mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
* feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim <sim@local>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
|
import type {
|
|
CodexAppServerConnection,
|
|
CodexAppServerConnectionHandlers,
|
|
openCodexAppServerConnection
|
|
} from './codex-app-server-connection'
|
|
import {
|
|
CodexStructuredSessionAdapter,
|
|
type CodexStructuredSessionEvent
|
|
} from './codex-structured-session-adapter'
|
|
import { handleCodexSessionExit } from './codex-structured-session-close'
|
|
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
|
import type { CodexSession } from './codex-structured-session-state'
|
|
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
|
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
|
|
|
const THREAD = 'thread-1'
|
|
|
|
function identity(sessionId: string): AgentSessionJournalIdentity {
|
|
return {
|
|
sessionId,
|
|
workspaceId: 'workspace-1',
|
|
hostId: 'host-1',
|
|
agent: 'codex',
|
|
providerHandle: { kind: 'codex', threadId: THREAD }
|
|
}
|
|
}
|
|
|
|
function adapterFixture() {
|
|
const connections: {
|
|
connection: CodexAppServerConnection
|
|
handlers: CodexAppServerConnectionHandlers
|
|
}[] = []
|
|
const events: CodexStructuredSessionEvent[] = []
|
|
let generation = 0
|
|
const openConnection = (async (_launch, handlers = {}) => {
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method) => (method === 'thread/start' ? { thread: { id: THREAD } } : {}),
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
connections.push({ connection, handlers })
|
|
return connection
|
|
}) as typeof openCodexAppServerConnection
|
|
const adapter = new CodexStructuredSessionAdapter({
|
|
resolveLaunch: async () => ({
|
|
command: 'codex',
|
|
args: ['app-server'],
|
|
cwd: '/workspace',
|
|
codexHome: null,
|
|
resumeThreadId: null
|
|
}),
|
|
openConnection,
|
|
readProcessStartTime: async () => 1_700_000_000_000,
|
|
mintAcquisitionGeneration: () => `generation-${++generation}`,
|
|
onEvent: (event) => events.push(event)
|
|
})
|
|
return { adapter, connections, events }
|
|
}
|
|
|
|
function claudeAdapterStub(): StructuredAgentSessionAdapter {
|
|
return {
|
|
acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never),
|
|
dispatch: vi.fn(),
|
|
cancelTurn: vi.fn(),
|
|
answerPrompt: vi.fn(),
|
|
setOption: vi.fn()
|
|
}
|
|
}
|
|
|
|
describe('Codex structured session close lifecycle', () => {
|
|
it('forwards a one-shot exit when lifecycle admission is rejected', () => {
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: true,
|
|
request: async () => ({}),
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
const prompts = { clear: vi.fn() } as unknown as CodexSession['prompts']
|
|
const translator = {
|
|
handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }),
|
|
dispose: vi.fn()
|
|
} as unknown as NonNullable<CodexSession['translator']>
|
|
const session = {
|
|
connection,
|
|
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
|
|
ended: false,
|
|
requestedClose: false,
|
|
fence: 7,
|
|
acquisitionGeneration: 'generation-1',
|
|
threadId: THREAD,
|
|
historyPath: null,
|
|
prompts,
|
|
options: new Map(),
|
|
reportedOptions: {},
|
|
turnIdWaiters: [],
|
|
translator
|
|
} as CodexSession
|
|
const sessions = new Map([['session-1', session]])
|
|
const onEvent = vi.fn()
|
|
|
|
expect(
|
|
handleCodexSessionExit({
|
|
sessions,
|
|
sessionId: 'session-1',
|
|
connection,
|
|
error: new Error('provider exited'),
|
|
prompts,
|
|
onEvent
|
|
})
|
|
).toBe(true)
|
|
expect(session.ended).toBe(true)
|
|
expect(prompts.clear).toHaveBeenCalledOnce()
|
|
expect(onEvent).toHaveBeenCalledOnce()
|
|
expect(translator.dispose).toHaveBeenCalledOnce()
|
|
expect(onEvent.mock.calls[0]?.[0]).toMatchObject({
|
|
cause: 'unexpected-exit',
|
|
settlementRetryRequired: true
|
|
})
|
|
expect(translator.handle).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('mints a distinct child generation even when acquisitions share one fence', async () => {
|
|
const { adapter } = adapterFixture()
|
|
const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }
|
|
|
|
const first = await adapter.acquire(input)
|
|
const second = await adapter.acquire(input)
|
|
|
|
expect(first.acquisitionGeneration).toBe('generation-1')
|
|
expect(second.acquisitionGeneration).toBe('generation-2')
|
|
})
|
|
|
|
it('distinguishes an observed provider death from a requested close', async () => {
|
|
const { adapter, connections, events } = adapterFixture()
|
|
await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
|
|
connections[0]?.handlers.onExit?.(new Error('provider exited'))
|
|
await adapter.acquire({ identity: identity('session-2'), fence: 9, spawnToken: 'spawn-2' })
|
|
|
|
await adapter.closeSession('session-2')
|
|
|
|
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
|
|
{
|
|
cause: 'unexpected-exit',
|
|
fence: 7,
|
|
acquisitionGeneration: 'generation-1'
|
|
},
|
|
{
|
|
cause: 'requested-close',
|
|
fence: 9,
|
|
acquisitionGeneration: 'generation-2'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => {
|
|
const { adapter, connections, events } = adapterFixture()
|
|
await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
|
|
const current = connections[0]
|
|
if (!current) {
|
|
throw new Error('missing connection')
|
|
}
|
|
current.connection.close = async () => {
|
|
current.handlers.onExit?.(new Error('sink failed'))
|
|
return true
|
|
}
|
|
|
|
await expect(adapter.forceCloseSession?.('session-1')).resolves.toBe(true)
|
|
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
|
|
{ cause: 'unexpected-exit', reason: 'sink failed', fence: 7 }
|
|
])
|
|
})
|
|
|
|
it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => {
|
|
const { adapter, connections, events } = adapterFixture()
|
|
const router = new StructuredAgentSessionAdapterRouter(
|
|
{ claude: claudeAdapterStub(), codex: adapter },
|
|
async () => {}
|
|
)
|
|
await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
|
|
const current = connections[0]
|
|
if (!current) {
|
|
throw new Error('missing connection')
|
|
}
|
|
current.connection.close = async () => {
|
|
current.handlers.onExit?.(new Error('journal sink failed'))
|
|
return true
|
|
}
|
|
|
|
const forceCloseSession = router.forceCloseSession
|
|
await expect(forceCloseSession('session-1')).resolves.toBe(true)
|
|
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
|
|
{ cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 }
|
|
])
|
|
})
|
|
})
|