Add mobile structured session read flow

This commit is contained in:
Brennan Benson
2026-08-09 18:22:23 -07:00
parent 76cce58478
commit b4bcd20caa
74 changed files with 3363 additions and 445 deletions
@@ -79,17 +79,17 @@ describe('codex item identity', () => {
it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => {
const ordinals = new CodexTurnOrdinals()
ordinals.ordinalFor(TURN_ID, 'item-0')
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
expect(ordinals.ordinalFor(TURN_ID, 'item-1')).toBe(1)
expect(ordinals.ordinalFor(TURN_ID, 'item-0')).toBe(0)
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1)
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0)
})
it('restarts numbering per turn', () => {
const ordinals = new CodexTurnOrdinals()
ordinals.ordinalFor(TURN_ID, 'item-0')
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
expect(ordinals.ordinalFor('turn-2', 'item-1')).toBe(0)
expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0)
})
it('keys a non-message item and a turnless message in the orca namespace', () => {
@@ -122,6 +122,24 @@ describe('codex item bodies', () => {
])
})
it('keeps provider image echoes in mixed user content', () => {
expect(
codexMessageBlocks({
type: 'userMessage',
id: 'm',
content: [
{ type: 'text', text: 'look' },
{ type: 'image', url: 'https://example.test/a.png' },
{ type: 'localImage', path: '/tmp/a.png' }
]
})
).toEqual([
{ type: 'text', text: 'look' },
{ type: 'image-ref', url: 'https://example.test/a.png' },
{ type: 'image-ref', path: '/tmp/a.png' }
])
})
it('maps a finished zero-exit command to a completed shell tool call', () => {
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
kind: 'tool-call',
@@ -163,6 +181,17 @@ describe('codex item bodies', () => {
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toBeNull()
})
it('renders array-shaped reasoning content', () => {
expect(
codexItemBody({
type: 'reasoning',
id: 'r',
summary: ['first', 'second'],
content: [{ text: 'fallback' }]
})
).toEqual({ kind: 'status', text: 'first\nsecond' })
})
it('refuses a value that is not a thread item at all', () => {
expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull()
expect(readCodexThreadItem(null)).toBeNull()
@@ -55,9 +55,10 @@ export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
export class CodexTurnOrdinals {
private readonly turns = new Map<string, Map<string, number>>()
ordinalFor(turnId: string, codexItemId: string): number {
const assigned = this.turns.get(turnId) ?? new Map<string, number>()
this.turns.set(turnId, assigned)
ordinalFor(threadId: string, turnId: string, codexItemId: string): number {
const turnKey = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
const assigned = this.turns.get(turnKey) ?? new Map<string, number>()
this.turns.set(turnKey, assigned)
const existing = assigned.get(codexItemId)
if (existing !== undefined) {
return existing
@@ -92,7 +93,7 @@ export function codexItemIdentity(input: {
provider: 'codex',
threadId: input.threadId,
turnId,
ordinal: input.ordinals.ordinalFor(turnId, item.id)
ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id)
}
}
return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` }
@@ -103,6 +104,28 @@ function readString(source: Record<string, unknown>, key: string): string | null
return typeof value === 'string' && value.length > 0 ? value : null
}
function readTextContent(source: Record<string, unknown>, key: string): string | null {
const direct = readString(source, key)
if (direct) {
return direct
}
const value = source[key]
if (!Array.isArray(value)) {
return null
}
const parts = value.flatMap((part) => {
if (typeof part === 'string') {
return part.length > 0 ? [part] : []
}
if (typeof part !== 'object' || part === null) {
return []
}
const text = readString(part as Record<string, unknown>, 'text')
return text ? [text] : []
})
return parts.length > 0 ? parts.join('\n') : null
}
/** `userMessage` carries structured content parts; `agentMessage` a flat text. */
export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
const text = readString(item, 'text')
@@ -121,6 +144,13 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
const partText = readString(part as Record<string, unknown>, 'text')
if (partText !== null) {
blocks.push({ type: 'text', text: partText })
continue
}
const record = part as Record<string, unknown>
if (record.type === 'image' && typeof record.url === 'string') {
blocks.push({ type: 'image-ref', url: record.url })
} else if (record.type === 'localImage' && typeof record.path === 'string') {
blocks.push({ type: 'image-ref', path: record.path })
}
}
return blocks
@@ -179,8 +209,13 @@ export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | nul
}
}
if (item.type === 'reasoning') {
const text = readString(item, 'text') ?? readString(item, 'summary')
return text === null ? null : { kind: 'status', text }
const text =
readTextContent(item, 'text') ??
readTextContent(item, 'summary') ??
readTextContent(item, 'content')
return text === null
? null
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
}
return null
}
@@ -20,7 +20,7 @@ type Row = { key: string; body: AgentJournalItemBody }
function recorder() {
const rows: Row[] = []
const bound: [string, string][] = []
const bound: [string, string, string][] = []
let publishes = 0
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity: AgentJournalItemIdentity, body) =>
@@ -35,8 +35,8 @@ function recorder() {
rows,
bound,
publishes: () => publishes,
bindPromptItemId: (journalItemId: string, promptKey: string) =>
bound.push([journalItemId, promptKey])
bindPromptItemId: (journalItemId: string, threadId: string, promptKey: string) =>
bound.push([journalItemId, threadId, promptKey])
}
}
@@ -206,7 +206,7 @@ describe('codex journal translation', () => {
const approval = tap.rows.at(-1)
expect(approval?.key).toBe('orca:codex-prompt%3Athread-abc%3Aitem-2')
expect(approval?.body).toMatchObject({ kind: 'approval', detail: 'rm -rf build' })
expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', 'item-2']])
expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', THREAD_ID, 'item-2']])
})
it('journals one row per approval when a tool item asks twice', () => {
@@ -241,7 +241,7 @@ describe('codex journal translation', () => {
])
// Both still name the command the shared item announced.
expect(approvals.every((row) => (row.body as { detail?: string }).detail === 'ls')).toBe(true)
expect(tap.bound.map(([, promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b'])
expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b'])
})
it('journals and binds one row per question in a user-input request', () => {
@@ -267,7 +267,7 @@ describe('codex journal translation', () => {
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq1',
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq2'
])
expect(tap.bound.map(([, promptKey]) => promptKey)).toEqual(['item-3', 'item-3'])
expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['item-3', 'item-3'])
})
it('starts a new turn at ordinal zero and refuses to adopt an ended turn', () => {
@@ -309,6 +309,59 @@ describe('codex journal translation', () => {
expect(tap.rows[0]?.key).toBe('codex:thread-abc:turn-9:0')
})
it('keeps interleaved thread turns, items, and deltas separate', () => {
const { translator, tap } = translatorWith()
const child = (method: string, params: unknown): CodexStructuredSessionEvent => ({
type: 'notification',
sessionId: SESSION_ID,
threadId: 'thread-child',
method,
params
})
translator.handle(TURN_STARTED)
translator.handle(child('turn/started', { threadId: 'thread-child', turnId: 'turn-child' }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-0', text: 'root' }
})
)
translator.handle(
child('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'child' } })
)
translator.handle(child('turn/completed', { turnId: 'turn-child' }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'still root' }
})
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'codex:thread-child:turn-child:0',
'codex:thread-abc:turn-1:1'
])
})
it('checkpoints long streams geometrically and flushes the final snapshot', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
for (let index = 0; index < 512; index += 1) {
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'x' }))
window.fire()
}
translator.flush()
expect(tap.rows.length).toBeLessThan(40)
expect(tap.rows.at(-1)?.body).toMatchObject({
blocks: [{ type: 'text', text: 'x'.repeat(512) }]
})
})
it('publishes after every write so a subscriber never trails the journal', () => {
const { translator, tap } = translatorWith()
@@ -19,6 +19,7 @@ import {
codexQuestionItems
} from './codex-structured-prompt-items'
import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies'
import { readCodexTurnId } from './codex-structured-thread-facts'
// The one place Codex events become journal rows.
//
@@ -32,13 +33,14 @@ const AGENT_MESSAGE_DELTA_METHOD = 'item/agentMessage/delta'
export type CodexJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
/** Points an answered journal item back at the live Codex request. */
bindPromptItemId?: (journalItemId: string, promptKey: string) => void
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
coalesceMs?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
export type CodexJournalTranslator = {
handle: (event: CodexStructuredSessionEvent) => void
flush: () => void
dispose: () => void
}
@@ -60,18 +62,47 @@ export function createCodexJournalTranslator(
const identities = new Map<string, AgentJournalItemIdentity>()
/** What each announced item is, so an approval can name what it approves. */
const details = new Map<string, string>()
let currentTurnId: string | null = null
const currentTurnIds = new Map<string, string>()
const latestStreamText = new Map<string, string>()
const checkpointLengths = new Map<string, number>()
const itemKey = (threadId: string, codexItemId: string): string =>
`${encodeURIComponent(threadId)}:${encodeURIComponent(codexItemId)}`
const persistStream = (key: string, text: string, force: boolean): void => {
latestStreamText.set(key, text)
const checkpointLength = checkpointLengths.get(key) ?? 0
const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125))
if (!force && checkpointLength > 0 && text.length < nextLength) {
return
}
checkpointLengths.set(key, text.length)
const identity = identities.get(key)
if (!identity) {
return
}
deps.sink.appendItem(identity, codexStreamingMessageBody(text))
deps.sink.publish()
}
const persistLatestStreams = (): void => {
for (const [key, text] of latestStreamText) {
if (checkpointLengths.get(key) !== text.length) {
persistStream(key, text, true)
}
}
}
const flushStreams = (): void => {
coalescer.flushAll()
persistLatestStreams()
}
const coalescer = createAgentSessionDeltaCoalescer({
windowMs: deps.coalesceMs,
schedule: deps.schedule,
emit: (codexItemId, text) => {
const identity = identities.get(codexItemId)
if (!identity) {
return
}
deps.sink.appendItem(identity, codexStreamingMessageBody(text))
deps.sink.publish()
emit: (key, text) => {
persistStream(key, text, false)
}
})
@@ -80,12 +111,13 @@ export function createCodexJournalTranslator(
turnId: string | null,
item: { type: string; id: string }
): AgentJournalItemIdentity => {
const existing = identities.get(item.id)
const key = itemKey(threadId, item.id)
const existing = identities.get(key)
if (existing) {
return existing
}
const identity = codexItemIdentity({ threadId, turnId, item, ordinals })
identities.set(item.id, identity)
identities.set(key, identity)
return identity
}
@@ -95,16 +127,18 @@ export function createCodexJournalTranslator(
if (!item) {
return
}
const turnId = readString(params, 'turnId') ?? currentTurnId
const turnId = readCodexTurnId(event.params) ?? currentTurnIds.get(event.threadId) ?? null
const identity = identityFor(event.threadId, turnId, item)
const body = codexItemBody(item)
const command = readString(item, 'command')
if (command) {
details.set(item.id, command)
details.set(itemKey(event.threadId, item.id), command)
}
if (event.method === 'item/completed') {
// The completed body is authoritative; the coalesced text is now stale.
coalescer.forget(item.id)
coalescer.forget(itemKey(event.threadId, item.id))
latestStreamText.delete(itemKey(event.threadId, item.id))
checkpointLengths.delete(itemKey(event.threadId, item.id))
}
if (!body) {
return
@@ -129,7 +163,11 @@ export function createCodexJournalTranslator(
params: event.params
})) {
deps.sink.appendItem(question.identity, question.body)
deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.promptKey)
deps.bindPromptItemId?.(
agentJournalItemKey(question.identity),
event.threadId,
event.promptKey
)
}
deps.sink.publish()
return
@@ -143,17 +181,17 @@ export function createCodexJournalTranslator(
codexApprovalItem({
method: event.method,
params: event.params,
detail: details.get(event.codexItemId) ?? null
detail: details.get(itemKey(event.threadId, event.codexItemId)) ?? null
})
)
deps.bindPromptItemId?.(agentJournalItemKey(identity), event.promptKey)
deps.bindPromptItemId?.(agentJournalItemKey(identity), event.threadId, event.promptKey)
deps.sink.publish()
}
return {
handle: (event) => {
if (event.type === 'ended') {
coalescer.flushAll()
flushStreams()
return
}
if (event.type === 'notification' && event.method === AGENT_MESSAGE_DELTA_METHOD) {
@@ -161,34 +199,41 @@ export function createCodexJournalTranslator(
const codexItemId = readString(params, 'itemId')
const delta = params.delta
if (codexItemId && typeof delta === 'string') {
coalescer.append(codexItemId, delta)
coalescer.append(itemKey(event.threadId, codexItemId), delta)
}
return
}
// Lifecycle bypass: nothing may be journaled ahead of the text it follows.
coalescer.flushAll()
flushStreams()
if (event.type === 'prompt') {
handlePrompt(event)
return
}
if (event.method === 'turn/started') {
currentTurnId = readString(readRecord(readRecord(event.params).turn), 'id')
const turnId = readCodexTurnId(event.params)
if (turnId) {
currentTurnIds.set(event.threadId, turnId)
}
return
}
if (event.method === 'turn/completed') {
// A later item with no turn of its own belongs to no turn, not to the
// one that just ended.
currentTurnId = null
currentTurnIds.delete(event.threadId)
return
}
if (event.method === 'item/started' || event.method === 'item/completed') {
handleItemEvent(event)
}
},
flush: flushStreams,
dispose: () => {
coalescer.dispose()
identities.clear()
details.clear()
currentTurnIds.clear()
latestStreamText.clear()
checkpointLengths.clear()
}
}
}
@@ -0,0 +1,6 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean {
return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null
}
@@ -67,6 +67,23 @@ describe('codex approval items', () => {
expect(item.detail).toBe('writes outside the workspace')
})
it('prefers the approval request command and describes file-change grants', () => {
expect(
codexApprovalItem({
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { command: ['git', 'status'] },
detail: 'parent command'
}).detail
).toBe('git status')
expect(
codexApprovalItem({
method: CODEX_FILE_CHANGE_APPROVAL_METHOD,
params: { grantRoot: '/outside' },
detail: null
}).detail
).toBe('"/outside"')
})
})
describe('codex question items', () => {
@@ -124,6 +141,29 @@ describe('codex question items', () => {
).toEqual([])
})
it('preserves a free-text path for null options and Other', () => {
const [withoutOptions, withOther] = codexQuestionItems({
threadId: THREAD_ID,
promptKey: CODEX_ITEM_ID,
params: {
questions: [
{ id: 'q1', question: 'Describe it', options: null },
{
id: 'q2',
question: 'Pick or type',
options: [{ label: 'Known' }, { label: 'Other', isOther: true }]
}
]
}
})
expect(withoutOptions?.body).toMatchObject({ options: [], freeTextQuestionId: 'q1' })
expect(withOther?.body).toMatchObject({
options: [{ id: 'q2:Known', label: 'Known' }],
freeTextQuestionId: 'q2'
})
})
it('keys an approval without a question id', () => {
expect(codexPromptIdentity({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID })).toEqual({
provider: 'orca',
@@ -75,12 +75,28 @@ export function codexApprovalItem(input: {
: input.method === CODEX_COMMAND_APPROVAL_METHOD
? 'Run a command?'
: 'Approve this action?',
detail: readString(params, 'reason') ?? input.detail,
detail: approvalDetail(params) ?? input.detail,
options: codexApprovalOptions(input.params),
resolution: { ...PENDING }
}
}
function approvalDetail(params: Record<string, unknown>): string | null {
const reason = readString(params, 'reason')
if (reason) {
return reason
}
const command = params.command
if (typeof command === 'string' && command.length > 0) {
return command
}
if (Array.isArray(command) && command.every((part) => typeof part === 'string')) {
return command.join(' ')
}
const detail = params.grantRoot ?? params.changes
return detail === undefined ? null : JSON.stringify(detail)
}
export type CodexQuestionItem = {
questionId: string
identity: AgentJournalItemIdentity
@@ -117,6 +133,7 @@ export function codexQuestionItems(input: {
kind: 'question',
question: prompt,
options: questionOptions(question, questionId),
...(questionAllowsFreeText(question) ? { freeTextQuestionId: questionId } : {}),
resolution: { ...PENDING }
}
})
@@ -124,6 +141,15 @@ export function codexQuestionItems(input: {
return items
}
function questionAllowsFreeText(question: Record<string, unknown>): boolean {
const options = question.options
return (
!Array.isArray(options) ||
options.length === 0 ||
options.some((option) => readParams(option).isOther === true)
)
}
function questionOptions(
question: Record<string, unknown>,
questionId: string
@@ -134,8 +160,9 @@ function questionOptions(
}
const mapped: AgentJournalPromptOption[] = []
for (const entry of options) {
const label = readString(readParams(entry), 'label')
if (label !== null) {
const option = readParams(entry)
const label = readString(option, 'label')
if (label !== null && option.isOther !== true) {
// The option id has to name its question: Codex's reply is a map keyed by
// question id, and the client only ever hands back an option id.
mapped.push({ id: encodeCodexQuestionOptionId(questionId, label), label })
@@ -72,7 +72,7 @@ describe('CodexPromptRegistry', () => {
it('addresses a prompt by its journal item id once bound, and forgets both', () => {
const registry = new CodexPromptRegistry()
const prompt = registry.register(userInputRequest(['q1']))
registry.bindJournalItemId('codex:thread-1:turn-1:2', 'codex-item-1')
registry.bindJournalItemId('codex:thread-1:turn-1:2', 'thread-1', 'codex-item-1')
expect(registry.find('codex:thread-1:turn-1:2')).toBe(prompt)
expect(registry.find('codex-item-1')).toBe(prompt)
@@ -81,6 +81,25 @@ describe('CodexPromptRegistry', () => {
expect(registry.find('codex:thread-1:turn-1:2')).toBeNull()
expect(registry.find('codex-item-1')).toBeNull()
})
it('keeps identical item ids on different threads independently answerable', () => {
const registry = new CodexPromptRegistry()
const register = (id: number, threadId: string) =>
registry.register({
id,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'item-2', threadId }
})
register(1, 'thread-root')
register(2, 'thread-child')
registry.bindJournalItemId('journal-root', 'thread-root', 'item-2')
registry.bindJournalItemId('journal-child', 'thread-child', 'item-2')
expect(registry.find('journal-root')?.requestId).toBe(1)
expect(registry.find('journal-child')?.requestId).toBe(2)
expect(registry.find('item-2')).toBeNull()
})
})
describe('applyCodexPromptAnswer', () => {
@@ -88,10 +88,14 @@ export function isCodexPromptMethod(method: string): boolean {
* became.
*/
export class CodexPromptRegistry {
private readonly byPromptKey = new Map<string, CodexPendingPrompt>()
/** Journal item id to prompt key. */
private readonly byAddress = new Map<string, CodexPendingPrompt>()
/** Journal item id to thread-scoped prompt address. */
private readonly journalItemIds = new Map<string, string>()
private address(threadId: string, promptKey: string): string {
return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}`
}
/** Returns null for a request this build does not model, so the caller can
* refuse it instead of leaving Codex blocked on an answer forever. */
register(request: {
@@ -115,33 +119,40 @@ export class CodexPromptRegistry {
request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [],
answers: new Map()
}
this.byPromptKey.set(prompt.promptKey, prompt)
this.byAddress.set(this.address(prompt.threadId, prompt.promptKey), prompt)
return prompt
}
/** Called by the translation module once the prompt has a journal id. */
bindJournalItemId(journalItemId: string, promptKey: string): void {
this.journalItemIds.set(journalItemId, promptKey)
bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void {
this.journalItemIds.set(journalItemId, this.address(threadId, promptKey))
}
/** Falls back to treating the id as a prompt key, which is what it is before
* any binding exists. */
find(journalItemId: string): CodexPendingPrompt | null {
const promptKey = this.journalItemIds.get(journalItemId) ?? journalItemId
return this.byPromptKey.get(promptKey) ?? null
const address = this.journalItemIds.get(journalItemId)
if (address) {
return this.byAddress.get(address) ?? null
}
const matches = [...this.byAddress.values()].filter(
(prompt) => prompt.promptKey === journalItemId
)
return matches.length === 1 ? matches[0]! : null
}
forget(prompt: CodexPendingPrompt): void {
this.byPromptKey.delete(prompt.promptKey)
for (const [journalItemId, promptKey] of this.journalItemIds) {
if (promptKey === prompt.promptKey) {
const address = this.address(prompt.threadId, prompt.promptKey)
this.byAddress.delete(address)
for (const [journalItemId, boundAddress] of this.journalItemIds) {
if (boundAddress === address) {
this.journalItemIds.delete(journalItemId)
}
}
}
clear(): void {
this.byPromptKey.clear()
this.byAddress.clear()
this.journalItemIds.clear()
}
}
@@ -10,6 +10,7 @@ import type {
CodexAppServerLaunch,
openCodexAppServerConnection
} from './codex-app-server-connection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies'
@@ -77,6 +78,7 @@ function fakeCodex(routes: Record<string, Route> = {}): {
respondWithError: (id, code, message) => connection.replies.push({ id, code, message }),
close: async () => {
connection.closeCount += 1
connection.closed = true
}
}
connections.push(connection)
@@ -276,6 +278,47 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
const bare = await acquired(withoutPath)
expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull()
})
it('lets closeAll cancel and reap an acquisition still opening', async () => {
const codex = fakeCodex()
let releaseOpen = (): void => {}
let markOpenEntered = (): void => {}
const gate = new Promise<void>((resolve) => {
releaseOpen = resolve
})
const openEntered = new Promise<void>((resolve) => {
markOpenEntered = resolve
})
const openConnection: typeof openCodexAppServerConnection = async (...args) => {
markOpenEntered()
await gate
return codex.openConnection(...args)
}
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const acquiring = adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
})
await openEntered
const closing = adapter.closeAll()
releaseOpen()
await expect(acquiring).rejects.toThrow('superseded while being acquired')
await closing
expect(codex.connections[0]?.closeCount).toBe(1)
})
})
describe('CodexStructuredSessionAdapter.dispatch', () => {
@@ -749,4 +792,40 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
expect(connection.replies).toEqual([])
})
it('flushes the final coalesced text before a graceful close', async () => {
const codex = fakeCodex()
const bodies: AgentJournalMessageItem[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (_identity, body) => {
if (body.kind === 'message') {
bodies.push(body)
}
},
appendTombstone: () => {},
publish: () => {}
}
const adapter = adapterFor(codex)
await adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9',
events: sink
})
const notify = codex.connections[0]!.handlers.onNotification
notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1' } })
notify?.('item/started', {
threadId: THREAD_ID,
item: { type: 'agentMessage', id: 'item-1', text: '' }
})
notify?.('item/agentMessage/delta', {
threadId: THREAD_ID,
itemId: 'item-1',
delta: 'last words'
})
await adapter.closeSession('session-1')
expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }])
})
})
@@ -8,14 +8,10 @@ import type {
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
createCodexJournalTranslator,
type CodexJournalTranslator
} from './codex-structured-journal-translation'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import {
isCodexAppServerRequestError,
openCodexAppServerConnection,
type CodexAppServerConnection,
type CodexAppServerServerRequest
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
@@ -24,11 +20,24 @@ import {
codexProcessIdentity,
codexProviderHandleLink
} from './codex-structured-owner-identity'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import { answerCodexPrompt, receiveCodexPromptRequest } from './codex-structured-prompt-replies'
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import { openCodexThread } from './codex-structured-thread-open'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
import {
createCodexAcquisitionAttempt,
type CodexAcquisitionAttempt,
type CodexSession,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
export type {
CodexStructuredLaunch,
CodexStructuredSessionAdapterDeps,
CodexStructuredSessionEvent
} from './codex-structured-session-state'
// The Codex half of the structured agent-session wire: one long-lived
// `codex app-server` child per session, started or resumed under the lease the
@@ -36,70 +45,14 @@ import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn
// belongs to the wire; this adapter only starts the process, names the turn the
// provider accepted, and answers Codex's blocking prompt requests.
export type CodexStructuredLaunch = {
command: string
args: string[]
/** Thread working directory, passed to Codex rather than to the child. */
cwd: string
/** Pinned account home. Null inherits whatever CODEX_HOME the host has. */
codexHome: string | null
/** Thread this session already proved. Null starts a new one. Taken from the
* durable handle chain, never from the client-declared identity. */
resumeThreadId: string | null
}
export type CodexStructuredSessionEvent =
| { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
| {
type: 'prompt'
sessionId: string
threadId: string
method: string
params: unknown
/** The tool item the prompt is about, which several prompts can share. */
codexItemId: string
/** What answers address; distinct per prompt even when `codexItemId` is not. */
promptKey: string
}
/** The child died without the host asking it to. The lease is now stale. */
| { type: 'ended'; sessionId: string; reason: string }
export type CodexStructuredSessionAdapterDeps = {
/** Command, args, cwd, and pinned home for one session. The wire's adapter
* contract carries none of these, so the runtime resolves them. */
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
/** Every Codex notification and prompt, in arrival order. The translation
* module subscribes here; without it the session still runs, unjournaled. */
onEvent?: (event: CodexStructuredSessionEvent) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
now?: () => number
requestTimeoutMs?: number
}
type CodexSession = {
connection: CodexAppServerConnection
threadId: string
historyPath: string | null
/** The registry the acquisition window opened; the session inherits it so a
* prompt that arrived before publication is still answerable. */
prompts: CodexAcquisitionWindow['prompts']
/** Applied to the next `turn/start`; Codex has no thread-settings write. */
options: Map<string, string>
turnIdWaiters: ((turnId: string) => void)[]
/** Absent when the caller supplied no sink; the session then runs unjournaled. */
translator: CodexJournalTranslator | null
}
export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, CodexSession>()
private readonly acquiring = new Map<string, CodexAcquisitionWindow>()
private readonly acquiring = new Map<string, CodexAcquisitionAttempt>()
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {}
supportsLocation = supportsCodexStructuredLocation
private get requestTimeoutMs(): number | undefined {
return this.deps.requestTimeoutMs
}
@@ -114,14 +67,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await this.closeSession(input.identity.sessionId)
const sessionId = input.identity.sessionId
const launch = await this.deps.resolveLaunch({ identity: input.identity })
const acquisition = new CodexAcquisitionWindow()
const attempt = createCodexAcquisitionAttempt()
const acquisition = attempt.window
// Registered before the spawn, because the handshake itself can emit.
this.acquiring.set(sessionId, acquisition)
this.acquiring.set(sessionId, attempt)
const translator = input.events
? createCodexJournalTranslator({
sink: input.events,
bindPromptItemId: (journalItemId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, promptKey)
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
: null
const open = this.deps.openConnection ?? openCodexAppServerConnection
@@ -170,7 +124,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
if (connection.closed) {
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
}
if (this.acquiring.get(sessionId) !== acquisition) {
if (attempt.cancelled || this.acquiring.get(sessionId) !== attempt) {
throw new Error(`codex session ${sessionId} was superseded while being acquired`)
}
this.acquiring.delete(sessionId)
@@ -188,7 +142,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
return acquired
} catch (error) {
if (this.acquiring.get(sessionId) === acquisition) {
if (this.acquiring.get(sessionId) === attempt) {
this.acquiring.delete(sessionId)
}
// Reap this attempt's child only. A replacement already published for the
@@ -198,12 +152,18 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
await acquisition.connection?.close()
}
throw error
} finally {
attempt.finish()
}
}
/** Buffers an event that arrived before the session was published, and drops
* one from a connection this session has already replaced. */
private deliver(acquisition: CodexAcquisitionWindow, sessionId: string, event: () => void): void {
private deliver(
acquisition: CodexAcquisitionAttempt['window'],
sessionId: string,
event: () => void
): void {
if (acquisition.buffer(event)) {
return
}
@@ -214,10 +174,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Only the connection that currently owns the session may retire it, or a
* superseded child's death would evict its live replacement. */
private handleExit(sessionId: string, acquisition: CodexAcquisitionWindow, error: Error): void {
private handleExit(
sessionId: string,
acquisition: CodexAcquisitionAttempt['window'],
error: Error
): void {
acquisition.prompts.clear()
const session = this.sessions.get(sessionId)
if (session?.connection !== acquisition.connection) {
if (!session || session.connection !== acquisition.connection) {
return
}
this.sessions.delete(sessionId)
@@ -272,7 +236,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Lets the translation module address a live prompt by the journal item it
* became; until then the prompt key addresses it directly. */
bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void {
this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey)
const session = this.sessions.get(sessionId)
session?.prompts.bindJournalItemId(journalItemId, session.threadId, promptKey)
}
async dispatch(input: {
@@ -340,21 +305,30 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
/** Reaps one session's child. The proven handle chain is already durable, so
* a graceful close loses nothing. */
async closeSession(sessionId: string): Promise<void> {
// An attempt still acquiring is superseded by this close: dropping its slot
// fails its publication check, so it reaps the child it spawned.
this.acquiring.delete(sessionId)
const attempt = this.acquiring.get(sessionId)
if (attempt) {
attempt.cancelled = true
await attempt.window.connection?.close()
await attempt.finished
}
const session = this.sessions.get(sessionId)
if (!session) {
return
}
this.sessions.delete(sessionId)
session.prompts.clear()
session.translator?.flush()
session.translator?.dispose()
await session.connection.close()
}
async closeAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((sessionId) => this.closeSession(sessionId)))
const sessionIds = new Set([...this.sessions.keys(), ...this.acquiring.keys()])
await Promise.all([...sessionIds].map((sessionId) => this.closeSession(sessionId)))
}
releaseAcquisition(input: { sessionId: string }): Promise<void> {
return this.closeSession(input.sessionId)
}
private session(sessionId: string): CodexSession {
@@ -0,0 +1,65 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
CodexAppServerConnection,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
export type CodexStructuredLaunch = {
command: string
args: string[]
cwd: string
codexHome: string | null
resumeThreadId: string | null
}
export type CodexStructuredSessionEvent =
| { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
| {
type: 'prompt'
sessionId: string
threadId: string
method: string
params: unknown
codexItemId: string
promptKey: string
}
| { type: 'ended'; sessionId: string; reason: string }
export type CodexStructuredSessionAdapterDeps = {
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
onEvent?: (event: CodexStructuredSessionEvent) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
now?: () => number
requestTimeoutMs?: number
}
export type CodexSession = {
connection: CodexAppServerConnection
threadId: string
historyPath: string | null
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
turnIdWaiters: ((turnId: string) => void)[]
translator: CodexJournalTranslator | null
}
export type CodexAcquisitionAttempt = {
window: CodexAcquisitionWindow
cancelled: boolean
finished: Promise<void>
finish: () => void
}
export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt {
let finish = (): void => {}
const finished = new Promise<void>((resolve) => {
finish = resolve
})
return { window: new CodexAcquisitionWindow(), cancelled: false, finished, finish }
}
+3
View File
@@ -267,6 +267,7 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime'
import { quitTeardownStartGate } from './quit-teardown-start-gate'
import { beginSshShutdown } from './ipc/ssh'
import { PluginService } from './plugins/plugin-service'
@@ -3109,6 +3110,7 @@ app.on('will-quit', (e) => {
pluginMarketplaceInstaller = null
const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()
const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries()
const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()
pluginService = null
setUnreadDockBadgeCount(0)
agentHookServer.stop()
@@ -3169,6 +3171,7 @@ app.on('will-quit', (e) => {
{ name: 'ssh', promise: sshShutdown },
{ name: 'plugin-hosts', promise: pluginHostShutdown },
{ name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown },
{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown },
{ name: 'usage-cache', promise: usageCacheFlush },
{ name: 'stats', promise: statsFlush },
{ name: 'state', promise: storeFlush }
@@ -91,6 +91,7 @@ describe('readAgentSessionHistory', () => {
expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')])
expect(tail.page.hasOlder).toBe(true)
expect(tail.page.hasNewer).toBe(false)
expect(tail.page.liveCursor).toEqual(journal.cursor())
const older = readAgentSessionHistory(journal, {
sessionId: 'session-1',
@@ -140,6 +140,7 @@ function buildPage(input: {
newest: newest ? { epoch, sequence: newest.sequence } : null,
nextCursor: input.nextCursor ?? input.fallbackCursor
},
liveCursor: input.snapshot.cursor,
hasOlder: input.hasOlder,
hasNewer: input.hasNewer
}
@@ -12,7 +12,10 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record'
import type {
AgentSessionExecutionLocation,
AgentSessionProcessIdentity
} from '../../../shared/agent-session-record'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
/** What a reservation turns into once something is actually running under it:
@@ -30,6 +33,8 @@ export type AgentSessionDispatchOutcome =
| { state: 'unknown'; reason: string }
export type StructuredAgentSessionAdapter = {
/** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */
supportsLocation?(location: AgentSessionExecutionLocation): boolean
/** Makes the reservation real. Called once per reservation, with the spawn
* token the lease was reserved under and the fence the handle must be minted
* at — the store rejects a link minted at any other fence. */
@@ -43,6 +48,8 @@ export type StructuredAgentSessionAdapter = {
* adapter that only answers calls needs no journal at all. */
events?: StructuredAgentSessionEventSink
}): Promise<AgentSessionAcquisition>
/** Reaps an acquired provider when the host cannot commit or prove its lease. */
releaseAcquisition?(input: { sessionId: string }): Promise<void>
dispatch(input: {
sessionId: string
clientMessageId: string
@@ -41,6 +41,8 @@ export type AttachFlowInput = {
/** Handed to the adapter so it can journal what the provider streams. The
* host owns it and binds it to the journal inside `onAttached`. */
eventSink?: StructuredAgentSessionEventSink
/** Stops acquisition-window events targeting the superseded journal. */
onAcquiring?: () => void
}
export async function performAttach(
@@ -117,27 +119,37 @@ async function acquireOwner(
if (!spawnToken) {
throw new Error('agent_session_ownership_unknown')
}
input.onAcquiring?.()
const acquired = await input.adapter.acquire({
identity: journalIdentityFor(record.sessionId, input.params),
identity: journalIdentityFor(record, input.params),
fence,
// Retries must recover the original reservation, not mint a second child.
spawnToken,
...(input.eventSink ? { events: input.eventSink } : {})
})
if (record.lease.ownerProcess === null) {
await input.store.commitProcessIdentity({
try {
if (record.lease.ownerProcess === null) {
await input.store.commitProcessIdentity({
sessionId: record.sessionId,
fence,
process: acquired.process,
now: input.now()
})
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
throw new Error('agent_session_ownership_unknown')
}
return await input.store.proveOwner({
sessionId: record.sessionId,
fence,
process: acquired.process,
link: acquired.link,
now: input.now()
})
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
throw new Error('agent_session_ownership_unknown')
} catch (error) {
try {
await input.adapter.releaseAcquisition?.({ sessionId: record.sessionId })
} catch {
// Preserve the lease failure; cleanup is best-effort and idempotent.
}
throw error
}
return input.store.proveOwner({
sessionId: record.sessionId,
fence,
link: acquired.link,
now: input.now()
})
}
@@ -29,6 +29,7 @@ import {
computeAgentSessionPayloadFingerprint
} from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
@@ -50,7 +51,8 @@ export type AgentSessionAttachParams = {
agent: AgentType
accountHome: AgentSessionAccountHome
runtimeKind: AgentSessionOwnerRuntimeKind
providerHandle: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
/** Omitted only for create-by-intent; the adapter proves the durable handle. */
providerHandle?: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
}
/** Host-supplied half of the reservation. */
@@ -81,7 +83,7 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor
export function admitAttachOrRefuse(
params: AgentSessionAttachParams
): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } {
if (params.providerHandle.kind !== params.provider) {
if (params.providerHandle && params.providerHandle.kind !== params.provider) {
return {
ok: false,
refusal: {
@@ -100,15 +102,26 @@ export function admitAttachOrRefuse(
}
export function journalIdentityFor(
sessionId: string,
record: AgentSessionRecord,
params: AgentSessionAttachParams
): AgentSessionJournalIdentity {
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
const providerHandle: AgentSessionProviderHandle =
head?.handle.provider === 'codex'
? { kind: 'codex', threadId: head.handle.threadId }
: head?.handle.provider === 'claude'
? {
kind: 'claude',
sessionId: head.handle.sessionId,
leafUuid: head.handle.leafUuid
}
: (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' })
return {
sessionId,
sessionId: record.sessionId,
workspaceId: params.location.workspaceId,
hostId: params.location.executionHostId,
agent: params.agent,
providerHandle: params.providerHandle
providerHandle
}
}
@@ -130,7 +143,7 @@ export async function attachJournal(input: {
journalRoot: string
adapter: StructuredAgentSessionAdapter
}): Promise<AttachedJournal> {
const identity = journalIdentityFor(input.record.sessionId, input.params)
const identity = journalIdentityFor(input.record, input.params)
const fence = input.record.lease.runtimeFence
const historyFilePath = input.adapter.historyFilePath
? await input.adapter.historyFilePath({ identity })
@@ -84,6 +84,20 @@ describe('deferred structured agent-session event sink', () => {
])
})
it('buffers replacement-acquisition events while unbound', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind(target(1, log))
deferred.unbind()
deferred.sink.appendItem(identity(0), BODY)
expect(log).toEqual([])
deferred.bind(target(2, log))
await deferred.drained()
expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }])
})
it('drops buffered and later writes once closed, and refuses to rebind', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink()
@@ -40,6 +40,8 @@ export type DeferredStructuredAgentSessionEventSink = {
/** Drains everything buffered so far, in order, then writes through. Called
* again on every re-attach to re-point the sink at the new journal. */
bind(target: StructuredAgentSessionEventTarget): void
/** Queues new provider events until a replacement journal is bound. */
unbind(): void
/** Permanently stops the sink. Queued writes are dropped rather than landing
* in a journal the host has already let go of. */
close(): void
@@ -106,6 +108,9 @@ export function createDeferredStructuredAgentSessionEventSink(
enqueue(operation)
}
},
unbind: () => {
target = null
},
close: () => {
closed = true
buffered.length = 0
@@ -118,6 +118,8 @@ function ensureParams(fence: number): AgentSessionAttachParams {
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
let releaseAcquisition: Mock<NonNullable<StructuredAgentSessionAdapter['releaseAcquisition']>>
let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
let answerPrompt: Mock<StructuredAgentSessionAdapter['answerPrompt']>
@@ -134,23 +136,8 @@ function accepted(): AgentSessionDispatchOutcome {
function adapter(): StructuredAgentSessionAdapter {
return {
acquire: async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_700_000_000_000,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
// A restarted host re-proves the thread it inherited; only the first
// owner of a session may claim to have created it.
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: NOW
}
}),
acquire,
releaseAcquisition,
dispatch,
cancelTurn,
answerPrompt,
@@ -197,6 +184,24 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
operations = 0
ordinal = 0
acquire = vi.fn(async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_700_000_000_000,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
// A restarted host re-proves the thread it inherited; only the first
// owner of a session may claim to have created it.
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: NOW
}
}))
releaseAcquisition = vi.fn(async () => undefined)
dispatch = vi.fn(async () => accepted())
cancelTurn = vi.fn(async () => ({ cancelled: true }))
answerPrompt = vi.fn(async () => undefined)
@@ -353,6 +358,15 @@ describe('attach', () => {
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true })
expect(acquire).toHaveBeenCalledTimes(2)
expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live')
expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION })
})
it('reaps an acquisition when process identity commit fails', async () => {
vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed'))
await expect(host.attach(CALLER, attachParams())).rejects.toThrow('commit failed')
expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION })
})
})
@@ -660,6 +674,23 @@ describe('restart', () => {
expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242)
})
it('restores durable journals for read-only history without acquiring a provider', async () => {
await attach()
const body = message('persisted conversation')
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' }))
acquire.mockClear()
await host.restoreReadableSessions()
expect(host.listSessionTabs()).toEqual([
{ sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' }
])
const history = host.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok && history.page.items).not.toHaveLength(0)
expect(acquire).not.toHaveBeenCalled()
})
it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => {
await attach()
const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
@@ -13,7 +13,10 @@ import type {
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionExecutionLocation,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type {
AgentSessionAttachResult,
AgentSessionCancelResult,
@@ -53,6 +56,7 @@ import {
AgentSessionSubscribers,
type AgentSessionSubscriberEmit
} from './structured-agent-session-subscribers'
import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
export type StructuredAgentSessionCaller = {
/** Stable per-client identity; scopes the operation ledger and records who
@@ -102,6 +106,45 @@ export class StructuredAgentSessionHost {
return this.sessions.has(sessionId)
}
supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean {
return agent === 'codex' && (this.deps.adapter.supportsLocation?.(location) ?? false)
}
listSessionTabs(): {
sessionId: string
workspaceId: string
agent: 'codex'
}[] {
return [...this.sessions.entries()].map(([sessionId, session]) => ({
sessionId,
workspaceId: session.params.location.workspaceId,
agent: 'codex' as const
}))
}
async restoreReadableSessions(): Promise<void> {
await Promise.all(
this.deps.store
.listRecords()
.filter((record) => record.provider === 'codex')
.map((record) =>
this.serialize(record.sessionId, async () => {
if (this.sessions.has(record.sessionId)) {
return
}
const restored = await restoreStructuredAgentSessionRead(
this.deps.store,
this.deps.journalRoot,
record.sessionId
)
if (restored) {
this.sessions.set(record.sessionId, restored)
}
})
)
)
}
/** Per-session single file. A rejected task must not poison the chain. */
private serialize<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
const prior = this.chains.get(sessionId) ?? Promise.resolve()
@@ -133,6 +176,7 @@ export class StructuredAgentSessionHost {
adapter: this.deps.adapter,
journalRoot: this.deps.journalRoot,
eventSink: eventSink.sink,
onAcquiring: () => eventSink.unbind(),
authority: {
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
claimKeyId: this.deps.claimKeyId,
@@ -0,0 +1,59 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { loadJournal } from '../agent-session-journal/journal-open'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import {
openAgentSessionJournal,
type AgentSessionJournal
} from '../agent-session-journal/journal-store'
import {
journalIdentityFor,
type AgentSessionAttachParams
} from './structured-agent-session-attach'
export type RestoredStructuredAgentSessionRead = {
journal: AgentSessionJournal
params: AgentSessionAttachParams
fence: number
}
export async function restoreStructuredAgentSessionRead(
store: AgentSessionRecordStore,
journalRoot: string,
sessionId: string
): Promise<RestoredStructuredAgentSessionRead | null> {
const record = store.getRecord(sessionId)
if (!record || record.provider !== 'codex') {
return null
}
const params = attachParamsForReadableRestore(record)
const journalDir = journalDirectoryFor(journalRoot, {
workspaceId: record.location.workspaceId,
sessionId
})
const loaded = await loadJournal(journalDir, sessionId)
if (!loaded || loaded.corrupt) {
return null
}
const journal = await openAgentSessionJournal({
identity: journalIdentityFor(record, params),
journalDir
})
return { journal, params, fence: record.lease.runtimeFence }
}
function attachParamsForReadableRestore(record: AgentSessionRecord): AgentSessionAttachParams {
return {
envelope: {
sessionId: record.sessionId,
clientOperationId: `read-restore:${record.sessionId}`,
expectedRuntimeFence: record.lease.runtimeFence,
payloadFingerprint: ''
},
location: record.location,
provider: 'codex',
agent: 'codex',
accountHome: record.accountHome,
runtimeKind: record.lease.runtimeKind
}
}
@@ -12,6 +12,7 @@ import type {
AgentJournalResolution
} from '../../../shared/agent-session-journal-types'
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies'
import type {
AgentSessionCancelResult,
AgentSessionOptionResult,
@@ -137,9 +138,11 @@ export async function performCancel(
return { ok: true, value: { turnId: input.turnId, cancelled } }
}
function promptBodyOf(
body: AgentJournalItemBody
): { options: readonly { id: string }[]; resolution: AgentJournalResolution } | null {
function promptBodyOf(body: AgentJournalItemBody): {
options: readonly { id: string }[]
freeTextQuestionId?: string
resolution: AgentJournalResolution
} | null {
return body.kind === 'approval' || body.kind === 'question' ? body : null
}
@@ -188,7 +191,13 @@ export async function performPrompt(
}
}
}
if (!prompt.options.some((option) => option.id === input.optionId)) {
const freeText = decodeCodexQuestionOptionId(input.optionId)
const acceptsFreeText =
item.body.kind === 'question' &&
prompt.freeTextQuestionId !== undefined &&
freeText?.questionId === prompt.freeTextQuestionId &&
freeText.answer.trim().length > 0
if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) {
return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`)
}
const identity = parseAgentJournalItemKey(input.itemId)
@@ -43,6 +43,7 @@ const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
// Why: shared-control unsubscribe methods are sent from generated cleanup
// paths, so literal mobile source scanning cannot discover every one.
'accounts.unsubscribe',
'agentSession.unsubscribe',
'browser.screencast.unsubscribe',
'notifications.unsubscribe',
'runtime.clientEvents.unsubscribe',
+206 -6
View File
@@ -70,7 +70,10 @@ import {
type AgentSessionClaimSigner
} from './agent-session-claim-identity'
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import {
agentSessionPtyWriteGate,
type AgentSessionPtyWriteAdmittance
@@ -337,6 +340,7 @@ import {
type RuntimeMarkdownReadTabResult,
type RuntimeMarkdownSaveTabResult,
type RuntimeMobileSessionCreateTerminalResult,
type RuntimeMobileSessionAgentTab,
type RuntimeMobileSessionClientTab,
type RuntimeMobileSessionTabCloseResult,
type RuntimeMobileSessionMarkdownTab,
@@ -8767,6 +8771,25 @@ export class OrcaRuntimeService {
// Why: clients reorder the sanitized session.tabs.list model; raw groups
// can still contain stale browser ids hidden from paired web clients.
.filter((tabId) => returnedIds.has(tabId))
const structuredIds = expected.filter((tabId) =>
snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId)
)
if (structuredIds.some((tabId) => !seen.has(tabId))) {
if (structuredIds.some((tabId) => seen.has(tabId))) {
throw new Error('invalid_tab_order')
}
const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId))
if (
normalized.length !== visibleExpected.length ||
visibleExpected.some((tabId) => !seen.has(tabId))
) {
throw new Error('invalid_tab_order')
}
for (const tabId of structuredIds) {
normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId)
}
return normalized
}
// Why: reorder is a pure permutation of one existing group. Missing or
// extra ids would let a paired web client silently move/lose host tabs.
if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) {
@@ -9168,6 +9191,150 @@ export class OrcaRuntimeService {
})
}
async getStructuredAgentSessionCreateSupport(
worktreeSelector: string,
agent: 'codex'
): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> {
if (agent !== 'codex') {
return { supported: false, reason: 'agent' }
}
const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector)
await this.ensureStructuredAgentSessionHost()
if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) {
return { supported: true }
}
return {
supported: false,
reason:
location.executionHostId !== LOCAL_EXECUTION_HOST_ID
? 'remote'
: location.wslDistro
? 'wsl'
: 'agent'
}
}
private async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
const repo = this.store?.getRepo(target.worktree.repoId)
const wslDistro =
repo && !target.connectionId
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
: null
const folderWorkspace = this.store
?.getFolderWorkspaces?.()
.some((workspace) => workspace.id === target.worktree.id)
return {
executionHostId: getRuntimeFileTargetExecutionHostId({
worktree: target.worktree,
connectionId: target.connectionId
}),
wslDistro,
workspaceId: target.worktree.id,
workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const)
}
}
async resolveStructuredAgentSessionCreateIntent(input: {
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'codex'
}): Promise<AgentSessionAttachParams> {
const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent)
if (!support.supported) {
throw new Error('structured_agent_session_unsupported')
}
const settings = this.requireStore().getSettings()
const configuredHome = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv).CODEX_HOME
const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
return {
envelope: {
sessionId: input.envelope.sessionId,
clientOperationId: input.envelope.clientOperationId,
expectedRuntimeFence: null,
payloadFingerprint: ''
},
location,
provider: 'codex',
agent: 'codex',
accountHome: {
variable: 'CODEX_HOME',
path: configuredHome?.trim() || getSystemCodexHomePath()
},
runtimeKind: 'native'
}
}
async restoreStructuredAgentSessionTabs(): Promise<void> {
await this.ensureStructuredAgentSessionHost()
const host = getStructuredAgentSessionHost()
await host?.restoreReadableSessions()
for (const session of host?.listSessionTabs() ?? []) {
this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
}
}
publishStructuredAgentSessionTab(input: {
workspaceId: string
sessionId: string
agent: 'codex'
activate: boolean
notify?: boolean
}): void {
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
const id = `agent-session:${input.sessionId}`
if (existing?.tabs.some((tab) => tab.id === id)) {
return
}
const tab: RuntimeMobileSessionAgentTab = {
type: 'agent-session',
id,
title: 'Codex Chat',
sessionId: input.sessionId,
agent: input.agent,
isActive: input.activate
}
const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({
...candidate,
isActive: input.activate ? false : candidate.isActive
}))
tabs.push(tab)
const priorGroups = existing?.tabGroups ?? [
{
id: this.getHeadlessMobileSessionGroupId(input.workspaceId),
activeTabId: existing?.activeTabId ?? null,
tabOrder: []
}
]
const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId)
? existing!.activeGroupId!
: priorGroups[0]!.id
const tabGroups = priorGroups.map((group) =>
group.id === groupId
? {
...group,
activeTabId: input.activate ? id : group.activeTabId,
tabOrder: [...group.tabOrder, id]
}
: group
)
const snapshot: RuntimeMobileSessionTabsSnapshot = {
worktree: input.workspaceId,
publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId),
activeTabId: input.activate ? id : (existing?.activeTabId ?? null),
activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null),
tabGroups,
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
}
}
private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{
worktree: ResolvedWorktree
repo?: Repo
@@ -30010,6 +30177,12 @@ export class OrcaRuntimeService {
const terminalTabs = tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
const tabGroups = this.mergeMobileSessionTabGroups(
snapshot.worktree,
snapshot.tabGroups ?? existing.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
)
return {
...snapshot,
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(
@@ -30020,16 +30193,40 @@ export class OrcaRuntimeService {
activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
tabGroups: this.mergeMobileSessionTabGroups(
snapshot.worktree,
snapshot.tabGroups ?? existing.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
tabGroups: this.mergeStructuredAgentSessionTabGroups(
tabGroups,
existing.tabGroups ?? [],
normalizedPreservedTabs,
activeTab?.id ?? null
),
tabs
}
}
private mergeStructuredAgentSessionTabGroups(
groups: readonly RuntimeMobileSessionTabGroup[],
existingGroups: readonly RuntimeMobileSessionTabGroup[],
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[],
activeTabId: string | null
): RuntimeMobileSessionTabGroup[] {
const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session')
if (structuredTabs.length === 0) {
return [...groups]
}
const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
for (const tab of structuredTabs) {
const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id
const target = next.find((group) => group.id === priorGroupId) ?? next[0]
if (target && !target.tabOrder.includes(tab.id)) {
target.tabOrder.push(tab.id)
}
if (target && tab.id === activeTabId) {
target.activeTabId = tab.id
}
}
return next
}
private buildPreservedHeadlessMobileSessionSnapshot(
existing: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot | null {
@@ -30100,6 +30297,9 @@ export class OrcaRuntimeService {
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionSnapshotTab
): boolean {
if (tab.type === 'agent-session') {
return true
}
// Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph".
if (tab.type === 'browser') {
if (!this.offscreenBrowserBackend) {
@@ -30412,7 +30612,7 @@ export class OrcaRuntimeService {
})
continue
}
if (tab.type === 'markdown' || tab.type === 'file') {
if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') {
tabs.push(tab)
continue
}
@@ -0,0 +1,22 @@
import { defineMethod, type RpcAnyMethod } from '../core'
import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas'
export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'markdown.readTab',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.readMobileMarkdownTab(params.worktree, params.tabId)
}),
defineMethod({
name: 'markdown.saveTab',
params: SaveMarkdownTab,
handler: async (params, { runtime }) =>
runtime.saveMobileMarkdownTab(
params.worktree,
params.tabId,
params.baseVersion,
params.content
)
})
]
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
@@ -34,6 +37,66 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho
}
describe('projectSessionTabAgentStatus', () => {
it('projects structured tabs and dangling group focus out of old clients', () => {
const snapshot: RuntimeMobileSessionTabsSnapshot = {
...makeSnapshot(false),
activeGroupId: 'group-a',
activeTabId: 'agent-session:session-a',
activeTabType: 'agent-session',
tabGroups: [
{
id: 'group-a',
activeTabId: 'agent-session:session-a',
tabOrder: ['tab-1::leaf-1', 'agent-session:session-a'],
recentTabIds: ['agent-session:session-a', 'tab-1::leaf-1']
},
{
id: 'group-b',
activeTabId: 'agent-session:session-b',
tabOrder: ['agent-session:session-b']
}
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-b' }
},
tabs: [
{ ...makeSnapshot(false).tabs[0]!, isActive: false },
{
type: 'agent-session',
id: 'agent-session:session-a',
title: 'Codex Chat',
sessionId: 'session-a',
agent: 'codex',
isActive: true
},
{
type: 'agent-session',
id: 'agent-session:session-b',
title: 'Codex Chat',
sessionId: 'session-b',
agent: 'codex',
isActive: false
}
]
}
const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', [])
expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal'])
expect(oldClient.activeTabId).toBe('tab-1::leaf-1')
expect(oldClient.activeTabType).toBe('terminal')
expect(oldClient.tabs[0]?.isActive).toBe(true)
expect(oldClient.tabGroups?.[0]?.tabOrder).toEqual(['tab-1::leaf-1'])
expect(oldClient.tabGroups).toHaveLength(1)
expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' })
const capable = projectSessionTabAgentStatus(snapshot, 'mobile', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
expect(capable).toBe(snapshot)
})
it('withholds session boundaries from legacy paired clients', () => {
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [])
@@ -1,9 +1,13 @@
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../../../shared/types'
type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot
@@ -12,16 +16,20 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: readonly RuntimeCapability[] | undefined
): TPayload {
const structuredVisible =
clientKind === undefined ||
(clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)
const projected = structuredVisible ? payload : projectStructuredTabsOut(payload)
// Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape.
if (
clientKind !== 'runtime' ||
clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY)
) {
return payload
return projected
}
let changed = false
const tabs = payload.tabs.map((tab) => {
const tabs = projected.tabs.map((tab) => {
if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) {
return tab
}
@@ -29,5 +37,67 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
const { agentStatus: _boundary, ...legacyTab } = tab
return legacyTab
})
return changed ? ({ ...payload, tabs } as TPayload) : payload
return changed ? ({ ...projected, tabs } as TPayload) : projected
}
function projectStructuredTabsOut<TPayload extends SessionTabsPayload>(
payload: TPayload
): TPayload {
const hiddenIds = new Set(
payload.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
)
if (hiddenIds.size === 0) {
return payload
}
const tabs = payload.tabs.filter((tab) => !hiddenIds.has(tab.id))
const groups = payload.tabGroups
?.map((group) => {
const tabOrder = group.tabOrder.filter((id) => !hiddenIds.has(id))
if (tabOrder.length === 0) {
return null
}
const recentTabIds = group.recentTabIds?.filter((id) => !hiddenIds.has(id))
return {
...group,
activeTabId:
group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (recentTabIds?.find((id) => tabOrder.includes(id)) ?? tabOrder[0] ?? null),
tabOrder,
...(recentTabIds ? { recentTabIds } : {})
}
})
.filter((group): group is NonNullable<typeof group> => group !== null)
const active =
tabs.find((tab) => tab.id === payload.activeTabId) ??
tabs.find((tab) => tab.isActive) ??
tabs[0] ??
null
const validGroupIds = new Set(groups?.map((group) => group.id) ?? [])
return {
...payload,
activeGroupId:
groups?.find((group) => group.tabOrder.includes(active?.id ?? ''))?.id ??
groups?.[0]?.id ??
null,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(groups ? { tabGroups: groups } : { tabGroups: undefined }),
...(payload.tabGroupLayout !== undefined
? { tabGroupLayout: pruneStructuredTabGroupLayout(payload.tabGroupLayout, validGroupIds) }
: {}),
tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id }))
} as TPayload
}
function pruneStructuredTabGroupLayout(
layout: TabGroupLayoutNode | null,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout || layout.type === 'leaf') {
return layout && validGroupIds.has(layout.groupId) ? layout : null
}
const first = pruneStructuredTabGroupLayout(layout.first, validGroupIds)
const second = pruneStructuredTabGroupLayout(layout.second, validGroupIds)
return first && second ? { ...layout, first, second } : (first ?? second)
}
@@ -13,6 +13,17 @@ function setMobileSessionSnapshot(
).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
}
function getMobileSessionSnapshot(
runtime: OrcaRuntimeService,
worktree: string
): RuntimeMobileSessionTabsSnapshot | undefined {
return (
runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
).mobileSessionTabsByWorktree.get(worktree)
}
function terminalTab() {
return {
type: 'terminal' as const,
@@ -50,6 +61,154 @@ function browserTab({
}
describe('session tab move validation', () => {
it('preserves a structured tab across renderer-authored snapshot sync', () => {
const runtime = new OrcaRuntimeService()
const structured = {
type: 'agent-session' as const,
id: 'agent-session:session-a',
title: 'Codex Chat',
sessionId: 'session-a',
agent: 'codex' as const,
isActive: false
}
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'structured-epoch',
snapshotVersion: 2,
activeGroupId: 'group-1',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'terminal-tab',
tabOrder: ['terminal-tab', structured.id]
}
],
tabs: [terminalTab(), structured]
})
const incoming: RuntimeMobileSessionTabsSnapshot = {
worktree: 'wt-1',
publicationEpoch: 'renderer-epoch',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }],
tabs: [terminalTab()]
}
;(
runtime as unknown as {
syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): Set<string>
}
).syncMobileSessionTabs([incoming])
const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
expect(snapshot?.tabs).toContainEqual(structured)
expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab', 'agent-session:session-a'])
})
it('publishes a structured tab into the active group instead of the first group', () => {
const runtime = new OrcaRuntimeService()
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-2',
activeTabId: 'file-tab',
activeTabType: 'file',
tabGroups: [
{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] },
{ id: 'group-2', activeTabId: 'file-tab', tabOrder: ['file-tab'] }
],
tabs: [
terminalTab(),
{
type: 'file',
id: 'file-tab',
title: 'README.md',
filePath: 'README.md',
relativePath: 'README.md',
language: 'markdown',
isDirty: false,
isActive: true
}
]
})
runtime.publishStructuredAgentSessionTab({
workspaceId: 'wt-1',
sessionId: 'session-a',
agent: 'codex',
activate: true
})
const snapshot = getMobileSessionSnapshot(runtime, 'wt-1')
expect(snapshot?.activeGroupId).toBe('group-2')
expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
expect(snapshot?.tabGroups?.[1]).toMatchObject({
activeTabId: 'agent-session:session-a',
tabOrder: ['file-tab', 'agent-session:session-a']
})
})
it('preserves a capability-hidden structured tab during an old-client reorder', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
runtime.setNotifier({ moveSessionTab } as never)
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'terminal-tab',
tabOrder: ['terminal-tab', 'agent-session:session-a', 'file-tab']
}
],
tabs: [
terminalTab(),
{
type: 'agent-session',
id: 'agent-session:session-a',
title: 'Codex Chat',
sessionId: 'session-a',
agent: 'codex',
isActive: false
},
{
type: 'file',
id: 'file-tab',
title: 'README.md',
filePath: 'README.md',
relativePath: 'README.md',
language: 'markdown',
isDirty: false,
isActive: false
}
]
})
await runtime.moveMobileSessionTab('id:wt-1', {
kind: 'reorder',
tabId: 'file-tab',
targetGroupId: 'group-1',
tabOrder: ['file-tab', 'terminal-tab']
})
expect(moveSessionTab).toHaveBeenCalledWith('wt-1', {
kind: 'reorder',
tabId: 'file-tab',
targetGroupId: 'group-1',
tabOrder: ['file-tab', 'agent-session:session-a', 'terminal-tab']
})
})
it('validates reorder moves against sanitized visible tab groups', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
+18 -27
View File
@@ -5,7 +5,6 @@ import {
ActivateTab,
CreateTerminalTab,
MoveTab,
SaveMarkdownTab,
SessionTabsUnsubscribe,
SetTabProps,
UpdatePaneLayout,
@@ -13,26 +12,33 @@ import {
} from './session-tabs-schemas'
import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { MOBILE_MARKDOWN_TAB_METHODS } from './mobile-markdown-tab-methods'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.list',
params: WorktreeTabSelector,
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) =>
projectSessionTabAgentStatus(
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
return projectSessionTabAgentStatus(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities
)
}
}),
defineMethod({
name: 'session.tabs.listAll',
params: null,
handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({
snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
)
})
handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
return {
snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) =>
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities)
)
}
}
}),
defineMethod({
name: 'session.tabs.activate',
@@ -144,6 +150,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let unsubscribe = (): void => {}
let closed = false
let initialized = false
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId)
if (closed) {
return
@@ -222,8 +229,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
let initialized = false
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: shared-control can carry multiple all-tab subscribers on one
// socket; include the RPC id so closing one does not evict siblings.
// Why: include the RPC id so one shared-control stream cannot evict siblings.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
@@ -239,6 +245,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
await restoreStructuredTabsIfSupported(runtime, clientCapabilities)
const snapshots = await Promise.resolve(
runtime.listAllMobileSessionTabs(pairedDeviceId)
).catch((error) => {
@@ -285,21 +292,5 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
return { unsubscribed: true }
}
}),
defineMethod({
name: 'markdown.readTab',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.readMobileMarkdownTab(params.worktree, params.tabId)
}),
defineMethod({
name: 'markdown.saveTab',
params: SaveMarkdownTab,
handler: async (params, { runtime }) =>
runtime.saveMobileMarkdownTab(
params.worktree,
params.tabId,
params.baseVersion,
params.content
)
})
...MOBILE_MARKDOWN_TAB_METHODS
]
@@ -94,6 +94,23 @@ export const AttachParams = z
})
.strict()
export const CreateIntentParams = z
.object({
envelope: MutationEnvelope,
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
})
.strict()
export const CreateParams = z.union([AttachParams, CreateIntentParams])
export const CreateSupportParams = z
.object({
worktree: Identifier('Invalid worktree selector'),
agent: z.literal('codex')
})
.strict()
/** Clients may only author user turns. Accepting an assistant or tool role here
* would let one client write words into the agent's mouth in another's
* timeline, and the provider — not the client — owns those. */
@@ -14,6 +14,7 @@ import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { ALL_RPC_METHODS } from './index'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
const SESSION = 'session-alpha'
const FINGERPRINT = 'f'.repeat(64)
@@ -60,10 +61,27 @@ function request(method: string, params: unknown): RpcRequest {
}
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
function hostStub(): StructuredAgentSessionHost {
hostCalls = {
attach: vi.fn(async () => ({ ok: true, replayed: false })),
attach: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
sessionId: SESSION,
fence: 1,
snapshot: {
sessionId: SESSION,
cursor: { epoch: 'epoch-a', sequence: 0 },
items: [],
submissions: []
},
unconfirmedClientMessageIds: []
}
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
@@ -76,11 +94,29 @@ function hostStub(): StructuredAgentSessionHost {
}
function dispatcher(): RpcDispatcher {
runtimeCalls = {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
}
const runtime = {
getRuntimeId: () => 'runtime-1',
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn()
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
@@ -134,7 +170,7 @@ describe('capability gating', () => {
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
expect(names).toContain(method.name)
}
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(10)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(11)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -149,6 +185,32 @@ describe('capability gating', () => {
expect(hostCalls.send).not.toHaveBeenCalled()
})
it('rejects create intent before resolving host-owned fields for an old client', async () => {
const worktree = 'id:workspace-1'
const response = await call(
'agentSession.create',
{
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree, agent: 'codex' }
})
}),
worktree,
agent: 'codex'
},
{ clientKind: 'mobile', clientCapabilities: [] }
)
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled()
})
it('serves a client that advertised it', async () => {
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
expect(response).toMatchObject({ ok: true })
@@ -168,6 +230,35 @@ describe('capability gating', () => {
})
describe('method routing', () => {
it('creates from mobile intent while the host resolves paths and provider identity', async () => {
const worktree = 'id:workspace-1'
const params = {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree, agent: 'codex' }
})
}),
worktree,
agent: 'codex'
}
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true, result: { ok: true } })
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
expect(hostCalls.attach).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }
})
)
expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle')
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: SESSION, activate: true })
)
})
it('separates create from ensure by the fence the client may declare', async () => {
const created = await call('agentSession.create', attachParams(), STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true })
@@ -7,6 +7,10 @@
// structured session.
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
agentSessionFingerprintConflict,
computeAgentSessionPayloadFingerprint
} from '../../../../shared/agent-session-mutation-envelope'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type {
StructuredAgentSessionCaller,
@@ -16,6 +20,8 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext
import {
AttachParams,
CancelParams,
CreateParams,
CreateSupportParams,
HistoryParams,
RespondParams,
SendParams,
@@ -37,8 +43,15 @@ function supportsStructuredSessions(ctx: RpcContext): boolean {
)
}
function requireStructuredCapability(ctx: RpcContext): void {
if (!supportsStructuredSessions(ctx)) {
throw new Error('structured_agent_session_unsupported')
}
}
function requireHost(ctx: RpcContext): StructuredAgentSessionHost {
const host = supportsStructuredSessions(ctx) ? getStructuredAgentSessionHost() : null
requireStructuredCapability(ctx)
const host = getStructuredAgentSessionHost()
if (!host) {
throw new Error('structured_agent_session_unsupported')
}
@@ -74,12 +87,61 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string {
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'agentSession.create',
params: AttachParams,
name: 'agentSession.createSupport',
params: CreateSupportParams,
handler: async (params, ctx) => {
if (!supportsStructuredSessions(ctx)) {
throw new Error('structured_agent_session_unsupported')
}
return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent)
}
}),
defineMethod({
name: 'agentSession.create',
params: CreateParams,
handler: async (params, ctx) => {
requireStructuredCapability(ctx)
if (params.envelope.expectedRuntimeFence !== null) {
throw new Error('agent_session_operation_invalid')
}
if ('worktree' in params) {
const intentFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: params.worktree, agent: params.agent }
})
const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint)
if (conflict) {
return { ok: false, refusal: conflict }
}
const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params)
const hostFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: params.envelope.sessionId,
fields: {
location: resolved.location,
provider: resolved.provider,
agent: resolved.agent,
accountHome: resolved.accountHome,
runtimeKind: resolved.runtimeKind,
expectedRuntimeFence: null
}
})
await ensureHostInstalled(ctx)
const result = await requireHost(ctx).attach(callerFor(ctx), {
...resolved,
envelope: { ...params.envelope, payloadFingerprint: hostFingerprint }
})
if (result.ok) {
ctx.runtime.publishStructuredAgentSessionTab({
workspaceId: resolved.location.workspaceId,
sessionId: result.value.sessionId,
agent: 'codex',
activate: true
})
}
return result
}
await ensureHostInstalled(ctx)
return requireHost(ctx).attach(callerFor(ctx), params)
}
@@ -0,0 +1,11 @@
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RpcContext } from '../core'
export async function restoreStructuredTabsIfSupported(
runtime: RpcContext['runtime'],
capabilities: readonly string[] | undefined
): Promise<void> {
if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) {
await runtime.restoreStructuredAgentSessionTabs()
}
}
+5
View File
@@ -185,6 +185,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'accounts.unsubscribe',
'aiVault.listSessions',
'aiVault.prepareSessionResume',
'agentSession.create',
'agentSession.createSupport',
'agentSession.history',
'agentSession.subscribe',
'agentSession.unsubscribe',
'browser.back',
'browser.dialogAccept',
'browser.dialogDismiss',
@@ -221,6 +221,20 @@ describe('startup ordering', () => {
expect(disposeIndex).toBeGreaterThan(commitIndex)
})
it('joins structured agent sessions to the committed quit barrier', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const willQuitStart = source.indexOf("app.on('will-quit'")
const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart)
const willQuit = source.slice(willQuitStart, willQuitEnd)
expect(willQuit).toContain(
'const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()'
)
expect(willQuit).toContain(
"{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown }"
)
})
it('starts the automation scheduler before headless serve reports ready', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const serveStart = source.indexOf('if (serveOptions) {')
@@ -125,6 +125,8 @@ export type AgentJournalQuestionItem = {
kind: 'question'
question: string
options: AgentJournalPromptOption[]
/** Present when the provider accepts an answer outside the offered options. */
freeTextQuestionId?: string
resolution: AgentJournalResolution
}
+2
View File
@@ -49,6 +49,8 @@ export type AgentSessionHistoryPage = {
newest: AgentJournalCursor | null
nextCursor: AgentJournalCursor
}
/** Current journal head for switching from a bounded page to live subscribe. */
liveCursor?: AgentJournalCursor
hasOlder: boolean
hasNewer: boolean
}
+15 -2
View File
@@ -251,11 +251,23 @@ export type RuntimeMobileSessionBrowserTab = {
isActive: boolean
}
export type RuntimeMobileSessionAgentTab = {
type: 'agent-session'
id: string
title: string
sessionId: string
agent: 'codex'
color?: string | null
isPinned?: boolean
isActive: boolean
}
export type RuntimeMobileSessionSnapshotTab =
| RuntimeMobileSessionTerminalTab
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
| RuntimeMobileSessionBrowserTab
| RuntimeMobileSessionAgentTab
export type RuntimeMobileSessionTerminalClientTab =
| (RuntimeMobileSessionTerminalTab & {
@@ -272,6 +284,7 @@ export type RuntimeMobileSessionClientTab =
| RuntimeMobileSessionMarkdownTab
| RuntimeMobileSessionFileTab
| RuntimeMobileSessionBrowserTab
| RuntimeMobileSessionAgentTab
export type RuntimeMobileSessionTabGroup = {
id: string
@@ -328,7 +341,7 @@ export type RuntimeMobileSessionTabsSnapshot = {
snapshotVersion: number
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionSnapshotTab[]
@@ -342,7 +355,7 @@ export type RuntimeMobileSessionTabsResult = {
navigationIntent?: 'follow'
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionClientTab[]