mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Add mobile structured session read flow
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user