mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix structured session TUI handoff recovery
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation'
|
||||
import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
|
||||
|
||||
export async function recoverDeadTuiHandoffStatus(input: {
|
||||
store: AgentSessionRecordStore
|
||||
now: () => number
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
probe: AgentSessionOwnerProbe
|
||||
}): Promise<AgentSessionHandoffStatus | null> {
|
||||
const { expectedFence, now, probe, record, store } = input
|
||||
if (
|
||||
record.lease.runtimeFence !== expectedFence ||
|
||||
record.lease.runtimeKind !== 'tui' ||
|
||||
record.lease.handoffStage !== null ||
|
||||
record.lease.claimStatus !== 'live' ||
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const recovered = await recoverStoredDeadTuiOwnerForHandoff(store, {
|
||||
sessionId: record.sessionId,
|
||||
expectedFence,
|
||||
operationId: createStructuredAgentSessionOperationId(randomUUID, now()),
|
||||
probe,
|
||||
now: now()
|
||||
})
|
||||
return idleStructuredHandoffStatus(recovered)
|
||||
}
|
||||
+45
-7
@@ -29,15 +29,18 @@ import type {
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const DEFAULT_MODEL = 'gpt-default'
|
||||
const PICKED_MODEL = 'gpt-picked'
|
||||
const PICKED_EFFORT = 'medium'
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
|
||||
let activeModel: string
|
||||
let activeEffort: string | null
|
||||
let transcriptPath: string
|
||||
let optionFailure: Error | null
|
||||
const dispatchedModels: string[] = []
|
||||
const launchedOptions: (Readonly<Record<string, string>> | undefined)[] = []
|
||||
|
||||
function envelope(method: string, fields: Record<string, unknown>): AgentSessionMutationEnvelope {
|
||||
return {
|
||||
@@ -80,7 +83,10 @@ function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner {
|
||||
function handoffTransport(): StructuredAgentSessionHandoffTransport {
|
||||
return {
|
||||
hostLabel: 'Test host',
|
||||
launchTui: async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken),
|
||||
launchTui: async ({ record, fence, spawnToken }) => {
|
||||
launchedOptions.push(record.options)
|
||||
return tuiOwner(fence, spawnToken)
|
||||
},
|
||||
reproveTuiOwner: async ({ owner }) => owner,
|
||||
recoverTuiOwner: async (record) =>
|
||||
tuiOwner(
|
||||
@@ -97,6 +103,7 @@ function handoffTransport(): StructuredAgentSessionHandoffTransport {
|
||||
function adapter(): StructuredAgentSessionAdapter {
|
||||
acquire = vi.fn(async ({ fence, spawnToken, options }) => {
|
||||
activeModel = options?.model ?? DEFAULT_MODEL
|
||||
activeEffort = options?.effort ?? null
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
@@ -124,16 +131,26 @@ function adapter(): StructuredAgentSessionAdapter {
|
||||
}),
|
||||
cancelTurn: vi.fn(async () => ({ cancelled: true })),
|
||||
answerPrompt: vi.fn(async () => undefined),
|
||||
setOption: vi.fn(async ({ value }) => {
|
||||
setOption: vi.fn(async ({ key, value }) => {
|
||||
if (optionFailure) {
|
||||
const error = optionFailure
|
||||
optionFailure = null
|
||||
throw error
|
||||
}
|
||||
activeModel = value
|
||||
return { model: value }
|
||||
if (key === 'model') {
|
||||
activeModel = value
|
||||
} else if (key === 'effort') {
|
||||
activeEffort = value
|
||||
}
|
||||
return {
|
||||
model: activeModel,
|
||||
...(activeEffort ? { effort: activeEffort } : {})
|
||||
}
|
||||
}),
|
||||
readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })),
|
||||
readOptions: vi.fn(async () => ({
|
||||
current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) },
|
||||
models: []
|
||||
})),
|
||||
closeSession: vi.fn(async () => {
|
||||
activeModel = DEFAULT_MODEL
|
||||
})
|
||||
@@ -144,8 +161,10 @@ beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-'))
|
||||
resetHostTestOperationIds()
|
||||
activeModel = DEFAULT_MODEL
|
||||
activeEffort = null
|
||||
optionFailure = null
|
||||
dispatchedModels.length = 0
|
||||
launchedOptions.length = 0
|
||||
const accountHome = join(root, 'codex-home')
|
||||
const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12')
|
||||
transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`)
|
||||
@@ -217,6 +236,18 @@ describe('structured session handoff options', () => {
|
||||
).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL })
|
||||
|
||||
const effortFields = { key: 'effort', value: PICKED_EFFORT }
|
||||
expect(
|
||||
await host.setOption(CALLER, {
|
||||
envelope: envelope('agentSession.setOption', effortFields),
|
||||
...effortFields
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(SESSION)?.options).toEqual({
|
||||
model: PICKED_MODEL,
|
||||
effort: PICKED_EFFORT
|
||||
})
|
||||
|
||||
expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(async () =>
|
||||
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' })
|
||||
@@ -226,8 +257,15 @@ describe('structured session handoff options', () => {
|
||||
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
|
||||
)
|
||||
|
||||
expect(acquire.mock.calls[1]?.[0].options).toEqual({ model: PICKED_MODEL })
|
||||
expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL })
|
||||
expect(launchedOptions).toEqual([{ model: PICKED_MODEL, effort: PICKED_EFFORT }])
|
||||
expect(acquire.mock.calls[1]?.[0].options).toEqual({
|
||||
model: PICKED_MODEL,
|
||||
effort: PICKED_EFFORT
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.options).toEqual({
|
||||
model: PICKED_MODEL,
|
||||
effort: PICKED_EFFORT
|
||||
})
|
||||
const body = hostTestMessage('use the selected model')
|
||||
expect(
|
||||
await host.send(CALLER, {
|
||||
|
||||
@@ -302,7 +302,7 @@ export class StructuredAgentSessionHandoffCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
|
||||
setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
|
||||
this.statuses.set(sessionId, status)
|
||||
this.deps.publish(sessionId, status)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from 'node:path'
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import'
|
||||
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
|
||||
@@ -8,6 +9,7 @@ import type { DeferredStructuredAgentSessionEventSink } from './structured-agent
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
|
||||
import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui-recovery'
|
||||
import { readNativeHandoffSessionOptions } from './structured-agent-session-handoff-options'
|
||||
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
|
||||
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
|
||||
@@ -23,6 +25,11 @@ type HostHandoffAccess = {
|
||||
|
||||
export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator & {
|
||||
stopTuiHistoryCatchup: () => void
|
||||
recoverDeadTuiOwner: (
|
||||
sessionId: string,
|
||||
expectedFence: number,
|
||||
probe: AgentSessionOwnerProbe
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export async function refreshRecoverableStructuredHandoffStatus(
|
||||
@@ -81,7 +88,29 @@ export function createStructuredAgentSessionHostHandoff(
|
||||
schedule: host.serialize,
|
||||
now: host.now
|
||||
})
|
||||
return Object.assign(coordinator, { stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll() })
|
||||
return Object.assign(coordinator, {
|
||||
stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll(),
|
||||
recoverDeadTuiOwner: async (
|
||||
sessionId: string,
|
||||
expectedFence: number,
|
||||
probe: AgentSessionOwnerProbe
|
||||
) => {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
const status = await recoverDeadTuiHandoffStatus({
|
||||
store: deps.store,
|
||||
now: host.now,
|
||||
record,
|
||||
expectedFence,
|
||||
probe
|
||||
})
|
||||
if (status) {
|
||||
coordinator.setStatus(sessionId, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function importTuiHistory(
|
||||
|
||||
+3
-1
@@ -13,13 +13,15 @@ export class StructuredAgentSessionHostRuntimeState {
|
||||
|
||||
constructor(
|
||||
private readonly deps: StructuredAgentSessionHostDeps,
|
||||
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>
|
||||
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>,
|
||||
onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise<void>
|
||||
) {
|
||||
this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store: deps.store,
|
||||
probe: (record) => this.probeRecord(record),
|
||||
now: () => deps.now?.() ?? Date.now(),
|
||||
...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}),
|
||||
...(onDeadTuiOwner ? { onDeadTuiOwner } : {}),
|
||||
onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,8 +68,15 @@ export class StructuredAgentSessionHost {
|
||||
private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate()
|
||||
|
||||
constructor(readonly deps: StructuredAgentSessionHostDeps) {
|
||||
this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) =>
|
||||
this.restoreRenewedHandoff(record.sessionId)
|
||||
this.runtimeState = new StructuredAgentSessionHostRuntimeState(
|
||||
deps,
|
||||
(record) => this.restoreRenewedHandoff(record.sessionId),
|
||||
(record, probe) =>
|
||||
this.sessions.has(record.sessionId)
|
||||
? this.serialize(record.sessionId, () =>
|
||||
this.handoffs.recoverDeadTuiOwner(record.sessionId, record.lease.runtimeFence, probe)
|
||||
)
|
||||
: Promise.resolve()
|
||||
)
|
||||
this.reconcileLeases = createRestartReconciler({
|
||||
store: deps.store,
|
||||
|
||||
+26
@@ -132,4 +132,30 @@ describe('structured agent-session lease renewal', () => {
|
||||
error: expect.any(Error)
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a proven dead TUI owner into handoff recovery', async () => {
|
||||
const store = await liveStore()
|
||||
await store.transitionHandoff('session-renewal', (record) => ({
|
||||
...record,
|
||||
lease: { ...record.lease, runtimeKind: 'tui' }
|
||||
}))
|
||||
const onDeadTuiOwner = vi.fn(async () => undefined)
|
||||
const onError = vi.fn()
|
||||
const renewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store,
|
||||
probe: async () => ({ outcome: 'pid-absent' }),
|
||||
now: () => NOW + 10_000,
|
||||
onDeadTuiOwner,
|
||||
onError
|
||||
})
|
||||
|
||||
await renewer.renewNow()
|
||||
|
||||
expect(onDeadTuiOwner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'session-renewal' }),
|
||||
{ outcome: 'pid-absent' }
|
||||
)
|
||||
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import {
|
||||
isProvenDeadProbe,
|
||||
type AgentSessionOwnerProbe
|
||||
} from '../../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
AGENT_SESSION_LEASE_TTL_MS,
|
||||
@@ -17,6 +20,7 @@ export class StructuredAgentSessionLeaseRenewer {
|
||||
probe: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
now: () => number
|
||||
onRenewed?: (record: AgentSessionRecord) => Promise<void>
|
||||
onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise<void>
|
||||
onError?: (input: { sessionId: string; error: unknown }) => void
|
||||
intervalMs?: number
|
||||
}
|
||||
@@ -61,10 +65,19 @@ export class StructuredAgentSessionLeaseRenewer {
|
||||
|
||||
private async renewRecord(record: AgentSessionRecord): Promise<void> {
|
||||
try {
|
||||
const probe = await this.input.probe(record)
|
||||
if (
|
||||
record.lease.runtimeKind === 'tui' &&
|
||||
isProvenDeadProbe(probe) &&
|
||||
this.input.onDeadTuiOwner
|
||||
) {
|
||||
await this.input.onDeadTuiOwner(record, probe)
|
||||
return
|
||||
}
|
||||
const renewed = await this.input.store.renewLease({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
childProbe: await this.input.probe(record),
|
||||
childProbe: probe,
|
||||
now: this.input.now()
|
||||
})
|
||||
await this.input.onRenewed?.(renewed)
|
||||
|
||||
@@ -3,9 +3,35 @@ import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import { recoverDeadTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions'
|
||||
import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions'
|
||||
|
||||
describe('agent session handoff restart transitions', () => {
|
||||
it('turns a proven dead TUI owner into one durable retry owner', () => {
|
||||
const operationId = '1800000000000-00000000000000000000000000000001'
|
||||
const record = agentSessionRecordFixture(
|
||||
agentSessionLeaseFixture({ runtimeKind: 'tui', runtimeFence: 3 })
|
||||
)
|
||||
|
||||
const next = recoverDeadTuiOwnerForHandoff({
|
||||
record,
|
||||
expectedFence: 3,
|
||||
operationId,
|
||||
probe: { outcome: 'pid-absent' },
|
||||
now: 1_800_000_001_000
|
||||
})
|
||||
|
||||
expect(next.lease).toMatchObject({
|
||||
runtimeKind: 'tui',
|
||||
runtimeFence: 4,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: operationId,
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null,
|
||||
deathEvidence: { kind: 'pid-absent' }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the stopped owner and operation for durable retry', () => {
|
||||
const handoffOperationId = '1800000000000-00000000000000000000000000000001'
|
||||
const record = agentSessionRecordFixture(
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import type {
|
||||
AgentSessionOwnerRuntimeKind,
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions'
|
||||
import {
|
||||
assertFence,
|
||||
evictAgentSessionOwner,
|
||||
reserveAgentSessionOwner,
|
||||
withLease
|
||||
} from './agent-session-lease-transitions'
|
||||
|
||||
export function recoverDeadTuiOwnerForHandoff(args: {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
probe: AgentSessionOwnerProbe
|
||||
now: number
|
||||
}): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
record.lease.runtimeKind !== 'tui' ||
|
||||
record.lease.handoffStage !== null ||
|
||||
record.lease.claimStatus !== 'live' ||
|
||||
record.lease.ownerProcess === null
|
||||
) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
const evicted = evictAgentSessionOwner(args)
|
||||
return withLease(evicted, {
|
||||
...evicted.lease,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: args.operationId
|
||||
})
|
||||
}
|
||||
|
||||
export function stopAgentSessionOwnerForHandoff(args: {
|
||||
record: AgentSessionRecord
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionHandoffStage } from '../../shared/agent-session-record'
|
||||
import {
|
||||
abandonAgentSessionHandoffAttempt,
|
||||
recoverDeadTuiOwnerForHandoff,
|
||||
reserveAgentSessionHandoffOwner,
|
||||
rollbackAgentSessionHandoffPreparation,
|
||||
stopAgentSessionOwnerForHandoff,
|
||||
@@ -27,6 +29,21 @@ export function setStoredAgentSessionHandoffStage(
|
||||
)
|
||||
}
|
||||
|
||||
export function recoverStoredDeadTuiOwnerForHandoff(
|
||||
store: AgentSessionRecordStore,
|
||||
args: {
|
||||
sessionId: string
|
||||
expectedFence: number
|
||||
operationId: string
|
||||
probe: AgentSessionOwnerProbe
|
||||
now: number
|
||||
}
|
||||
) {
|
||||
return store.transitionHandoff(args.sessionId, (record) =>
|
||||
recoverDeadTuiOwnerForHandoff({ ...args, record })
|
||||
)
|
||||
}
|
||||
|
||||
export function stopStoredAgentSessionOwnerForHandoff(
|
||||
store: AgentSessionRecordStore,
|
||||
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
|
||||
|
||||
@@ -180,7 +180,9 @@ describe('structured TUI launch tab binding', () => {
|
||||
getSettings: () => ({
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultArgs: {
|
||||
codex: '-m gpt-5.6-sol -c model_reasoning_effort=high'
|
||||
},
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
} as never,
|
||||
@@ -190,8 +192,9 @@ describe('structured TUI launch tab binding', () => {
|
||||
}
|
||||
)
|
||||
runtime.setNotifier(notifier(revealTerminalSession) as never)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 })
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }),
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
@@ -264,6 +267,7 @@ describe('structured TUI launch tab binding', () => {
|
||||
sessionId: 'session-1',
|
||||
location: { workspaceId: WORKTREE_ID, executionHostId: 'local' },
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
|
||||
options: { model: 'gpt-5.6-terra', effort: 'medium' },
|
||||
providerHandleChain: [
|
||||
{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }
|
||||
]
|
||||
@@ -289,6 +293,11 @@ describe('structured TUI launch tab binding', () => {
|
||||
expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
revealTerminalSession.mock.invocationCallOrder[0]!
|
||||
)
|
||||
const launchCommand = spawn.mock.calls[0]?.[0]?.command
|
||||
expect(launchCommand).toContain("'-m' 'gpt-5.6-terra'")
|
||||
expect(launchCommand).toContain("'-c' 'model_reasoning_effort=medium'")
|
||||
expect(launchCommand).not.toContain('gpt-5.6-sol')
|
||||
expect(launchCommand).not.toContain('model_reasoning_effort=high')
|
||||
|
||||
Object.assign(internal.handles.get(owner.terminal.handle)!, {
|
||||
rendererGraphEpoch: -1,
|
||||
|
||||
@@ -9133,6 +9133,7 @@ export class OrcaRuntimeService {
|
||||
worktree: `id:${record.location.workspaceId}`,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: head.handle.threadId },
|
||||
...(record.options ? { launchPreferences: record.options } : {}),
|
||||
presentation: 'background'
|
||||
},
|
||||
{},
|
||||
@@ -25904,6 +25905,7 @@ export class OrcaRuntimeService {
|
||||
},
|
||||
ompResumeFilePath: request.ompResumeFilePath,
|
||||
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
|
||||
sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences),
|
||||
platform,
|
||||
shell,
|
||||
isRemote
|
||||
|
||||
@@ -30,4 +30,29 @@ describe('StructuredAgentSessionHandoffChrome', () => {
|
||||
|
||||
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
|
||||
})
|
||||
|
||||
it('offers one Retry action for a recoverable dead TUI owner', () => {
|
||||
const onRequest = vi.fn()
|
||||
render(
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={{
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'failed',
|
||||
stage: 'old-owner-stopped',
|
||||
operationId: '1800000000000-00000000000000000000000000000001',
|
||||
error: {
|
||||
message: "Couldn't resume chat — the agent terminal still owns this session",
|
||||
recoverableOwner: 'tui'
|
||||
}
|
||||
}}
|
||||
isWorking={false}
|
||||
onRequest={onRequest}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ export function StructuredAgentSessionHandoffChrome({
|
||||
{translate('components.native-chat.handoff.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : owner === 'native' && !switching ? (
|
||||
) : owner === 'native' && phase === 'idle' ? (
|
||||
isWorking ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -132,7 +132,7 @@ export function StructuredAgentSessionHandoffChrome({
|
||||
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
|
||||
</Button>
|
||||
)
|
||||
) : owner === 'tui' && !switching ? (
|
||||
) : owner === 'tui' && phase === 'idle' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
type AgentProviderSessionMetadata,
|
||||
type ResumableTuiAgent
|
||||
} from './agent-session-resume'
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config'
|
||||
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
|
||||
import type { AgentStartupPlan } from './tui-agent-startup'
|
||||
import {
|
||||
quoteStartupArg,
|
||||
resolveStartupShell,
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { TuiAgent } from './types'
|
||||
|
||||
export function buildAgentResumeStartupPlan(args: {
|
||||
agent: ResumableTuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string> | null
|
||||
agentCommand?: string | null
|
||||
ompResumeFilePath?: string | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
sessionOptionsOverrideAgentArgs?: boolean
|
||||
isRemote?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
|
||||
if (!argv) {
|
||||
return null
|
||||
}
|
||||
const shell = resolveStartupShell(args.platform, args.shell)
|
||||
const resolvedAgentCommand = args.agentCommand?.trim()
|
||||
const baseCommand = resolvedAgentCommand
|
||||
? ({
|
||||
ok: true,
|
||||
command: resolvedAgentCommand,
|
||||
commandWithoutSessionOptions: resolvedAgentCommand,
|
||||
appliedSessionOptions: {}
|
||||
} as const)
|
||||
: resolveAgentLaunchCommand({
|
||||
agent: args.agent,
|
||||
cmdOverrides: args.cmdOverrides,
|
||||
platform: args.platform,
|
||||
shell,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
if (!baseCommand.ok) {
|
||||
return null
|
||||
}
|
||||
const launchConfig = buildSleepingAgentLaunchConfig({
|
||||
...args,
|
||||
agentCommand: baseCommand.commandWithoutSessionOptions
|
||||
})
|
||||
const resumeArgs = argv
|
||||
.slice(1)
|
||||
.map((arg) => quoteStartupArg(arg, shell))
|
||||
.join(' ')
|
||||
const launchCommand = resumeArgs ? `${baseCommand.command} ${resumeArgs}` : baseCommand.command
|
||||
const applied = baseCommand.appliedSessionOptions
|
||||
return {
|
||||
agent: args.agent,
|
||||
launchCommand,
|
||||
expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig,
|
||||
...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}),
|
||||
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
|
||||
}
|
||||
}
|
||||
@@ -140,15 +140,22 @@ describe('tui agent startup session options', () => {
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' })
|
||||
})
|
||||
|
||||
it('never injects session options into resume commands', () => {
|
||||
it('applies explicit session options to resume commands', () => {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'thread-1' },
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
sessionOptions: { model: 'gpt-5.5', effort: 'high' }
|
||||
agentArgs: '-m gpt-5.6-sol -c model_reasoning_effort=medium',
|
||||
sessionOptions: { model: 'gpt-5.5', effort: 'high' },
|
||||
sessionOptionsOverrideAgentArgs: true
|
||||
})
|
||||
expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'")
|
||||
expect(plan?.sessionOptions).toBeUndefined()
|
||||
expect(plan?.launchCommand).toBe(
|
||||
"codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high' 'resume' 'thread-1'"
|
||||
)
|
||||
expect(plan?.launchConfig.agentCommand).toBe(
|
||||
"codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium'"
|
||||
)
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.5', effort: 'high' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { isShellProcess } from './agent-detection'
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
type AgentProviderSessionMetadata,
|
||||
type ResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from './agent-session-resume'
|
||||
import type { SleepingAgentLaunchConfig } from './agent-session-resume'
|
||||
import {
|
||||
clearEnvCommand,
|
||||
commandSeparator,
|
||||
@@ -21,6 +16,8 @@ import type { TuiAgent } from './types'
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
|
||||
|
||||
export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup'
|
||||
|
||||
export type AgentStartupPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
@@ -184,59 +181,6 @@ export function buildAgentStartupPlan(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentResumeStartupPlan(args: {
|
||||
agent: ResumableTuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string> | null
|
||||
agentCommand?: string | null
|
||||
ompResumeFilePath?: string | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
/** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */
|
||||
isRemote?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
|
||||
if (!argv) {
|
||||
return null
|
||||
}
|
||||
const shell = resolveStartupShell(args.platform, args.shell)
|
||||
const config = TUI_AGENT_CONFIG[args.agent]
|
||||
const resolvedAgentCommand = args.agentCommand?.trim()
|
||||
const baseCommand = resolvedAgentCommand
|
||||
? ({ ok: true, command: resolvedAgentCommand } as const)
|
||||
: resolveAgentLaunchCommand({
|
||||
agent: args.agent,
|
||||
cmdOverrides: args.cmdOverrides,
|
||||
platform: args.platform,
|
||||
shell,
|
||||
agentArgs: args.agentArgs,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
if (!baseCommand.ok) {
|
||||
return null
|
||||
}
|
||||
const launchConfig = buildSleepingAgentLaunchConfig({
|
||||
...args,
|
||||
agentCommand: baseCommand.command
|
||||
})
|
||||
const resumeArgs = argv
|
||||
.slice(1)
|
||||
.map((arg) => quoteStartupArg(arg, shell))
|
||||
.join(' ')
|
||||
const launchCommand = resumeArgs ? `${baseCommand.command} ${resumeArgs}` : baseCommand.command
|
||||
return {
|
||||
agent: args.agent,
|
||||
launchCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null,
|
||||
launchConfig,
|
||||
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
|
||||
Reference in New Issue
Block a user