mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* Require Codex 0.136.0 for native chats
* Add host-owned Codex install and update actions
* Bind Codex checks and maintenance to the selected installation
* Resolve Windows updater cleanup through system helpers
* Use one child environment for Codex checks and launch
* Expire Codex readiness and reconcile current host jobs
* Resolve Codex checks against the child command search path
* Restrict global npm repair to the selected Codex install layout
* Pin the host updater directory through Windows supervision
* Synchronize Codex maintenance fixtures on readiness
* Unify Codex notices and recover version-refused chats
* Route Codex recovery to the operation that remains
* Preserve Codex setup execution errors in the job log
* Preserve UTF-8 in Codex setup logs
* Remove unused Codex host installation detector
* Require host administration for Codex maintenance
* Show a missing Codex explanation once
* test(native-chat): give scripted Codex runtimes their installation fact; keep the registry out of the relay
Runtime tests that drive a scripted Codex app-server still ran the new
version check against whatever codex was on the machine's PATH: refused
on CI (no codex), and on a dev machine it ran the real codex --version.
A scripted transport now must state the Codex it stands for
(readCodexInstallation), asserted at install, and the tests get it from
one scriptedCodexTransport fixture.
The relay's Codex maintenance path reached @orca/windows-registry through
resolvePathEnvKey's re-export in windows-environment-path; import it from
its registry-free module instead.
Also update the catalog-probe test for the shared child environment.
* refactor(native-chat): drop the Update Codex action; keep Install for a missing Codex
A too-old Codex now gets only the plain message ("Codex X is too old for
chats. Update to 0.136.0 or newer.") with no button and no job. The
existing rechecks (window focus, the <=30 s expiry of a saved result)
retry the refused chat once the user has updated Codex themselves.
A missing Codex keeps the Install Codex button: `npm install -g
@openai/codex` with the npm on the chat's PATH, as a host-run job with
its log, then a recheck and recovery. A configured Command that is
missing gets the message only, since a global npm install would not
land at that path.
Removed: the `codex update` command and its 0.126 floor, manual
instructions, npm layout and launcher identity detection, the action
descriptor (install|update) and its titles, the legacy capability, and
the SSH relay maintenance path (structured chat never runs over SSH or
WSL), so the relay preflight handler is back to main's.
* refactor(native-chat): keep Install Codex in Settings only
When Codex isn't installed, main already hides Codex from the New tab menu,
so the in-chat Install button was almost never reachable. Settings → Agents
already shows "Codex isn't installed." with Install Codex.
- Chats and the new-workspace composer now only state "Codex isn't
installed." (no button, no install job from the chat).
- The Install action, its busy spinner and the host-busy subscription move
into the Settings Codex row, the only place that starts the job.
- The store no longer re-checks every other workspace context when a job
finishes. A refused chat recovers through the same re-check a too-old
Codex uses (window focus, or the <=30 s expiry of a saved result).
- The chat recovery key no longer includes the install job id.
- Tests: Settings drives the install; a new test proves a chat refused for
a missing Codex recovers through focus after a Settings install, not
through the job; the expiry recovery test covers missing and too old.
* refactor(codex): check chat compatibility when acquiring the child
* Restore workspace submit guard and simplify Codex maintenance
* Keep only the Codex chat minimum version
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
// Codex answers an interrupt it took before it ends the turn: on `TurnAborted` the app-server
|
|
// answers pending interrupts, then sends `turn/completed` (interrupted) on the same channel. The
|
|
// Stop's settle, which runs between the two, must label the turn the Stop's: no end row for it
|
|
// ever reads as a failure. Driven through the shipped host, journal and Codex adapter; only the
|
|
// Codex child is fake, and the test delivers the turn's end after the Stop has answered.
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
CodexAppServerConnection,
|
|
CodexAppServerConnectionHandlers,
|
|
openCodexAppServerConnection
|
|
} from '../codex/codex-app-server-connection'
|
|
import { readCodexTurnId } from '../codex/codex-structured-thread-facts'
|
|
import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake'
|
|
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
|
import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types'
|
|
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
|
import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work'
|
|
import {
|
|
HOST_TEST_SESSION as SESSION,
|
|
HOST_TEST_THREAD as THREAD,
|
|
hostTestAttachParams,
|
|
hostTestMessage
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
|
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
|
import {
|
|
liveTestJournalRows,
|
|
openTestJournalHostDatabase
|
|
} from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
|
import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import {
|
|
ensureStructuredAgentSessionHost,
|
|
stopStructuredAgentSessionRuntime
|
|
} from './structured-agent-session-runtime'
|
|
import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture'
|
|
|
|
const CALLER = { callerKey: 'codex-interrupt-order-test' }
|
|
const MODEL = {
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
|
|
let root: string
|
|
let host: StructuredAgentSessionHost
|
|
let fence: number
|
|
let handlers: CodexAppServerConnectionHandlers | undefined
|
|
let answers: number
|
|
let interrupts: number
|
|
let turns: ReturnType<typeof codexTurnLifecycleFake>
|
|
let statuses: AgentSessionStatusSummary[]
|
|
let opened: Set<string>
|
|
let steers: number
|
|
/** The turn each turn/start answered into, in order. */
|
|
let startedTurns: string[]
|
|
/** When set, Codex answers the interrupt only once it resolves. */
|
|
let interruptAnswer: Promise<void> | null
|
|
let operations = 0
|
|
|
|
/** The durable ledger stamps its own clock and refuses an id far from it. */
|
|
const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`
|
|
|
|
function envelope(method: string, fields: Record<string, unknown>) {
|
|
return {
|
|
sessionId: SESSION,
|
|
clientOperationId: operationId(),
|
|
expectedRuntimeFence: fence,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method,
|
|
sessionId: SESSION,
|
|
fields
|
|
})
|
|
}
|
|
}
|
|
|
|
async function send(text: string): Promise<string> {
|
|
const body = hostTestMessage(text)
|
|
const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
|
if (!sent.ok) {
|
|
throw new Error(JSON.stringify(sent.refusal))
|
|
}
|
|
return sent.value.clientMessageId
|
|
}
|
|
|
|
async function stop(turnId?: string): Promise<void> {
|
|
const stopped = await host.cancel(CALLER, {
|
|
envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }),
|
|
...(turnId === undefined ? {} : { turnId })
|
|
})
|
|
if (!stopped.ok) {
|
|
throw new Error(JSON.stringify(stopped.refusal))
|
|
}
|
|
}
|
|
|
|
/** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */
|
|
function turnEndRows(): AgentJournalTurnItem[] {
|
|
return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).flatMap((stored) => {
|
|
const parsed = parseJournalRow(stored.rowJson)
|
|
if (!parsed.ok) {
|
|
return []
|
|
}
|
|
const { row } = parsed
|
|
const bodies = row.kind === 'lifecycle-batch' ? row.mutations : [row]
|
|
return bodies.flatMap((entry) =>
|
|
entry.kind === 'item' &&
|
|
entry.body.kind === 'turn' &&
|
|
entry.body.turnId === 'turn-1' &&
|
|
entry.body.state !== 'running'
|
|
? [entry.body]
|
|
: []
|
|
)
|
|
})
|
|
}
|
|
|
|
/** The Stop has answered with Codex's end still to come; then Codex ends the turn. Stopping
|
|
* showed while the Stop settled, ended with the turn, and no status read the turn as failed. */
|
|
async function expectInterruptedThroughout(): Promise<void> {
|
|
expect(interrupts).toBe(1)
|
|
expect(turnEndRows()).toEqual([
|
|
expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' })
|
|
])
|
|
|
|
turns.end('interrupted')
|
|
|
|
await vi.waitFor(async () => expect(await owesWork()).toBe(false))
|
|
const ends = turnEndRows()
|
|
expect(ends.length).toBeGreaterThan(0)
|
|
for (const end of ends) {
|
|
expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
|
}
|
|
await vi.waitFor(() =>
|
|
expect(statuses.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' })
|
|
)
|
|
expect(statuses.some((summary) => summary.stopping === true)).toBe(true)
|
|
expect(statuses.at(-1)).not.toHaveProperty('stopping')
|
|
const otherVerdicts = statuses.filter(
|
|
(summary) => summary.turnOutcome !== undefined && summary.turnOutcome !== 'cancellation'
|
|
)
|
|
expect(otherVerdicts).toEqual([])
|
|
}
|
|
|
|
async function owesWork(): Promise<boolean> {
|
|
const snapshot = await host.journalSnapshot(SESSION)
|
|
return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence)
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-'))
|
|
answers = 0
|
|
interrupts = 0
|
|
opened = new Set()
|
|
steers = 0
|
|
startedTurns = []
|
|
interruptAnswer = null
|
|
turns = codexTurnLifecycleFake(THREAD, () => (method, params) => {
|
|
if (method === 'turn/started') {
|
|
opened.add(turns.turnId ?? '')
|
|
}
|
|
handlers?.onNotification?.(method, params)
|
|
})
|
|
const openConnection: typeof openCodexAppServerConnection = async (
|
|
_launch,
|
|
connectionHandlers = {}
|
|
) => {
|
|
handlers = connectionHandlers
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/start' || method === 'thread/resume') {
|
|
return { thread: { id: THREAD } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return { data: [MODEL], nextCursor: null }
|
|
}
|
|
if (method === 'turn/start') {
|
|
answers += 1
|
|
const answer = await turns.routes['turn/start']()
|
|
startedTurns.push(readCodexTurnId(answer) ?? '')
|
|
return answer
|
|
}
|
|
if (method === 'turn/steer') {
|
|
steers += 1
|
|
return turns.routes['turn/steer'](params)
|
|
}
|
|
if (method === 'turn/interrupt') {
|
|
// Taken: answered now; the test sends the turn's end once the Stop has answered. A turn
|
|
// Codex runs but whose turn/started was not yet read gets that frame ahead of the answer.
|
|
interrupts += 1
|
|
if (turns.turnId !== null && !opened.has(turns.turnId)) {
|
|
turns.start()
|
|
}
|
|
await interruptAnswer
|
|
// Codex drops the turn as its active one before it answers; turn/completed comes later.
|
|
turns.takeInterrupt()
|
|
return {}
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
return connection
|
|
}
|
|
host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = operationId()
|
|
const attached = await host.attach(CALLER, attachParams)
|
|
if (!attached.ok) {
|
|
throw new Error(JSON.stringify(attached.refusal))
|
|
}
|
|
fence = attached.value.fence
|
|
statuses = []
|
|
host.subscribeStatus({
|
|
id: 'interrupt-order',
|
|
emit: (event) => {
|
|
if (event.type === 'status' && event.session.sessionId === SESSION) {
|
|
statuses.push(event.session)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await stopStructuredAgentSessionRuntime()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () => {
|
|
it.each([
|
|
['names the running turn', 'turn-1'],
|
|
['names no turn', undefined]
|
|
] as const)(
|
|
'reads the turn as interrupted by the Stop, never failed, when it %s',
|
|
async (_, named) => {
|
|
await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
turns.start()
|
|
|
|
await stop(named)
|
|
|
|
await expectInterruptedThroughout()
|
|
}
|
|
)
|
|
|
|
// No turn showed when the person pressed Stop, which interrupts the answered turn at once: the
|
|
// turn that opens is bound by the Stop's settle.
|
|
it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => {
|
|
const release = turns.holdNextAnswer()
|
|
await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
const stopping = stop()
|
|
release()
|
|
await stopping
|
|
|
|
await expectInterruptedThroughout()
|
|
})
|
|
})
|
|
|
|
// A message sent while the Stop ends the turn goes out once that turn has ended. Codex answered the
|
|
// interrupt, so the turn has aborted even while its turn/completed is still on the wire: the
|
|
// message opens its own turn, never a steer Codex would refuse.
|
|
describe('a send behind a Codex Stop answered before its turn ends', () => {
|
|
it('opens its own turn with turn/start, never turn/steer', async () => {
|
|
await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
turns.start()
|
|
const answer = Promise.withResolvers<void>()
|
|
interruptAnswer = answer.promise
|
|
const stopping = stop()
|
|
await vi.waitFor(() => expect(interrupts).toBe(1))
|
|
|
|
// Accepted behind the Stop on the session's lane, so it reaches Codex only once the Stop has
|
|
// answered and the turn reads ended, while Codex's turn/completed is still to come.
|
|
const sent = send('run this after the stop')
|
|
await vi.waitFor(() => expect(statuses.some((summary) => summary.stopping === true)).toBe(true))
|
|
expect(answers).toBe(1)
|
|
answer.resolve()
|
|
await stopping
|
|
await sent
|
|
|
|
await vi.waitFor(() => expect(answers).toBe(2))
|
|
expect(steers).toBe(0)
|
|
// Its own turn, not folded into the aborted one, and the message is not lost.
|
|
expect(startedTurns).toEqual(['turn-1', 'turn-2'])
|
|
turns.end('interrupted')
|
|
const clientMessageId = await sent
|
|
const submission = (await host.journalSnapshot(SESSION)).submissions.find(
|
|
(entry) => entry.clientMessageId === clientMessageId
|
|
)
|
|
expect(submission?.dispatchState).not.toBe('rejected')
|
|
})
|
|
})
|