mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
feat(native-chat): say in the chat why Orca stopped a reply, and offer Continue (#25675)
* feat(native-chat): say in the chat why Orca stopped a reply, and offer Continue When the Orca that runs a structured chat (this computer or a paired server) quits, updates or crashes mid-reply, the chat's stopped row now names the cause and the machine, and a Continue button sends the existing restart continuation for that cut turn, with or without a restart offer. - Host: a quit/update writes one turn-scoped row for the turn its stop cut, in today's words, with an optional `orcaStop` cause on the providerExited fact; restart adjudication stamps how the previous runtime ended on the deaths it proves (crash, or the quit/update it began), so the crash row names it too. Older clients keep their single row. - Host: agentSession.continueInterrupted, capability-gated, rechecks under the session lock that the chat still sits on that cut, so a second click or a retry sends nothing. - Client: the row's copy names the cause and machine; Continue sits above the composer. * test(native-chat): Continue is not held by a recovery file that never answers * fix(native-chat): bind an Orca stop's cause to the runtime that held the agent; neutral row, Continue explains itself - The cause now rides on the host's row itself (`orcaStop` on the status row, beside today's words), the same row family and id scheme as the reopen's death row. - Each recorded owner is stamped with the Orca runtime that holds it; a death proven later (at restart, or when recovery stops a survivor) names how that runtime ended: the quit or update it began, else a crash. Owners an older build recorded, a terminal's claim, an agent that died while its Orca ran, and unreadable quit records all keep the generic words. - The quitting runtime's word is written first in teardown, before the recovery wait, through a bounded asynchronous writer apart from the chat database. - Row copy: one neutral sentence naming the machine and cause; it drops "You can continue in this conversation." while Continue is offered, and Continue's tooltip says what it does. * test(native-chat): type the Orca-stop test fixtures so the typecheck passes The cut turn's outcome takes the journal's outcome type, and the stand-in close reads the provider sink through a checked lookup instead of an index that may be absent. * fix(native-chat): call a cut a crash only when Orca's runtime started and never ended A chat said "Orca stopped unexpectedly" whenever its runtime left no quit record, and only the desktop quit wrote one, so a headless server's restart or update, the Settings relaunch, and a Windows logoff all read as crashes. Each runtime now records its own start when its chat store opens, and every graceful exit records its end through one synchronous entry point: the desktop quit's teardown, the headless server's stop, the in-app relaunch, the GPU-fallback restarts, the update-install watchdog, and Windows session end. A crash is a runtime that started and never ended; a runtime with no readable record (never written, pruned, unreadable) names no cause, so the chat keeps its generic words. One file per runtime, written durably and only by that runtime, so a damaged file never blocks a later write and two processes never lose each other's record. * fix(native-chat): Continue answers once Orca accepts it, not once the agent has started On a paired server, Continue waited for the agent to start before answering, and the client gives a paired call 15 s. A slow start (account switch, login shell, a long resume) showed "Couldn't continue this chat" while the agent was in fact continuing. Continue now answers when Orca has accepted the message, as a send does. The agent's start and answer settle afterwards, and a start that fails is the chat's own note, as before. The restart dialog's batch still waits for the handover, which is where it counts a start as done. The verdict helpers move to their own module to keep the continuation file within its size limit. * fix(native-chat): a reply the user steered, or a command run after the cut, still offers Continue The cut detector stopped at the first user message after the cut turn, so a steer the turn had taken, or a conversation command such as /context run after the cut, removed Continue while the row still named the cause. The rule for what is no request of its own (a conversation command, a row its turn produced, a send handed into a running turn) moves out of the latest-request reader into one shared predicate, which both that reader and the cut detector use. The host's "still wanted?" check reads the same detector, so the client and host agree. * fix(native-chat): a death proven after an earlier settle explains the turn it ends When a chat was read before the restart proved its old agent dead, the read could only call the turn unverifiable. The proof then revised the turn to interrupted, but the death row was scoped by the turn still marked running, and none was, so it landed on the conversation instead of the turn. That cut never offered Continue, and the row did not name it as the turn's explanation. The row is now scoped to the newest root turn the settle actually ends, running or revised. * fix(native-chat): the cause row's words stay put, and Continue waits out a resume already running The row's "You can continue in this conversation." came and went with the button: it showed while a paired host's answer was still on its way, vanished when the button appeared, and came back the moment Continue was clicked. Continue also appeared on chats the restart prompt or the launch's own resume was already carrying on. The row now drops that sentence wherever the chat's host can continue a cut, counting a host that has not answered yet as able (a host that writes cause rows has Continue), so its words never change on screen. Continue is hidden while a resume is carrying that chat on. * fix(native-chat): a refused Continue says so once, in the composer A Continue the host refused before accepting anything wrote a red note into the chat and brought the button back, so each retry added another identical note; a chat the host had no record of was refused with no word at all. Continue now reports every refusal the same way as a failed request: the existing composer line "Couldn't continue this chat. Try again, or send a message.", which a retry replaces rather than repeats. A refusal before acceptance writes no note. A failure after the message was accepted (the agent could not start) is still the chat's own note, as for any send. * fix(native-chat): the row naming Orca's stop carries its own presentation and never folds A client that re-words host rows it cannot name (the draft that makes these cuts read as interruptions) treated the cause row as an older red row and replaced its words, so the cause never showed there. The row was also folded away under its collapsed turn once shown muted. The host's cause row now names the presentation 'orca-stop' beside today's words, failure fact and red tone, so a client that predates both changes still prints exactly today's row, red and on screen, and a client that re-words unnamed rows passes it through. This build shows it muted, counts it as no failure (the reply it cut stays the turn's answer), and never folds it; the fold field becomes `explainsTurn`, as the other change names it. * test(native-chat): pass the session-end event without a type assertion * test(native-chat): the row naming Orca's stop renders neutral, whoever re-presented it Pins the rendered tone on this build: the stored red row, and the same row after a reader re-presents it in the neutral tone with its presentation and cause kept, both render muted and never fold. The phone draws chat rows without tone styling, so it needs no change. * fix(native-chat): the "Couldn't continue" line goes once the chat is continued The composer line a failed or refused Continue set stayed on screen while the agent carried on: after an answer lost in transit, or once another client or the restart prompt continued the chat. Only the next Continue click or the user's own send cleared it, and a click also wiped an unrelated composer error. The line is now derived: shown only while the chat still sits on the cut that Continue failed on, so it goes as soon as the journal shows the chat continued, from anywhere. A Continue click clears only its own line, and a retry answered "already continued" leaves none. * fix(native-chat): Continue waits while an opted-in launch may still resume the chat With "resume automatically" on, Continue showed on a quit or update cut while the launch was still waiting for its settings and reading the restart offer, then vanished when the launch's own resume began; a click in between sent a competing continuation. The launch's one decision (nothing offered, ask, or resume) is now published, and the chats it resumes are named the moment it decides, with no gap. Until it decides, and while the setting has not loaded or is on, Continue stays hidden on this machine's chats; a paired server's chats are not the launch's to resume and keep it. * fix(native-chat): a runtime's end survives a late reinstall, a failed write and any clean exit Three ways the runtime record could still read a graceful stop as a crash: - A chat host reinstalled during the quit (a request landing after teardown began) recorded the runtime's start again and erased the end it had just written. A second start of the same runtime now keeps that end. - When the end could not be written (a full disk), the start alone stayed and read as a crash. The runtime now removes its record, so its chats name no cause. - Each `app.exit(0)` had to remember to record the end. A process 'exit' with code 0 now records a quit when nothing else did: Electron emits it on every quit and exit once its loop runs (`app.exit` -> Browser::Shutdown -> the app's 'quit' -> process 'exit'), and Node on every `process.exit`. The relaunch and GPU-fallback calls it covers are dropped; the quit teardown, the headless server's stop, the update watchdog and Windows session end keep theirs, which run earlier or say more. * test(native-chat): build the re-presented row as the plain status item it is * fix(native-chat): a Continue click clears the composer's old error, so its own failure shows Since the "Couldn't continue" line became derived, an older composer error (such as "Remove attachments before using a chat-session command.") outranked it: a failed Continue showed the old error instead, and a Continue that went through left the old error on screen. A Continue click is the user's newer action, so it clears the composer's error again, as before; the line then shows the Continue's own failure, if any. That failure still goes away by itself once the chat is continued, and nothing but the user's own Continue click clears an unrelated composer error. * fix(native-chat): a chat start compares the owner process, not the runtime stamped on it A chat start checks that the process it just started is the one the record names, by a deep comparison of the stored owner with the adapter's process. The store stamps that owner with the Orca runtime holding it, so the check passed only because the store happened to return the record from before the stamp; returning the published record would have refused every chat start with agent_session_ownership_unknown. The start now compares the process identity without the runtime stamp, which says who holds the process rather than which process it is. * test(native-chat): count agentSession.continueInterrupted among the structured methods * refactor(native-chat): derive the structured chat's transcript session in its own hook Main's appearance work and this branch's Continue wiring together put NativeChatStructuredSession past the 400-line limit for components. The session the transcript reads moves, unchanged, to use-structured-chat-live-session.ts. * refactor(native-chat): keep the Continue capability in its own module Main grew protocol-version.ts to its line limit; the Continue capability moves to its own module, as other capability groups have, and the runtime list still names it. * refactor(native-chat): keep two shared files within their line limit after the main merge Main left agent-session-record.ts and structured-agent-session-params.ts just under 300 lines, and this branch's additions put them over. The account-home shape check moves next to the account-home type it checks (written without a type assertion), and the Continue params move to their own contract module; the params catalog is regenerated. No behavior change. * test(native-chat): compare the store directory's files without depending on listing order The corruption test checks that no file was created or removed by comparing two recursive listings. Their order is the runtime's: with the per-runtime record directory nested under the store, Bun returns the same entries in a different order than Node. Both listings are now sorted. * fix: share the path bound main's launch-directory check needs * test: give the stop-row fold rows the draws flag main's fold now reads * refactor: mark the launch's resume decision where the resume begins * test: count main's new structured method alongside agentSession.continueInterrupted * fix: the journal database keeps its folder, where runtime end records live Main's #26038 dropped stateDirectory from JournalHostDatabase; this PR's runtime end records are read from and written beside it.
This commit is contained in:
@@ -44,6 +44,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts',
|
||||
@@ -121,6 +122,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts',
|
||||
@@ -158,6 +160,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/agent-session-record-store-tab-table.test.ts',
|
||||
'src/main/runtime/agent-session-record-store.test.ts',
|
||||
'src/main/runtime/agent-session-released-reservation-replay.test.ts',
|
||||
'src/main/runtime/agent-session-runtime-attribution.test.ts',
|
||||
'src/main/runtime/agent-session-store-transactions.test.ts',
|
||||
'src/main/runtime/agent-session-unsaved-creation-supersession.test.ts',
|
||||
'src/main/runtime/automation-change-publication.test.ts',
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('grouping a Codex thread restored from full history', () => {
|
||||
draws: true,
|
||||
outlivesTurn: false,
|
||||
reportsFailure: false,
|
||||
reportsCompaction: false
|
||||
explainsTurn: false
|
||||
})),
|
||||
settledTurnKeys: new Set(bars.settledTurns.keys()),
|
||||
expandedTurnKeys: new Set()
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is
|
||||
// recorded beside its identity, and the start compares the process, not the stamp.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import {
|
||||
attachFingerprintFields,
|
||||
type AgentSessionAttachParams
|
||||
} from './structured-agent-session-attach'
|
||||
import { performAttach } from './structured-agent-session-attach-flow'
|
||||
import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SESSION = 'stamped-owner-session'
|
||||
const OPERATION = `${NOW}-${'1'.padStart(32, '0')}`
|
||||
let root: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
root = null
|
||||
})
|
||||
|
||||
function attachParams(): AgentSessionAttachParams {
|
||||
const params: AgentSessionAttachParams = {
|
||||
envelope: {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: OPERATION,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
||||
runtimeKind: 'native',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
envelope: {
|
||||
...params.envelope,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId: SESSION,
|
||||
fields: attachFingerprintFields(params)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('starts a chat whose recorded owner carries the runtime stamp', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-'))
|
||||
const store = await openTestAgentSessionRecordStore(root)
|
||||
// The record as the store published it, stamp included, rather than the transition's own copy.
|
||||
const commit = store.commitProcessIdentity.bind(store)
|
||||
vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => {
|
||||
await commit(input)
|
||||
const published = store.getRecord(input.sessionId)
|
||||
if (!published) {
|
||||
throw new Error('the committed record is missing')
|
||||
}
|
||||
return published
|
||||
})
|
||||
const adapter: StructuredAgentSessionAdapter = {
|
||||
acquire: vi.fn<StructuredAgentSessionAdapter['acquire']>(
|
||||
async ({ fence, spawnToken, onSpawned }) => {
|
||||
const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }
|
||||
await onSpawned?.(process)
|
||||
return {
|
||||
process,
|
||||
link: {
|
||||
linkId: 'created-link',
|
||||
handle: codexProviderHandle('thread-1'),
|
||||
origin: 'created',
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
}
|
||||
|
||||
const attached = await performAttach({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)),
|
||||
authority: {
|
||||
spawnToken: 'spawn-a',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: OPERATION,
|
||||
probe: { outcome: 'reservation-unused' }
|
||||
},
|
||||
callerKey: 'client-1',
|
||||
params: attachParams(),
|
||||
now: () => NOW,
|
||||
onAttached: () => {}
|
||||
})
|
||||
|
||||
expect(attached).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({
|
||||
pid: 4242,
|
||||
runtime: agentSessionRuntimeIncarnation()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionProcessIdentity,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import {
|
||||
AgentSessionPreSpawnError,
|
||||
isAgentSessionPreSpawnError,
|
||||
@@ -11,6 +14,17 @@ import type { AttachFlowInput } from './structured-agent-session-attach-flow'
|
||||
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
|
||||
import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation'
|
||||
|
||||
/** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp
|
||||
* is about who holds the process, not which process it is. */
|
||||
function sameOwnerProcess(
|
||||
stored: AgentSessionProcessIdentity,
|
||||
acquired: AgentSessionProcessIdentity
|
||||
): boolean {
|
||||
const { runtime: _storedRuntime, ...storedProcess } = stored
|
||||
const { runtime: _acquiredRuntime, ...acquiredProcess } = acquired
|
||||
return isDeepStrictEqual(storedProcess, acquiredProcess)
|
||||
}
|
||||
|
||||
/** A reservation with no process behind it is only a promise to spawn; the
|
||||
* adapter makes it real and the store then grants the writer. */
|
||||
export async function acquireOwner(
|
||||
@@ -79,7 +93,7 @@ export async function acquireOwner(
|
||||
process: acquired.process,
|
||||
now: input.now()
|
||||
})
|
||||
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
|
||||
} else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
const proved = await input.store.proveOwner({
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// What a continuation's send came to, read from its submission: the four dispatch states, never
|
||||
// collapsed into transport success (`StructuredAgentSessionContinuationOutcome`).
|
||||
|
||||
import {
|
||||
readAgentSessionFailureFact,
|
||||
type UnreadAgentSessionFailureFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation'
|
||||
|
||||
export type ContinuationSubmission = {
|
||||
dispatchState?: string
|
||||
reason?: string | null
|
||||
rejection?: UnreadAgentSessionFailureFact
|
||||
}
|
||||
|
||||
export function refusedBy(
|
||||
sessionId: string,
|
||||
submission: ContinuationSubmission
|
||||
): StructuredAgentSessionContinuationOutcome {
|
||||
// A start the agent was refused files that refusal's code, which the failure guidance keys on.
|
||||
const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal
|
||||
return {
|
||||
sessionId,
|
||||
outcome: 'refused',
|
||||
reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected',
|
||||
...(refusal ? { refusal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function verdictOf(
|
||||
sessionId: string,
|
||||
submission: ContinuationSubmission | undefined
|
||||
): StructuredAgentSessionContinuationOutcome {
|
||||
const dispatch = submission?.dispatchState
|
||||
if (submission && dispatch === 'rejected') {
|
||||
return refusedBy(sessionId, submission)
|
||||
}
|
||||
if (dispatch === 'pending') {
|
||||
// Still pending after settlement gave up: handed off, never confirmed.
|
||||
return { sessionId, outcome: 'pending' }
|
||||
}
|
||||
// `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims
|
||||
// neither success nor failure — and writes no note saying the agent was asked to continue.
|
||||
return dispatch === 'accepted'
|
||||
? { sessionId, outcome: 'continued' }
|
||||
: { sessionId, outcome: 'unknown' }
|
||||
}
|
||||
+37
@@ -39,6 +39,8 @@ import {
|
||||
import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import { beginAgentSessionRuntimeRecord } from '../../runtime/agent-session-runtime-end-record'
|
||||
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import {
|
||||
@@ -360,6 +362,41 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
|
||||
).toEqual({ key: 'workedFor', duration: '27s' })
|
||||
})
|
||||
|
||||
it('explains the turn its proof revised, so the cut reads once and offers Continue', async () => {
|
||||
// The Orca that crashed recorded that it started, and never that it ended.
|
||||
const crashed = crashedClaudeRecord()
|
||||
const ownerProcess = crashed.lease.ownerProcess
|
||||
if (!ownerProcess) {
|
||||
throw new Error('the crashed record has no owner')
|
||||
}
|
||||
await seedTestAgentSessionRecordStore(root, {
|
||||
records: [
|
||||
{
|
||||
...crashed,
|
||||
lease: { ...crashed.lease, ownerProcess: { ...ownerProcess, runtime: 'runtime-crashed' } }
|
||||
}
|
||||
]
|
||||
})
|
||||
beginAgentSessionRuntimeRecord(root, 'runtime-crashed', TOOL_STARTED_AT - 60_000)
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
|
||||
expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN)
|
||||
|
||||
await host.reconcileRestartLeases()
|
||||
await drainSession()
|
||||
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId
|
||||
expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.turnScope] : []))).toEqual([
|
||||
{ kind: 'turn', turnItemId }
|
||||
])
|
||||
expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' })
|
||||
const readerRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).filter(
|
||||
(item) => item.body.kind === 'status'
|
||||
)
|
||||
expect(readerRows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('revises nothing twice, whoever re-runs the settle', async () => {
|
||||
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
|
||||
await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
|
||||
+5
-11
@@ -45,8 +45,9 @@ import {
|
||||
} from './structured-agent-session-stale-turn-verdict'
|
||||
import {
|
||||
exitedRootTurnScope,
|
||||
runningRootTurnScope
|
||||
settledRootTurnScope
|
||||
} from './structured-agent-session-exit-turn-scope'
|
||||
import { orcaStopRowBody } from './structured-agent-session-orca-stop-row'
|
||||
import {
|
||||
hasUnfinishedStructuredAgentSessionWork,
|
||||
isInProgressStructuredAgentSessionItem,
|
||||
@@ -242,16 +243,9 @@ export async function settleStaleStructuredAgentSessionState(input: {
|
||||
clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}`
|
||||
},
|
||||
// The death evidence is Orca's log text, never a sentence for a person: the row says only
|
||||
// that the provider stopped.
|
||||
body: {
|
||||
kind: 'status',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('providerExited'), {
|
||||
...input.failureTextContext,
|
||||
surface: 'row'
|
||||
}),
|
||||
tone: 'error'
|
||||
},
|
||||
turnScope: runningRootTurnScope(items)
|
||||
// that the provider stopped, and how Orca ended when the provider died with it.
|
||||
body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd),
|
||||
turnScope: settledRootTurnScope(items, turnEnds)
|
||||
})
|
||||
}
|
||||
for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) {
|
||||
|
||||
+10
-6
@@ -7,6 +7,8 @@ import {
|
||||
type AgentJournalTurnScope
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
|
||||
import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict'
|
||||
|
||||
/** The turn the exit ended: still running, or already ended at the exit's instant by the child's
|
||||
@@ -26,11 +28,13 @@ export function exitedRootTurnScope(
|
||||
return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
|
||||
export function runningRootTurnScope(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
/** The newest root turn a settle ends: one still running, or one an earlier settle left
|
||||
* `unverifiable` that a proof now revises, whose row no longer reads running. */
|
||||
export function settledRootTurnScope(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
turnEnds: readonly JournalLifecycleMutationInput[]
|
||||
): AgentJournalTurnScope {
|
||||
const running = items.findLast(
|
||||
(item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running'
|
||||
)
|
||||
return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE
|
||||
const ended = new Set(turnEnds.map((mutation) => agentJournalItemKey(mutation.identity)))
|
||||
const settled = items.findLast((item) => isRootAgentJournalItem(item) && ended.has(item.itemId))
|
||||
return settled ? { kind: 'turn', turnItemId: settled.itemId } : AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
structuredAgentSessionHostInstance
|
||||
} from './structured-agent-session-queued-pause'
|
||||
import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter'
|
||||
import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker'
|
||||
import {
|
||||
recordStructuredAgentSessionShutdownCut,
|
||||
runningRootTurnItemId
|
||||
} from './structured-agent-session-orca-stop-row'
|
||||
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
|
||||
export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event'
|
||||
import {
|
||||
recordStopEvent,
|
||||
@@ -162,6 +168,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
)
|
||||
}
|
||||
const { close } = child
|
||||
// Read before the kill: the turn a quit's stop may cut.
|
||||
const quitCuts = 'quit' in ending && ending.quit ? runningRootTurnItemId(session.journal) : null
|
||||
try {
|
||||
if (context.restartWitness) {
|
||||
await snapshotBeforeStructuredAgentSessionStop(
|
||||
@@ -180,6 +188,22 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
|
||||
new Error('provider child exit was not proven')
|
||||
)
|
||||
}
|
||||
// The exit handler has settled the turn by now, so the row reads its verdict.
|
||||
if ('quit' in ending && ending.quit) {
|
||||
const record = context.deps.store.getRecord(sessionId)
|
||||
await recordStructuredAgentSessionShutdownCut({
|
||||
journal: session.journal,
|
||||
sessionId,
|
||||
fence: child.fence,
|
||||
generation: child.generation ?? 'unknown',
|
||||
turnItemId: quitCuts,
|
||||
trigger: ending.quit,
|
||||
...(record
|
||||
? { failureTextContext: structuredAgentSessionFailureWordsContext(record) }
|
||||
: {}),
|
||||
logger: context.deps.logger
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// A person's close binds what its child's end cut; done, proven or not, it binds no more.
|
||||
void close?.recorded.then((settle) => session.journal.stopMarks.settled(settle))
|
||||
@@ -237,7 +261,8 @@ export async function evictOwnedStructuredAgentSessions(
|
||||
context: StructuredAgentSessionLifetimeContext & {
|
||||
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
|
||||
},
|
||||
retainOnFailure: Set<string>
|
||||
retainOnFailure: Set<string>,
|
||||
trigger: AgentSessionResumeTrigger = 'quit'
|
||||
): Promise<void> {
|
||||
const ownedSessionIds = [...context.sessions]
|
||||
.filter(([, session]) => session.child !== null)
|
||||
@@ -255,7 +280,7 @@ export async function evictOwnedStructuredAgentSessions(
|
||||
await context.serialize(sessionId, () =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, {
|
||||
cause: 'evict',
|
||||
quit: true
|
||||
quit: trigger
|
||||
})
|
||||
)
|
||||
retainOnFailure.delete(sessionId)
|
||||
|
||||
@@ -9,16 +9,18 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause'
|
||||
import { sentSinceStop } from './structured-agent-session-queued-stop'
|
||||
import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker'
|
||||
|
||||
/** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its
|
||||
* own step (`recorded` names its reason); any other stop names the reason its event records, with
|
||||
* the host's text for it. Quit writes none: its resume marker's trigger records why. */
|
||||
* the host's text for it. Quit writes none: its row about a turn it cut says why. */
|
||||
export type StructuredAgentSessionStopEnding =
|
||||
| { recorded: 'user-stop' }
|
||||
| {
|
||||
cause: Exclude<StructuredAgentSessionStopCause, 'user-stop'>
|
||||
reason?: string
|
||||
quit?: true
|
||||
/** The app is quitting, and why: a turn this stop cuts gets a row naming it. */
|
||||
quit?: AgentSessionResumeTrigger
|
||||
/** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is
|
||||
* no work its event records. */
|
||||
resting?: true
|
||||
|
||||
@@ -174,7 +174,8 @@ export async function flushStructuredAgentSessionHost(
|
||||
stopped: context.restartResume.confirmStopped
|
||||
}
|
||||
},
|
||||
retainSessionIds
|
||||
retainSessionIds,
|
||||
context.trigger
|
||||
),
|
||||
beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger),
|
||||
recordResumeMarkers: context.restartResume.recordMarkers,
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
// Continue on a reply an Orca stop cut off: the restart continuation, bound to the cut turn and
|
||||
// re-checked under the session lock, so it works with or without a restart offer and never sends
|
||||
// twice.
|
||||
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_RESTART_CONTINUATION_MESSAGE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_NOTE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
restartContinuationMessage
|
||||
} from '../../../shared/agent-session-restart-continuation'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import { CALLER, envelope } from './structured-agent-session-host-test-harness'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring'
|
||||
import {
|
||||
interruptedRestart,
|
||||
statusNotes
|
||||
} from './structured-agent-session-restart-interruption-test-harness'
|
||||
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { previousExitUnverifiableRefusal } from './structured-agent-session-child-close'
|
||||
|
||||
type Restarted = Awaited<ReturnType<typeof interruptedRestart>>
|
||||
|
||||
async function cutTurn(host: StructuredAgentSessionHost): Promise<string> {
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
const cut = latestNativeChatOrcaStopCut(items, [])
|
||||
if (!cut) {
|
||||
throw new Error('the restart left no Orca-stop cut')
|
||||
}
|
||||
return cut.turnItemId
|
||||
}
|
||||
|
||||
function sentTexts(dispatch: Restarted['dispatch']): string[] {
|
||||
return dispatch.mock.calls.map(([input]) =>
|
||||
input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('')
|
||||
)
|
||||
}
|
||||
|
||||
it('continues with no restart offer, as Orca itself, and the chat says so', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
// What a crash, a paired server's restart or a dismissed offer leaves: no offer at all.
|
||||
await host.restartResume.dismiss()
|
||||
expect(await host.restartResume.list()).toEqual([])
|
||||
const send = vi.spyOn(host, 'send')
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, await cutTurn(host))
|
||||
|
||||
expect(answer).toMatchObject({
|
||||
sessionId: SESSION,
|
||||
outcome: expect.stringMatching(/pending|continued/)
|
||||
})
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(sentTexts(dispatch)).toEqual([AGENT_SESSION_RESTART_CONTINUATION_MESSAGE])
|
||||
// Sent under Orca's own caller, so it reads as Orca's and leaves a queue pause alone.
|
||||
expect(send.mock.calls[0]?.[0]).toEqual({
|
||||
callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER
|
||||
})
|
||||
await vi.waitFor(async () =>
|
||||
expect(JSON.stringify(await statusNotes(host))).toContain(
|
||||
AGENT_SESSION_RESTART_CONTINUATION_NOTE
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('continues a chat that still has its restart offer, in its words, and the offer is spent', async () => {
|
||||
const { host, dispatch, marker } = await interruptedRestart()
|
||||
expect(await host.restartResume.list()).toHaveLength(1)
|
||||
|
||||
await host.restartResume.continueInterrupted(SESSION, await cutTurn(host))
|
||||
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)])
|
||||
expect(await host.restartResume.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('sends once for two clicks at the same moment', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
|
||||
const answers = await Promise.all([
|
||||
host.restartResume.continueInterrupted(SESSION, turnItemId),
|
||||
host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
])
|
||||
|
||||
expect(answers.filter((answer) => answer.outcome === 'superseded')).toHaveLength(1)
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('sends nothing for a retry after an answer that was lost', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
|
||||
// The client never heard back, so it asks again for the same cut.
|
||||
const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect(retry).toEqual({ sessionId: SESSION, outcome: 'superseded' })
|
||||
expect(dispatch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("sends nothing once the user's own message came first", async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
const body = hostTestMessage('A new request')
|
||||
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' })
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(sentTexts(dispatch)).toEqual(['A new request'])
|
||||
})
|
||||
|
||||
it('sends nothing for a turn that is not the cut the chat sits on', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, 'some-other-turn')
|
||||
|
||||
expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' })
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues when reading the restart offer hangs', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
vi.spyOn(host.deps.recoveryCapsule!, 'list').mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
}, 10_000)
|
||||
|
||||
it('continues when the restart offer cannot be read', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
vi.spyOn(host.deps.recoveryCapsule!, 'list').mockRejectedValue(new Error('unreadable'))
|
||||
|
||||
await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('answers once Orca accepted it, while the agent is still starting', async () => {
|
||||
const { host, acquire, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
const start = acquire.getMockImplementation()
|
||||
if (!start) {
|
||||
throw new Error('the harness acquire has no implementation')
|
||||
}
|
||||
let finishStarting!: () => void
|
||||
const starting = new Promise<void>((resolve) => {
|
||||
finishStarting = resolve
|
||||
})
|
||||
// An agent start slower than a paired client's wait for the answer.
|
||||
acquire.mockImplementationOnce(async (input) => {
|
||||
await starting
|
||||
return start(input)
|
||||
})
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' })
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
finishStarting()
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('says in the chat when the agent cannot start after the click was answered', async () => {
|
||||
const { host, acquire } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
acquire.mockRejectedValueOnce(new Error('the agent could not start'))
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' })
|
||||
await vi.waitFor(async () =>
|
||||
expect(await statusNotes(host)).toContainEqual({
|
||||
text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
tone: 'error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('continues a reply the user had steered before Orca stopped', async () => {
|
||||
const { host, dispatch, store, marker } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
const fence = store.getRecord(SESSION)?.lease.runtimeFence
|
||||
const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal
|
||||
if (fence === undefined || fence === null || !journal) {
|
||||
throw new Error('the restarted chat has no open journal')
|
||||
}
|
||||
// A steer the cut turn took: the client still offers Continue, so the host must agree.
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'steer' },
|
||||
hostTestMessage('also update the tests'),
|
||||
{ fence, turnScope: { kind: 'turn', turnItemId } }
|
||||
)
|
||||
|
||||
const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' })
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)])
|
||||
})
|
||||
|
||||
it('answers a refusal before anything was accepted, and a retry adds no note to the chat', async () => {
|
||||
const { host, dispatch } = await interruptedRestart()
|
||||
const turnItemId = await cutTurn(host)
|
||||
const notesBefore = await statusNotes(host)
|
||||
vi.spyOn(host, 'send').mockResolvedValue({
|
||||
ok: false,
|
||||
refusal: previousExitUnverifiableRefusal()
|
||||
})
|
||||
|
||||
const first = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId)
|
||||
|
||||
expect([first.outcome, retry.outcome]).toEqual(['refused', 'refused'])
|
||||
// The click's answer is the one report; the chat stays as it was.
|
||||
expect(await statusNotes(host)).toEqual(notesBefore)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Continue, pressed in a chat whose reply an Orca stop cut off: the restart continuation, bound to
|
||||
// that cut turn rather than to a restart offer. An offer, when one survives, only picks the words;
|
||||
// none is needed, so a crash or a paired server's restart continues the same way.
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import {
|
||||
continuationDeps,
|
||||
RestartContinuationSupersededError,
|
||||
startStructuredAgentSessionContinuation,
|
||||
type StructuredAgentSessionContinuationHost,
|
||||
type StructuredAgentSessionContinuationOutcome
|
||||
} from './structured-agent-session-restart-continuation'
|
||||
|
||||
export type StructuredAgentSessionInterruptedContinuationOutcome = {
|
||||
sessionId: string
|
||||
/** `superseded`: the chat is no longer sitting on that cut (a message, or another Continue, came
|
||||
* first), so nothing was sent. */
|
||||
outcome: StructuredAgentSessionContinuationOutcome['outcome'] | 'superseded'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
const hex16 = (parts: readonly unknown[]): string =>
|
||||
createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)
|
||||
|
||||
/** Dated by the click, as the ledger requires of a new id; tagged with the cut it continues. */
|
||||
function interruptedContinuationId(
|
||||
sessionId: string,
|
||||
turnItemId: string,
|
||||
actionAt: number,
|
||||
nonce: string
|
||||
): string {
|
||||
return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${hex16([sessionId, turnItemId])}${hex16([sessionId, turnItemId, nonce])}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the continuation when, at acceptance inside the session lock, the chat's latest turn is
|
||||
* still `turnItemId`, cut by an Orca stop with nothing sent since. A second Continue (another
|
||||
* client, a retry after a lost answer) finds the first one's message there and sends nothing.
|
||||
*/
|
||||
export async function continueInterruptedStructuredAgentSessionTurn(
|
||||
host: Omit<StructuredAgentSessionContinuationHost, 'stillResumable'>,
|
||||
input: {
|
||||
sessionId: string
|
||||
turnItemId: string
|
||||
/** The chat's restart offer, when one could be read: its words cover lost background work. */
|
||||
offer: Pick<AgentSessionResumeMarker, 'activity'> | null
|
||||
nonce: string
|
||||
}
|
||||
): Promise<StructuredAgentSessionInterruptedContinuationOutcome> {
|
||||
const { sessionId, turnItemId } = input
|
||||
const stillCut = (): boolean => {
|
||||
const journal = host.sessions.get(sessionId)?.journal
|
||||
return (
|
||||
journal !== undefined &&
|
||||
latestNativeChatOrcaStopCut(journal.snapshot().items, journal.submissions())?.turnItemId ===
|
||||
turnItemId
|
||||
)
|
||||
}
|
||||
const continuationId = interruptedContinuationId(sessionId, turnItemId, host.now(), input.nonce)
|
||||
try {
|
||||
const started = await startStructuredAgentSessionContinuation(
|
||||
// As a send answers: a paired client's wait is shorter than an agent's start can be.
|
||||
{ ...continuationDeps(host, stillCut), answerAtAcceptance: true },
|
||||
sessionId,
|
||||
input.offer ?? {},
|
||||
continuationId
|
||||
)
|
||||
if ('verdict' in started) {
|
||||
// Accepted: the agent's start and answer can take a whole turn, and the chat's own note
|
||||
// reports a failure, so the click is answered now.
|
||||
void started.verdict().catch(() => {
|
||||
host.logger.warn('settling an interrupted-turn continuation failed', {
|
||||
scope: 'interrupted-continuation',
|
||||
sessionId
|
||||
})
|
||||
})
|
||||
return { sessionId, outcome: 'pending' }
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
outcome: started.done.outcome,
|
||||
...(started.done.reason ? { reason: started.done.reason } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RestartContinuationSupersededError) {
|
||||
return { sessionId, outcome: 'superseded' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Reading the offer only picks the words, so a slow recovery file never holds Continue. */
|
||||
const OFFER_READ_TIMEOUT_MS = 2_000
|
||||
|
||||
/** Continue for one host: its offer, when one can be read in time, picks the words. */
|
||||
export function createInterruptedContinuation(
|
||||
host: Omit<StructuredAgentSessionContinuationHost, 'stillResumable'>,
|
||||
readOffers: () => Promise<readonly AgentSessionResumeMarker[]>
|
||||
): (
|
||||
sessionId: string,
|
||||
turnItemId: string
|
||||
) => Promise<StructuredAgentSessionInterruptedContinuationOutcome> {
|
||||
return async (sessionId, turnItemId) => {
|
||||
const offers = await withTimeout(
|
||||
readOffers().catch(() => []),
|
||||
OFFER_READ_TIMEOUT_MS,
|
||||
[]
|
||||
)
|
||||
return continueInterruptedStructuredAgentSessionTurn(host, {
|
||||
sessionId,
|
||||
turnItemId,
|
||||
offer: offers.find((offer) => offer.sessionId === sessionId) ?? null,
|
||||
nonce: randomUUID()
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -53,7 +53,8 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag
|
||||
await attach()
|
||||
// The attach's restart-offer withdrawal holds a lock file until it ends; snapshot after it.
|
||||
await hostTestRecoveryCapsuleSettled()
|
||||
const files = await readdir(root, { recursive: true })
|
||||
// Order-free: recursive listing order is the runtime's, and only what exists matters.
|
||||
const files = (await readdir(root, { recursive: true })).toSorted()
|
||||
const damaged = sqliteError('database disk image is malformed', 11)
|
||||
vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => {
|
||||
throw damaged
|
||||
@@ -81,7 +82,7 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag
|
||||
error: expect.objectContaining({ message: 'database disk image is malformed' })
|
||||
})
|
||||
await hostTestRecoveryCapsuleSettled()
|
||||
expect(await readdir(root, { recursive: true })).toEqual(files)
|
||||
expect((await readdir(root, { recursive: true })).toSorted()).toEqual(files)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// The row about Orca's own stop, as each kind of client reads it. A client that predates the cause
|
||||
// prints today's row, red and on screen; this build names the cause, muted, and never folds it.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import { nativeChatTurnFold } from '../../../shared/native-chat-turn-fold'
|
||||
import { orcaStopRowBody } from './structured-agent-session-orca-stop-row'
|
||||
|
||||
const LEGACY_TEXT =
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 })
|
||||
|
||||
function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] {
|
||||
const scope = { kind: 'turn' as const, turnItemId: TURN }
|
||||
return [
|
||||
{
|
||||
itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'user-1' }),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
},
|
||||
{
|
||||
itemId: TURN,
|
||||
revision: 2,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 }
|
||||
},
|
||||
{
|
||||
itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 2 }),
|
||||
revision: 1,
|
||||
sequence: 3,
|
||||
observedAt: 3,
|
||||
turnScope: scope,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] }
|
||||
},
|
||||
{
|
||||
itemId: agentJournalItemKey({
|
||||
provider: 'orca',
|
||||
clientMessageId: 'stale-session:session-1:shutdown-3-gen-1'
|
||||
}),
|
||||
revision: 1,
|
||||
sequence: 4,
|
||||
observedAt: 6,
|
||||
turnScope: scope,
|
||||
body: row
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('the row a host writes for a cut it knows the cause of', () => {
|
||||
const row = orcaStopRowBody({ agentName: 'Codex' }, 'update')
|
||||
|
||||
it("keeps today's words and tone, and names its presentation and cause beside them", () => {
|
||||
expect(row).toMatchObject({
|
||||
kind: 'status',
|
||||
text: LEGACY_TEXT,
|
||||
tone: 'error',
|
||||
failure: { kind: 'providerExited' },
|
||||
presentation: 'orca-stop',
|
||||
orcaStop: { cause: 'update' }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads, on a client that predates the cause, as the one row it prints today, on screen', () => {
|
||||
const items = cutChat(row)
|
||||
const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' })
|
||||
expect(read).toBe(items)
|
||||
expect(read.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))).toEqual([
|
||||
LEGACY_TEXT
|
||||
])
|
||||
// That client's fold reads only the stored tone: a red row is the turn's end, never folded.
|
||||
const rows = read.map((item) => ({
|
||||
turnKey: item.itemId === read[0]!.itemId ? undefined : TURN,
|
||||
role: item.body.kind === 'message' ? item.body.role : ('system' as const),
|
||||
rendersProse: item.body.kind !== 'turn',
|
||||
draws: true,
|
||||
outlivesTurn: false,
|
||||
reportsFailure: item.body.kind === 'status' && item.body.tone === 'error',
|
||||
explainsTurn: false
|
||||
}))
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
settledTurnKeys: new Set([TURN]),
|
||||
expandedTurnKeys: new Set()
|
||||
})
|
||||
expect(foldedRows.has(3)).toBe(false)
|
||||
})
|
||||
|
||||
it('offers Continue on this build', () => {
|
||||
expect(latestNativeChatOrcaStopCut(cutChat(row), [])).toEqual({
|
||||
turnItemId: TURN,
|
||||
cause: 'update'
|
||||
})
|
||||
})
|
||||
|
||||
it('names no presentation when the cause is unknown, so it reads as any owner death', () => {
|
||||
expect(orcaStopRowBody({ agentName: 'Codex' }, undefined)).not.toHaveProperty('presentation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// The row that says why a quit or update cut a reply: the cut turn's own explanation, in the words
|
||||
// every client already prints for a stopped agent, with the cause beside them for clients that
|
||||
// name it (`AgentSessionOrcaStop`).
|
||||
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import {
|
||||
AGENT_SESSION_ORCA_STOP_PRESENTATION,
|
||||
isAgentSessionOrcaStopCause
|
||||
} from '../../../shared/agent-session-orca-stop'
|
||||
import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker'
|
||||
import {
|
||||
agentSessionFailureWords,
|
||||
type AgentSessionFailureWordsContext
|
||||
} from '../../../shared/agent-session-failure-words'
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalStatusItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
readAgentJournalTurn,
|
||||
readAgentJournalTurnOutcome
|
||||
} from '../../../shared/agent-session-turn-record'
|
||||
import { agentTurnVerdict } from '../../../shared/agent-turn-outcome'
|
||||
import { orcaShutdownRowClientMessageId } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
|
||||
type OrcaStopRowJournal = Pick<AgentSessionJournal, 'snapshot' | 'appendItem'>
|
||||
|
||||
/** The row about an owner gone from under a turn: today's words and tone, which every client prints
|
||||
* as it always has, and why Orca stopped when it was Orca, which a client that knows the cause names
|
||||
* instead, muted. */
|
||||
export function orcaStopRowBody(
|
||||
context: AgentSessionFailureWordsContext | undefined,
|
||||
orcaEnd: unknown
|
||||
): AgentJournalStatusItem {
|
||||
return {
|
||||
kind: 'status',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('providerExited'), {
|
||||
...context,
|
||||
surface: 'row'
|
||||
}),
|
||||
tone: 'error',
|
||||
...(isAgentSessionOrcaStopCause(orcaEnd)
|
||||
? { presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, orcaStop: { cause: orcaEnd } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The root turn running as the quit stops the child: the one its stop may cut. */
|
||||
export function runningRootTurnItemId(
|
||||
journal: Pick<AgentSessionJournal, 'snapshot'>
|
||||
): string | null {
|
||||
return (
|
||||
journal
|
||||
.snapshot()
|
||||
.items.findLast(
|
||||
(item) =>
|
||||
isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running'
|
||||
)?.itemId ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function cutByNobody(item: AgentJournalRenderItem | undefined): boolean {
|
||||
const turn = item ? readAgentJournalTurn(item.body) : null
|
||||
return (
|
||||
turn !== null &&
|
||||
agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) ===
|
||||
'interruption'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the quit's stop has settled: when the turn running at the stop now reads cut with nobody
|
||||
* asking (not finished, not a person's Stop), say why. Never throws: a row that cannot be written
|
||||
* must not keep the quit from releasing the chat.
|
||||
*/
|
||||
export async function recordStructuredAgentSessionShutdownCut(input: {
|
||||
journal: OrcaStopRowJournal
|
||||
sessionId: string
|
||||
fence: number
|
||||
generation: string
|
||||
turnItemId: string | null
|
||||
trigger: AgentSessionResumeTrigger
|
||||
failureTextContext?: AgentSessionFailureWordsContext
|
||||
logger: StructuredAgentSessionLogger
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { turnItemId } = input
|
||||
if (turnItemId === null) {
|
||||
return
|
||||
}
|
||||
const clientMessageId = orcaShutdownRowClientMessageId(
|
||||
input.sessionId,
|
||||
input.fence,
|
||||
input.generation
|
||||
)
|
||||
const items = input.journal.snapshot().items
|
||||
const written = items.some((item) => {
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
return identity?.provider === 'orca' && identity.clientMessageId === clientMessageId
|
||||
})
|
||||
if (written || !cutByNobody(items.find((item) => item.itemId === turnItemId))) {
|
||||
return
|
||||
}
|
||||
await input.journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId },
|
||||
orcaStopRowBody(input.failureTextContext, input.trigger),
|
||||
{ fence: input.fence, turnScope: { kind: 'turn', turnItemId } }
|
||||
)
|
||||
} catch (error) {
|
||||
input.logger.warn('recording why a quit cut a reply failed', {
|
||||
scope: 'shutdown-cut-row',
|
||||
sessionId: input.sessionId,
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -9,7 +9,9 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res
|
||||
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
|
||||
|
||||
/** The message body, built once so both the send and any test read the same text. */
|
||||
export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem {
|
||||
export function restartContinuationBody(
|
||||
marker: Pick<AgentSessionResumeMarker, 'activity'>
|
||||
): AgentJournalMessageItem {
|
||||
return {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
@@ -57,7 +59,7 @@ export function isRestartContinuationOf(
|
||||
export function restartContinuationEnvelope(
|
||||
sessionId: string,
|
||||
fence: number,
|
||||
marker: AgentSessionResumeMarker,
|
||||
marker: Pick<AgentSessionResumeMarker, 'activity'>,
|
||||
continuationId: string
|
||||
): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } {
|
||||
const body = restartContinuationBody(marker)
|
||||
|
||||
+40
-66
@@ -10,14 +10,15 @@ import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalMessageItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
readAgentSessionFailureFact,
|
||||
type UnreadAgentSessionFailureFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import {
|
||||
agentSessionRefusalReference,
|
||||
type AgentSessionRefusalReference
|
||||
} from '../../../shared/agent-session-wire-refusals'
|
||||
import {
|
||||
refusedBy,
|
||||
verdictOf,
|
||||
type ContinuationSubmission
|
||||
} from './structured-agent-session-continuation-verdict'
|
||||
import {
|
||||
agentSessionSendSubmission,
|
||||
type AgentSessionMutationEnvelope,
|
||||
@@ -85,10 +86,11 @@ export type StructuredAgentSessionContinuationHost = {
|
||||
}
|
||||
|
||||
/** Binds one continuation to the host: the superseded check before dispatch, the settlement
|
||||
* waiter for the verdict, and the journal note that attributes the send to Orca. */
|
||||
export function restartContinuationDeps(
|
||||
host: StructuredAgentSessionContinuationHost,
|
||||
marker: AgentSessionResumeMarker
|
||||
* waiter for the verdict, and the journal note that attributes the send to Orca. `stillWanted` is
|
||||
* asked at acceptance, inside the session lock, so the first message accepted since decides. */
|
||||
export function continuationDeps(
|
||||
host: Omit<StructuredAgentSessionContinuationHost, 'stillResumable'>,
|
||||
stillWanted: () => boolean
|
||||
): StructuredAgentSessionContinuationDeps {
|
||||
return {
|
||||
currentFence: host.conversationFence,
|
||||
@@ -96,7 +98,7 @@ export function restartContinuationDeps(
|
||||
host.send({
|
||||
...input,
|
||||
beforeRun: () => {
|
||||
if (!host.stillResumable(marker)) {
|
||||
if (!stillWanted()) {
|
||||
throw new RestartContinuationSupersededError()
|
||||
}
|
||||
}
|
||||
@@ -154,12 +156,6 @@ export class RestartContinuationSupersededError extends AgentSessionPreDispatchE
|
||||
}
|
||||
}
|
||||
|
||||
type ContinuationSubmission = {
|
||||
dispatchState?: string
|
||||
reason?: string | null
|
||||
rejection?: UnreadAgentSessionFailureFact
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionContinuationDeps = {
|
||||
/** Runtime fence as it stands now; null when this host has no record of the session. */
|
||||
currentFence: (sessionId: string) => number | null
|
||||
@@ -197,6 +193,9 @@ export type StructuredAgentSessionContinuationDeps = {
|
||||
/** Where a note that could not be written is reported. The note is best effort, but its failure
|
||||
* is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */
|
||||
logger: StructuredAgentSessionLogger
|
||||
/** Answer once Orca accepted the message, as a send does, not once the agent took it. A refusal
|
||||
* before acceptance is then the caller's to report, not a note: a retry adds none. */
|
||||
answerAtAcceptance?: true
|
||||
}
|
||||
|
||||
/** A continuation handed to its agent, or already decided. */
|
||||
@@ -206,13 +205,15 @@ export type StartedStructuredAgentSessionContinuation =
|
||||
|
||||
/**
|
||||
* Sends the continuation to ONE session and returns once the agent has taken it or its start
|
||||
* failed — the point a restart batch counts a start as done. What the provider then answered is
|
||||
* the `verdict`, awaited separately so a slow answer does not hold the batch.
|
||||
* failed — the point a restart batch counts a start as done — or, with `answerAtAcceptance`, once
|
||||
* Orca accepted it. What the provider then answered is the `verdict`, awaited separately so a slow
|
||||
* answer does not hold the batch.
|
||||
*/
|
||||
export async function startStructuredAgentSessionContinuation(
|
||||
deps: StructuredAgentSessionContinuationDeps,
|
||||
sessionId: string,
|
||||
marker: AgentSessionResumeMarker,
|
||||
/** Only what picks the message's words; a continuation with no offer passes none. */
|
||||
marker: Pick<AgentSessionResumeMarker, 'activity'>,
|
||||
/** This action's continuation, as its offer recorded it. */
|
||||
continuationId: string
|
||||
): Promise<StartedStructuredAgentSessionContinuation> {
|
||||
@@ -221,13 +222,15 @@ export async function startStructuredAgentSessionContinuation(
|
||||
started = await sendContinuation(deps, sessionId, marker, continuationId)
|
||||
} catch (error) {
|
||||
// The user's own message came first: nothing failed, so the chat says nothing.
|
||||
if (!(error instanceof RestartContinuationSupersededError)) {
|
||||
if (!(error instanceof RestartContinuationSupersededError) && !deps.answerAtAcceptance) {
|
||||
await noteNotContinued(deps, sessionId, 'refused')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if ('done' in started) {
|
||||
await noteOutcome(deps, sessionId, started.done)
|
||||
if (!(deps.answerAtAcceptance && started.done.outcome === 'refused')) {
|
||||
await noteOutcome(deps, sessionId, started.done)
|
||||
}
|
||||
return started
|
||||
}
|
||||
const { verdict } = started
|
||||
@@ -291,7 +294,7 @@ async function noteNotContinued(
|
||||
async function sendContinuation(
|
||||
deps: StructuredAgentSessionContinuationDeps,
|
||||
sessionId: string,
|
||||
marker: AgentSessionResumeMarker,
|
||||
marker: Pick<AgentSessionResumeMarker, 'activity'>,
|
||||
continuationId: string
|
||||
): Promise<StartedStructuredAgentSessionContinuation> {
|
||||
const fence = deps.currentFence(sessionId)
|
||||
@@ -325,54 +328,25 @@ async function sendContinuation(
|
||||
}
|
||||
}
|
||||
const clientMessageId = envelope.clientOperationId
|
||||
const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined)
|
||||
const handOver = () => deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined)
|
||||
const settle = async (handedOver: ContinuationSubmission | undefined) =>
|
||||
verdictOf(
|
||||
sessionId,
|
||||
// The send result carries the dispatch as it stood when Orca took the message, which for
|
||||
// a normal successful send is `pending`, so the settled value is what decides.
|
||||
(await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ??
|
||||
handedOver ??
|
||||
sent.value?.submission
|
||||
)
|
||||
if (deps.answerAtAcceptance) {
|
||||
// The agent's start can outlast the caller's wait; a start refused is the verdict's note.
|
||||
return { verdict: async () => settle(await handOver()) }
|
||||
}
|
||||
const handedOver = await handOver()
|
||||
if (handedOver?.dispatchState === 'rejected') {
|
||||
return { done: refusedBy(sessionId, handedOver) }
|
||||
}
|
||||
return {
|
||||
verdict: async () =>
|
||||
verdictOf(
|
||||
sessionId,
|
||||
// The send result carries the dispatch as it stood when Orca took the message, which for
|
||||
// a normal successful send is `pending`, so the settled value is what decides.
|
||||
(await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ??
|
||||
handedOver ??
|
||||
sent.value?.submission
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function refusedBy(
|
||||
sessionId: string,
|
||||
submission: ContinuationSubmission
|
||||
): StructuredAgentSessionContinuationOutcome {
|
||||
// A start the agent was refused files that refusal's code, which the failure guidance keys on.
|
||||
const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal
|
||||
return {
|
||||
sessionId,
|
||||
outcome: 'refused',
|
||||
reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected',
|
||||
...(refusal ? { refusal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function verdictOf(
|
||||
sessionId: string,
|
||||
submission: ContinuationSubmission | undefined
|
||||
): StructuredAgentSessionContinuationOutcome {
|
||||
const dispatch = submission?.dispatchState
|
||||
if (submission && dispatch === 'rejected') {
|
||||
return refusedBy(sessionId, submission)
|
||||
}
|
||||
if (dispatch === 'pending') {
|
||||
// Still pending after settlement gave up: handed off, never confirmed.
|
||||
return { sessionId, outcome: 'pending' }
|
||||
}
|
||||
// `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims
|
||||
// neither success nor failure — and writes no note saying the agent was asked to continue.
|
||||
return dispatch === 'accepted'
|
||||
? { sessionId, outcome: 'continued' }
|
||||
: { sessionId, outcome: 'unknown' }
|
||||
return { verdict: () => settle(handedOver) }
|
||||
}
|
||||
|
||||
/** Without the error: a failed append can carry the chat's private recovery payload. */
|
||||
|
||||
+2
-2
@@ -45,8 +45,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends
|
||||
const capsule = new AgentSessionRecoveryCapsule(root)
|
||||
expect(await capsule.listFailed(NOW)).toEqual([])
|
||||
expect(await capsule.list(NOW)).toEqual([])
|
||||
expect(await statusNotes(host)).toEqual([])
|
||||
// No note took over, so the cut turn keeps its one notice.
|
||||
// Only the quit's own row about the cut: no note took over, so it stays the turn's one notice.
|
||||
expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
})
|
||||
|
||||
|
||||
+3
-2
@@ -17,6 +17,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
interruptedRestart,
|
||||
QUIT_CUT_NOTICE,
|
||||
statusNotes
|
||||
} from './structured-agent-session-restart-interruption-test-harness'
|
||||
import { CALLER, envelope } from './structured-agent-session-host-test-harness'
|
||||
@@ -385,8 +386,8 @@ it("refuses an automatic continuation when the user's message was accepted first
|
||||
expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }])
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START)
|
||||
expect(sentTexts(dispatch)).toEqual(['A new request'])
|
||||
// Nothing the user did failed: no row reached the reader, and nothing is kept.
|
||||
expect(await statusNotes(host)).toEqual([])
|
||||
// Nothing the user did failed: no row but the quit's own about the cut, and nothing is kept.
|
||||
expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ import {
|
||||
import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring'
|
||||
import {
|
||||
interruptedRestart,
|
||||
QUIT_CUT_NOTICE,
|
||||
startAgent,
|
||||
statusNotes,
|
||||
supersededRefusal,
|
||||
@@ -357,7 +358,8 @@ it("refuses a continuation quietly when the user's own message was accepted firs
|
||||
failed: []
|
||||
})
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(await statusNotes(host)).toEqual([])
|
||||
// The quit's own row about the cut, and nothing about the refused continuation.
|
||||
expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([])
|
||||
expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([])
|
||||
})
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// What a host's restart-resume surface offers: teardown's offer recording, the offer list and its
|
||||
// actions, and Continue on a cut reply.
|
||||
|
||||
import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker'
|
||||
import type { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation'
|
||||
import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation'
|
||||
import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner'
|
||||
import type {
|
||||
StructuredAgentSessionRestartAudience,
|
||||
StructuredAgentSessionResumeCandidate,
|
||||
StructuredAgentSessionResumeFailure
|
||||
} from './structured-agent-session-restart-resume-set'
|
||||
|
||||
export type StructuredAgentSessionRestartResume = {
|
||||
/** Teardown: begin, then per session a snapshot right before its child stops and a confirmation
|
||||
* once the stop is proven, then one write of the confirmed offers. */
|
||||
beginTeardown: (trigger: AgentSessionResumeTrigger) => void
|
||||
captureBeforeStop: (sessionId: string) => void
|
||||
confirmStopped: (sessionId: string) => void
|
||||
recordMarkers: () => Promise<void>
|
||||
list: (
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<StructuredAgentSessionResumeCandidate[]>
|
||||
/** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */
|
||||
listFailures: (
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<StructuredAgentSessionResumeFailure[]>
|
||||
/** Unnamed, continues every offer the audience sees; named, only those of them. */
|
||||
continueAfterRestart: (
|
||||
sessionIds: readonly string[] | undefined,
|
||||
owner: string,
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<{
|
||||
resumed: StructuredAgentSessionResumeOutcome[]
|
||||
continued: StructuredAgentSessionContinuationOutcome[]
|
||||
sessions?: StructuredAgentSessionResumeCandidate[]
|
||||
failed?: StructuredAgentSessionResumeFailure[]
|
||||
}>
|
||||
/** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a
|
||||
* newer Orca's stay). An audience limits either to the agents it sees. */
|
||||
dismiss: (
|
||||
sessionIds?: readonly string[],
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<number>
|
||||
/** The chat's agent proved a start: its offer ends unless the start is a resume's own. */
|
||||
onAgentStarted: (sessionId: string) => void
|
||||
/** Continue on a reply an Orca stop cut off, offer or not; the send itself retires any offer. */
|
||||
continueInterrupted: ReturnType<typeof createInterruptedContinuation>
|
||||
}
|
||||
+10
-45
@@ -7,10 +7,7 @@ import {
|
||||
AgentSessionRefusalError,
|
||||
agentSessionRefusalFromReference
|
||||
} from '../../../shared/agent-session-wire-refusals'
|
||||
import type {
|
||||
AgentSessionResumeMarker,
|
||||
AgentSessionResumeTrigger
|
||||
} from '../../../shared/agent-session-resume-marker'
|
||||
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
|
||||
import {
|
||||
createNewerOrcaChats,
|
||||
createStructuredAgentSessionRestartCandidateReaders
|
||||
@@ -24,21 +21,21 @@ import { createStructuredAgentSessionRestartOfferRecords } from './structured-ag
|
||||
import {
|
||||
restartRowsFor,
|
||||
type StructuredAgentSessionRestartAudience,
|
||||
type StructuredAgentSessionResumeCandidate,
|
||||
type StructuredAgentSessionResumeFailure
|
||||
type StructuredAgentSessionResumeCandidate
|
||||
} from './structured-agent-session-restart-resume-set'
|
||||
import {
|
||||
resumeStructuredAgentSessionsFromRestart,
|
||||
StructuredAgentSessionResumeAdmission,
|
||||
type StructuredAgentSessionResumeOutcome
|
||||
StructuredAgentSessionResumeAdmission
|
||||
} from './structured-agent-session-restart-resume-runner'
|
||||
import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-contract'
|
||||
import {
|
||||
restartContinuationDeps,
|
||||
continuationDeps,
|
||||
startStructuredAgentSessionContinuation,
|
||||
type StructuredAgentSessionContinuationHost,
|
||||
type StructuredAgentSessionContinuationOutcome
|
||||
} from './structured-agent-session-restart-continuation'
|
||||
import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope'
|
||||
import { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation'
|
||||
import {
|
||||
createStructuredAgentSessionRestartOfferWithdrawal,
|
||||
type StructuredAgentSessionRestartOfferSession
|
||||
@@ -54,40 +51,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session-
|
||||
|
||||
type LiveSession = StructuredAgentSessionRestartOfferSession
|
||||
|
||||
export type StructuredAgentSessionRestartResume = {
|
||||
/** Teardown: begin, then per session a snapshot right before its child stops and a confirmation
|
||||
* once the stop is proven, then one write of the confirmed offers. */
|
||||
beginTeardown: (trigger: AgentSessionResumeTrigger) => void
|
||||
captureBeforeStop: (sessionId: string) => void
|
||||
confirmStopped: (sessionId: string) => void
|
||||
recordMarkers: () => Promise<void>
|
||||
list: (
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<StructuredAgentSessionResumeCandidate[]>
|
||||
/** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */
|
||||
listFailures: (
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<StructuredAgentSessionResumeFailure[]>
|
||||
/** Unnamed, continues every offer the audience sees; named, only those of them. */
|
||||
continueAfterRestart: (
|
||||
sessionIds: readonly string[] | undefined,
|
||||
owner: string,
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<{
|
||||
resumed: StructuredAgentSessionResumeOutcome[]
|
||||
continued: StructuredAgentSessionContinuationOutcome[]
|
||||
sessions?: StructuredAgentSessionResumeCandidate[]
|
||||
failed?: StructuredAgentSessionResumeFailure[]
|
||||
}>
|
||||
/** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a
|
||||
* newer Orca's stay). An audience limits either to the agents it sees. */
|
||||
dismiss: (
|
||||
sessionIds?: readonly string[],
|
||||
audience?: StructuredAgentSessionRestartAudience
|
||||
) => Promise<number>
|
||||
/** The chat's agent proved a start: its offer ends unless the start is a resume's own. */
|
||||
onAgentStarted: (sessionId: string) => void
|
||||
}
|
||||
export type { StructuredAgentSessionRestartResume }
|
||||
|
||||
export function createStructuredAgentSessionRestartResume(
|
||||
deps: Pick<
|
||||
@@ -261,7 +225,7 @@ export function createStructuredAgentSessionRestartResume(
|
||||
// never starts more agents at once than the runner allows; the provider's answer comes after.
|
||||
const action = await run(sessionIds, owner, audience, async (marker, continuationId) => {
|
||||
const started = await startStructuredAgentSessionContinuation(
|
||||
restartContinuationDeps(continuationHost, marker),
|
||||
continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)),
|
||||
marker.sessionId,
|
||||
marker,
|
||||
continuationId
|
||||
@@ -327,6 +291,7 @@ export function createStructuredAgentSessionRestartResume(
|
||||
audience
|
||||
),
|
||||
continueAfterRestart,
|
||||
onAgentStarted: withdrawal.onAgentStarted
|
||||
onAgentStarted: withdrawal.onAgentStarted,
|
||||
continueInterrupted: createInterruptedContinuation(continuationHost, readMarkers)
|
||||
}
|
||||
}
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
// A quit or update that cuts a reply writes one row saying so, in the words every client already
|
||||
// prints, with the cause beside them. A turn that finished, a person's Stop and an idle eviction
|
||||
// write none.
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalTurnLifecycle
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { readAgentSessionOrcaStop } from '../../../shared/agent-session-orca-stop'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut'
|
||||
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
import {
|
||||
agentSessionRuntimeIncarnation,
|
||||
beginAgentSessionRuntimeIncarnationForTest
|
||||
} from '../../runtime/agent-session-runtime-attribution'
|
||||
import { recordAgentSessionRuntimeEnd } from '../../runtime/agent-session-runtime-end-record'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import {
|
||||
adapter,
|
||||
attach,
|
||||
hostTestState,
|
||||
replaceHostTestState
|
||||
} from './structured-agent-session-host-test-harness'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 }
|
||||
const LEGACY_TEXT =
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
|
||||
let host: StructuredAgentSessionHost
|
||||
/** How the provider ends its open turn as it is stopped. */
|
||||
let providerEnd: Pick<AgentJournalTurnLifecycle, 'state' | 'outcome' | 'completedAt'>
|
||||
|
||||
beforeEach(() => {
|
||||
const state = hostTestState()
|
||||
providerEnd = { state: 'interrupted', completedAt: HOST_TEST_NOW }
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store: state.store,
|
||||
adapter: {
|
||||
...adapter(),
|
||||
closeSession: async () => {
|
||||
const events = state.acquire.mock.calls.at(-1)?.[0]?.events
|
||||
if (!events) {
|
||||
throw new Error('missing provider event sink')
|
||||
}
|
||||
events.appendItem(
|
||||
CUT_TURN,
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'cut-turn',
|
||||
startedAt: 1_000,
|
||||
requestedAt: 1_000,
|
||||
...providerEnd
|
||||
},
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
return true
|
||||
}
|
||||
},
|
||||
journalDatabase: openTestJournalHostDatabase(state.root),
|
||||
recoveryCapsule: new AgentSessionRecoveryCapsule(state.root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-a',
|
||||
now: () => HOST_TEST_NOW
|
||||
})
|
||||
replaceHostTestState({ store: state.store, host })
|
||||
})
|
||||
|
||||
async function runningTurn(): Promise<void> {
|
||||
await attach()
|
||||
const events = hostTestState().acquire.mock.calls[0]?.[0].events
|
||||
if (!events) {
|
||||
throw new Error('missing provider event sink')
|
||||
}
|
||||
events.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 },
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
events.appendItem(
|
||||
CUT_TURN,
|
||||
{ kind: 'turn', turnId: 'cut-turn', state: 'running', startedAt: 1_000, requestedAt: 1_000 },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
}
|
||||
|
||||
/** The Orca that ran the turn dies with no teardown; the next one starts over the same files and
|
||||
* finds the turn's owner gone. */
|
||||
async function restartAfterDeath(): Promise<void> {
|
||||
const { root } = hostTestState()
|
||||
beginAgentSessionRuntimeIncarnationForTest()
|
||||
const store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
recoveryCapsule: new AgentSessionRecoveryCapsule(root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-next',
|
||||
probeOwner: async () => ({ outcome: 'pid-absent' }),
|
||||
now: () => HOST_TEST_NOW + 60_000
|
||||
})
|
||||
replaceHostTestState({ store, host })
|
||||
await host.reconcileRestartLeases()
|
||||
}
|
||||
|
||||
async function reread() {
|
||||
await host.restoreReadableSessions([SESSION])
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId
|
||||
const stopRows = items.filter(
|
||||
(item) =>
|
||||
item.body.kind === 'status' && readAgentSessionOrcaStop(item.body.orcaStop) !== undefined
|
||||
)
|
||||
const readerErrors = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap(
|
||||
(item) => (item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [])
|
||||
)
|
||||
return { items, turnItemId, stopRows, readerErrors }
|
||||
}
|
||||
|
||||
describe('the row a quit writes for the reply it cut', () => {
|
||||
it.each(['update', 'quit'] as const)('names a %s on the cut turn, once', async (trigger) => {
|
||||
await runningTurn()
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger })
|
||||
|
||||
const { items, turnItemId, stopRows, readerErrors } = await reread()
|
||||
expect(stopRows).toHaveLength(1)
|
||||
const [row] = stopRows
|
||||
expect(row?.body).toMatchObject({
|
||||
kind: 'status',
|
||||
text: LEGACY_TEXT,
|
||||
tone: 'error',
|
||||
failure: { kind: 'providerExited' },
|
||||
presentation: 'orca-stop',
|
||||
orcaStop: { cause: trigger }
|
||||
})
|
||||
expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId })
|
||||
// A client that reads no cause still prints exactly one row for the cut, in today's words.
|
||||
expect(readerErrors).toEqual([LEGACY_TEXT])
|
||||
expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger })
|
||||
})
|
||||
|
||||
it('writes nothing for a turn the provider finished during the stop', async () => {
|
||||
providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 }
|
||||
await runningTurn()
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger: 'update' })
|
||||
|
||||
const { stopRows, readerErrors } = await reread()
|
||||
expect(stopRows).toEqual([])
|
||||
expect(readerErrors).toEqual([])
|
||||
})
|
||||
|
||||
it("writes nothing for a turn a person's Stop already ended", async () => {
|
||||
providerEnd = { state: 'interrupted', outcome: 'cancellation', completedAt: 1_500 }
|
||||
await runningTurn()
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger: 'update' })
|
||||
|
||||
const { stopRows } = await reread()
|
||||
expect(stopRows).toEqual([])
|
||||
})
|
||||
|
||||
it('never calls an idle eviction a restart', async () => {
|
||||
await runningTurn()
|
||||
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
const { items, stopRows, readerErrors } = await reread()
|
||||
expect(stopRows).toEqual([])
|
||||
// The cut keeps today's derived notice, and offers no Continue.
|
||||
expect(readerErrors).toEqual([LEGACY_TEXT])
|
||||
expect(latestNativeChatOrcaStopCut(items, [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the row a restart writes for a reply its Orca died in', () => {
|
||||
it('says Orca stopped unexpectedly when the Orca before began no quit', async () => {
|
||||
await runningTurn()
|
||||
|
||||
await restartAfterDeath()
|
||||
|
||||
const { items, turnItemId, stopRows, readerErrors } = await reread()
|
||||
expect(stopRows).toHaveLength(1)
|
||||
expect(stopRows[0]?.body).toMatchObject({
|
||||
text: LEGACY_TEXT,
|
||||
orcaStop: { cause: 'crash' }
|
||||
})
|
||||
expect(readerErrors).toEqual([LEGACY_TEXT])
|
||||
expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' })
|
||||
})
|
||||
|
||||
it('names the update an unfinished quit began, never a crash', async () => {
|
||||
await runningTurn()
|
||||
// The quit recorded its end, then died before it stopped the agent.
|
||||
recordAgentSessionRuntimeEnd('update', HOST_TEST_NOW)
|
||||
|
||||
await restartAfterDeath()
|
||||
|
||||
const { stopRows } = await reread()
|
||||
expect(stopRows.map((row) => row.body)).toMatchObject([{ orcaStop: { cause: 'update' } }])
|
||||
})
|
||||
|
||||
it('names no cause when the Orca before left no record of itself', async () => {
|
||||
await runningTurn()
|
||||
await rm(join(hostTestState().root, 'agent-session-runtimes'), { recursive: true, force: true })
|
||||
|
||||
await restartAfterDeath()
|
||||
|
||||
const { stopRows, readerErrors } = await reread()
|
||||
expect(stopRows).toEqual([])
|
||||
// The cut keeps today's words.
|
||||
expect(readerErrors).toEqual([LEGACY_TEXT])
|
||||
})
|
||||
})
|
||||
|
||||
describe('an agent this Orca runs', () => {
|
||||
it('is recorded as held by this runtime', async () => {
|
||||
await runningTurn()
|
||||
expect(hostTestState().store.getRecord(SESSION)?.lease.ownerProcess?.runtime).toBe(
|
||||
agentSessionRuntimeIncarnation()
|
||||
)
|
||||
})
|
||||
|
||||
it('names no Orca cause when the agent dies while Orca runs', async () => {
|
||||
await runningTurn()
|
||||
const child = host.collaboratorsForTests().sessions.get(SESSION)!.child!
|
||||
|
||||
await host.handleAdapterEvent({
|
||||
type: 'ended',
|
||||
sessionId: SESSION,
|
||||
reason: 'killed',
|
||||
cause: 'unexpected-exit',
|
||||
fence: child.fence,
|
||||
acquisitionGeneration: child.generation!
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({
|
||||
kind: 'exit-observed'
|
||||
})
|
||||
)
|
||||
expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).not.toHaveProperty(
|
||||
'runtimeEnd'
|
||||
)
|
||||
const { stopRows } = await reread()
|
||||
expect(stopRows).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,9 @@
|
||||
* The two things a supervisor reads off a launch: what the arguments mean, and what an exit
|
||||
* code means. Both are part of the ops contract in docs/reference/orcad-operations.md.
|
||||
*/
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORCAD_EXIT_CONFIGURATION,
|
||||
@@ -10,6 +13,10 @@ import {
|
||||
resolveOrcadExitCode
|
||||
} from './orcad-entry'
|
||||
import { startOrcadWithLifecycle } from './orcad-lifecycle'
|
||||
import {
|
||||
beginAgentSessionRuntimeRecord,
|
||||
readAgentSessionRuntimeEnds
|
||||
} from '../runtime/agent-session-runtime-end-record'
|
||||
import { OrcadBindAddressError } from './orcad-bind-address'
|
||||
import { OrcadInstanceLockError } from './orcad-instance-lock'
|
||||
import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access'
|
||||
@@ -124,6 +131,30 @@ describe('orcad lifecycle cleanup', () => {
|
||||
expect(cleanupHost).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("records the runtime's end before its stop waits on anything", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orcad-runtime-end-'))
|
||||
try {
|
||||
beginAgentSessionRuntimeRecord(directory, 'orcad-runtime', 1)
|
||||
let endSeenByCleanup: string | undefined
|
||||
const handle = await startOrcadWithLifecycle(
|
||||
async (registerCleanup) => {
|
||||
registerCleanup(async () => {
|
||||
endSeenByCleanup = readAgentSessionRuntimeEnds(directory)?.get('orcad-runtime')
|
||||
})
|
||||
return {}
|
||||
},
|
||||
vi.fn(async () => {})
|
||||
)
|
||||
|
||||
await handle.stop()
|
||||
|
||||
// A chat whose agent dies with this stop reads as a restart, not a crash.
|
||||
expect(endSeenByCleanup).toBe('quit')
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => {
|
||||
const failure = new Error('profile writer still running')
|
||||
const cleanupHost = vi.fn(async () => {})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request'
|
||||
import { acquireOrcadInstanceLock } from './orcad-instance-lock'
|
||||
import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime'
|
||||
import { resolveOrcadExitCode } from './orcad-exit-code'
|
||||
import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record'
|
||||
import { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines'
|
||||
import {
|
||||
acquireProfileStateRuntimeAdmission,
|
||||
@@ -78,6 +79,8 @@ export async function startOrcadWithLifecycle<T extends object>(
|
||||
// Runtime resources stop in reverse registration order before any host resource.
|
||||
const runtime = new OrcadRuntimeLifetime()
|
||||
const cleanup = createIdempotentOrcadCleanup(async () => {
|
||||
// First, before any wait: chats whose agent dies with this stop read as a restart, not a crash.
|
||||
recordAgentSessionRuntimeEnd('quit')
|
||||
let runtimeCleanupSucceeded = false
|
||||
try {
|
||||
await runtime.stop()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Whether a chat's agent died with the Orca runtime that held it, and how that runtime ended. Only
|
||||
// an owner tagged with an earlier runtime of this build can be attributed; anything unknown keeps
|
||||
// the death unattributed, so the chat keeps its generic words.
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionLease } from '../../shared/agent-session-record'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import {
|
||||
openTestAgentSessionRecordStore,
|
||||
seedTestAgentSessionRecordStore
|
||||
} from './agent-session-record-store-test-harness'
|
||||
import {
|
||||
agentSessionRuntimeIncarnation,
|
||||
beginAgentSessionRuntimeIncarnationForTest
|
||||
} from './agent-session-runtime-attribution'
|
||||
import {
|
||||
beginAgentSessionRuntimeRecord,
|
||||
recordAgentSessionRuntimeEnd
|
||||
} from './agent-session-runtime-end-record'
|
||||
|
||||
const SESSION = 'session-alpha-1'
|
||||
const OWNER = {
|
||||
hostId: 'local',
|
||||
pid: 4242,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken: 'spawn-owner'
|
||||
}
|
||||
|
||||
let directory: string
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-runtime-attribution-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A lease the runtime before this one held when it went away, as that runtime left it. */
|
||||
async function seedHeldBy(runtime: string | null, lease: Partial<AgentSessionLease> = {}) {
|
||||
await seedTestAgentSessionRecordStore(directory, {
|
||||
records: [
|
||||
agentSessionRecordFixture(
|
||||
agentSessionLeaseFixture({
|
||||
ownerProcess: runtime ? { ...OWNER, runtime } : OWNER,
|
||||
unreconciled: true,
|
||||
...lease
|
||||
})
|
||||
)
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** What that runtime recorded about itself: its start, and its end when it ended gracefully. */
|
||||
function recorded(runtime: string, end?: 'quit' | 'update') {
|
||||
beginAgentSessionRuntimeRecord(directory, runtime, 1_000)
|
||||
if (end) {
|
||||
recordAgentSessionRuntimeEnd(end, 50_000)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartEvidence() {
|
||||
const store = await openTestAgentSessionRecordStore(directory)
|
||||
await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 })
|
||||
return store.getRecord(SESSION)?.lease.deathEvidence ?? null
|
||||
}
|
||||
|
||||
describe("an agent that died with Orca's previous runtime", () => {
|
||||
it('died in a crash when that runtime started and never ended', async () => {
|
||||
await seedHeldBy('runtime-a')
|
||||
recorded('runtime-a')
|
||||
expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' })
|
||||
})
|
||||
|
||||
it.each(['update', 'quit'] as const)(
|
||||
'died with the %s that runtime began, when the quit did not finish',
|
||||
async (trigger) => {
|
||||
await seedHeldBy('runtime-a')
|
||||
recorded('runtime-a', trigger)
|
||||
expect(await restartEvidence()).toMatchObject({ runtimeEnd: trigger })
|
||||
}
|
||||
)
|
||||
|
||||
it("is never told by another runtime's end", async () => {
|
||||
await seedHeldBy('runtime-b')
|
||||
recorded('runtime-b')
|
||||
recorded('runtime-a', 'update')
|
||||
expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' })
|
||||
})
|
||||
|
||||
it('names no cause when that runtime left no record', async () => {
|
||||
await seedHeldBy('runtime-a')
|
||||
recorded('runtime-b', 'quit')
|
||||
expect(await restartEvidence()).not.toHaveProperty('runtimeEnd')
|
||||
})
|
||||
|
||||
it("names no cause when that runtime's record cannot be read", async () => {
|
||||
await seedHeldBy('runtime-a')
|
||||
await mkdir(join(directory, 'agent-session-runtimes'), { recursive: true })
|
||||
await writeFile(join(directory, 'agent-session-runtimes', 'runtime-a.json'), '{not json')
|
||||
expect(await restartEvidence()).not.toHaveProperty('runtimeEnd')
|
||||
})
|
||||
|
||||
it('names no cause for an owner an older build recorded', async () => {
|
||||
await seedHeldBy(null)
|
||||
expect(await restartEvidence()).not.toHaveProperty('runtimeEnd')
|
||||
})
|
||||
|
||||
it("names no cause for a terminal's agent", async () => {
|
||||
await seedHeldBy('runtime-a', { claimStatus: 'conflicted' })
|
||||
expect(await restartEvidence()).not.toHaveProperty('runtimeEnd')
|
||||
})
|
||||
|
||||
it('is told once a survivor that outlived the runtime is stopped and proven gone', async () => {
|
||||
await seedHeldBy('runtime-a')
|
||||
recorded('runtime-a')
|
||||
const store = await openTestAgentSessionRecordStore(directory)
|
||||
// The owner was still alive at restart, so it waits in recovery for its stop.
|
||||
await store.reconcileOnRestart({
|
||||
probe: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }),
|
||||
now: 90_000
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering')
|
||||
|
||||
await store.evictProvenDeadOwner({
|
||||
sessionId: SESSION,
|
||||
expectedFence: 7,
|
||||
probe: { outcome: 'pid-absent' },
|
||||
now: 95_000
|
||||
})
|
||||
|
||||
expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ runtimeEnd: 'crash' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('an agent this runtime ran', () => {
|
||||
it("becomes an earlier runtime's after a relaunch", async () => {
|
||||
await seedHeldBy(agentSessionRuntimeIncarnation())
|
||||
// This runtime's store opened, so it recorded its start; it then died without a quit.
|
||||
await openTestAgentSessionRecordStore(directory)
|
||||
beginAgentSessionRuntimeIncarnationForTest()
|
||||
expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// Which Orca runtime held each chat's agent, and, when that agent is later proven dead, whether it
|
||||
// died with that runtime and how the runtime ended. Applied to every store transaction, so a death
|
||||
// any transition proves (a restart's adjudication, recovery's stop of a survivor) is told the same
|
||||
// way. When anything is unknown (an owner an older build recorded, a terminal's claim, a runtime
|
||||
// with no readable record) the death names no cause and the chat keeps its generic words.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
/** This Orca runtime: one per process, so a relaunch is a new one. */
|
||||
let incarnation = randomUUID()
|
||||
|
||||
export function agentSessionRuntimeIncarnation(): string {
|
||||
return incarnation
|
||||
}
|
||||
|
||||
/** For tests that relaunch Orca inside one process. */
|
||||
export function beginAgentSessionRuntimeIncarnationForTest(): void {
|
||||
incarnation = randomUUID()
|
||||
}
|
||||
|
||||
function attributed(
|
||||
before: AgentSessionRecord | undefined,
|
||||
after: AgentSessionRecord,
|
||||
runtimeEnds: AgentSessionStoreState['runtimeEnds']
|
||||
): AgentSessionRecord {
|
||||
const { ownerProcess, deathEvidence } = after.lease
|
||||
// An owner this transaction recorded is this runtime's.
|
||||
if (ownerProcess && !ownerProcess.runtime && ownerProcess !== before?.lease.ownerProcess) {
|
||||
return {
|
||||
...after,
|
||||
lease: { ...after.lease, ownerProcess: { ...ownerProcess, runtime: incarnation } }
|
||||
}
|
||||
}
|
||||
const heldBy = before?.lease.ownerProcess?.runtime
|
||||
if (
|
||||
!deathEvidence ||
|
||||
deathEvidence === before?.lease.deathEvidence ||
|
||||
deathEvidence.runtimeEnd !== undefined ||
|
||||
!heldBy ||
|
||||
heldBy === incarnation ||
|
||||
// A terminal's agent, which an older build recorded: its transport was never Orca's.
|
||||
before?.lease.claimStatus === 'conflicted' ||
|
||||
!runtimeEnds
|
||||
) {
|
||||
return after
|
||||
}
|
||||
// Unrecorded, pruned or unreadable: no cause. A crash is only a runtime that started and never ended.
|
||||
const runtimeEnd = runtimeEnds.get(heldBy)
|
||||
if (!runtimeEnd) {
|
||||
return after
|
||||
}
|
||||
return { ...after, lease: { ...after.lease, deathEvidence: { ...deathEvidence, runtimeEnd } } }
|
||||
}
|
||||
|
||||
/** Stamps what this transaction learned about runtimes onto the records it changed. */
|
||||
export function attributeAgentSessionRuntime(
|
||||
published: AgentSessionStoreState,
|
||||
draft: AgentSessionStoreState
|
||||
): void {
|
||||
for (const [sessionId, record] of draft.records) {
|
||||
const before = published.records.get(sessionId)
|
||||
if (record !== before) {
|
||||
const next = attributed(before, record, draft.runtimeEnds)
|
||||
if (next !== record) {
|
||||
draft.records.set(sessionId, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// What a runtime records about itself: a start when its store opens, and its end on a graceful exit.
|
||||
// A crash is only a runtime known to have started and never ended; anything else names no cause.
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } 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 * as DurableFileWrite from '../durable-file-write'
|
||||
|
||||
const writes = vi.hoisted(() => ({ fail: false }))
|
||||
vi.mock('../durable-file-write', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DurableFileWrite>()
|
||||
return {
|
||||
...actual,
|
||||
writeFileDurableSync: (...args: Parameters<typeof actual.writeFileDurableSync>) => {
|
||||
if (writes.fail) {
|
||||
throw new Error('ENOSPC: no space left on device')
|
||||
}
|
||||
actual.writeFileDurableSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
beginAgentSessionRuntimeRecord,
|
||||
readAgentSessionRuntimeEnds,
|
||||
recordAgentSessionRuntimeEnd,
|
||||
recordAgentSessionRuntimeEndOnExit
|
||||
} from './agent-session-runtime-end-record'
|
||||
import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown'
|
||||
|
||||
const RUNTIME_A = '0000000a-0000-4000-8000-000000000000'
|
||||
const RUNTIME_B = '0000000b-0000-4000-8000-000000000000'
|
||||
|
||||
let directory: string
|
||||
|
||||
beforeEach(async () => {
|
||||
writes.fail = false
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function runtimesDirectory(): string {
|
||||
return join(directory, 'agent-session-runtimes')
|
||||
}
|
||||
|
||||
describe('what a runtime records about itself', () => {
|
||||
it('reads as a crash once it started and never ended', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash')
|
||||
})
|
||||
|
||||
it.each(['quit', 'update'] as const)('reads as the %s it recorded as it ended', (trigger) => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
recordAgentSessionRuntimeEnd(trigger, 2)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe(trigger)
|
||||
})
|
||||
|
||||
it('keeps the first end it recorded', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
recordAgentSessionRuntimeEnd('update', 2)
|
||||
recordAgentSessionRuntimeEnd('quit', 3)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update')
|
||||
})
|
||||
|
||||
it('names no cause for a runtime it has no record of', () => {
|
||||
expect(readAgentSessionRuntimeEnds(directory)).toEqual(new Map())
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_B)).toBe(false)
|
||||
})
|
||||
|
||||
it('names no cause for a record it cannot read, and the next start writes its own', async () => {
|
||||
await mkdir(runtimesDirectory(), { recursive: true })
|
||||
await writeFile(join(runtimesDirectory(), `${RUNTIME_A}.json`), '{not json')
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false)
|
||||
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 1)
|
||||
recordAgentSessionRuntimeEnd('quit', 2)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_B)).toBe('quit')
|
||||
})
|
||||
|
||||
it('names no cause when the records cannot be listed', async () => {
|
||||
await writeFile(runtimesDirectory(), 'not a directory')
|
||||
expect(readAgentSessionRuntimeEnds(directory)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps only the newest records, so a pruned runtime names no cause', async () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
// The oldest record by far.
|
||||
await utimes(join(runtimesDirectory(), `${RUNTIME_A}.json`), 1, 1)
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
beginAgentSessionRuntimeRecord(
|
||||
directory,
|
||||
`00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
index
|
||||
)
|
||||
}
|
||||
const ends = readAgentSessionRuntimeEnds(directory)
|
||||
expect(ends?.size).toBe(16)
|
||||
expect(ends?.has(RUNTIME_A)).toBe(false)
|
||||
})
|
||||
|
||||
it("never loses another runtime's record to its own writes", () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 2)
|
||||
recordAgentSessionRuntimeEnd('quit', 3)
|
||||
expect(readAgentSessionRuntimeEnds(directory)).toEqual(
|
||||
new Map([
|
||||
[RUNTIME_A, 'crash'],
|
||||
[RUNTIME_B, 'quit']
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('never fails the exit when it cannot be written', async () => {
|
||||
await writeFile(runtimesDirectory(), 'not a directory')
|
||||
expect(() => beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)).not.toThrow()
|
||||
expect(() => recordAgentSessionRuntimeEnd('quit', 2)).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps its end when the same runtime records its start again during the quit', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
recordAgentSessionRuntimeEnd('update', 2)
|
||||
// A host reinstalled by a request that landed during the quit opens its store again.
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 3)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update')
|
||||
})
|
||||
|
||||
it('names no cause, rather than a crash, when its end could not be written', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
writes.fail = true
|
||||
recordAgentSessionRuntimeEnd('quit', 2)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a clean process exit nothing else recorded as a quit, and any other exit as nothing', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
expect(process.listeners('exit')).toContain(recordAgentSessionRuntimeEndOnExit)
|
||||
recordAgentSessionRuntimeEndOnExit(1)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash')
|
||||
recordAgentSessionRuntimeEndOnExit(0)
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('quit')
|
||||
})
|
||||
|
||||
it('leaves no temp files behind', async () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
recordAgentSessionRuntimeEnd('quit', 2)
|
||||
expect(await readdir(runtimesDirectory())).toEqual([`${RUNTIME_A}.json`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a quit', () => {
|
||||
it('records its end before it waits on anything, so a quit that never finishes is still a quit', () => {
|
||||
beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)
|
||||
const installed = {
|
||||
host: { stopDelivery: vi.fn(), flushAllStreamedEvents: vi.fn() },
|
||||
adapter: { closeAll: vi.fn() },
|
||||
journalDatabase: { stateDirectory: directory, close: vi.fn() },
|
||||
// A recovery that never drains: the quit's deadline ends the process here.
|
||||
waitForRecovery: () => new Promise<void>(() => {})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown reads only the members stubbed above before it parks on the recovery wait.
|
||||
void tearDownRuntime(installed as unknown as InstalledRuntime, 'update')
|
||||
|
||||
expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update')
|
||||
expect(installed.host.flushAllStreamedEvents).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(runtimesDirectory(), `${RUNTIME_A}.json`))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
// What each Orca runtime that ran chats here says about itself: that it started, and, when it ends
|
||||
// gracefully, how (a quit or an update). A later start reads them to tell an owner that died with a
|
||||
// quit from one that died in a crash. A crash is only ever concluded from a runtime known to have
|
||||
// started and never ended; a runtime with no readable record names no cause.
|
||||
//
|
||||
// One file per runtime, written only by that runtime, so two processes sharing the directory never
|
||||
// lose each other's word to a read-modify-write. Pruned to the newest few, and losing a record only
|
||||
// costs the distinction.
|
||||
|
||||
import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write'
|
||||
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop'
|
||||
import {
|
||||
AGENT_SESSION_RESUME_TRIGGERS,
|
||||
type AgentSessionResumeTrigger
|
||||
} from '../../shared/agent-session-resume-marker'
|
||||
|
||||
const RUNTIMES_DIRECTORY = 'agent-session-runtimes'
|
||||
/** Enough runtimes that an owner a few restarts old still finds its runtime's record. */
|
||||
const MAX_RUNTIME_RECORDS = 16
|
||||
const MAX_RUNTIME_RECORD_BYTES = 4 * 1024
|
||||
/** A temp file older than this belongs to a writer that died between write and rename. */
|
||||
const STALE_TEMP_FILE_MS = 60_000
|
||||
const RECORD_FILE = /^([A-Za-z0-9-]{1,64})\.json$/
|
||||
|
||||
type RuntimeRecord = {
|
||||
runtime: string
|
||||
startedAt: number
|
||||
end?: { trigger: AgentSessionResumeTrigger; at: number }
|
||||
}
|
||||
|
||||
/** This process's runtime, once its store opened: the one record a graceful end rewrites. */
|
||||
let current: { path: string; record: RuntimeRecord } | null = null
|
||||
|
||||
function writeRecord(path: string, record: RuntimeRecord): void {
|
||||
writeFileDurableSync(durableWriteTempPath(path), path, JSON.stringify(record))
|
||||
}
|
||||
|
||||
function prune(directory: string, keep: string): void {
|
||||
const records: { name: string; modifiedAt: number }[] = []
|
||||
for (const name of readdirSync(directory)) {
|
||||
const modifiedAt = statSync(join(directory, name), { throwIfNoEntry: false })?.mtimeMs ?? 0
|
||||
if (name.endsWith('.tmp')) {
|
||||
if (Date.now() - modifiedAt > STALE_TEMP_FILE_MS) {
|
||||
rmSync(join(directory, name), { force: true })
|
||||
}
|
||||
} else if (name !== keep) {
|
||||
records.push({ name, modifiedAt })
|
||||
}
|
||||
}
|
||||
records.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
||||
for (const { name } of records.slice(MAX_RUNTIME_RECORDS - 1)) {
|
||||
rmSync(join(directory, name), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** A graceful exit no call site recorded, such as an `app.exit(0)` or `process.exit(0)`, is a quit:
|
||||
* Electron emits the process's 'exit' on every quit or exit once its loop runs, and Node on every
|
||||
* `process.exit`. A non-zero exit records nothing, so it stays a crash. */
|
||||
export function recordAgentSessionRuntimeEndOnExit(code: number): void {
|
||||
if (code === 0) {
|
||||
recordAgentSessionRuntimeEnd('quit')
|
||||
}
|
||||
}
|
||||
|
||||
let exitHookInstalled = false
|
||||
|
||||
/** Records that this runtime started. Never throws: a runtime with no record reads as unknown. A
|
||||
* second start of the same runtime (a host reinstalled during its quit) keeps the end it recorded. */
|
||||
export function beginAgentSessionRuntimeRecord(
|
||||
stateDirectory: string,
|
||||
runtime: string,
|
||||
now: number
|
||||
): void {
|
||||
const directory = join(stateDirectory, RUNTIMES_DIRECTORY)
|
||||
const name = `${runtime}.json`
|
||||
const path = join(directory, name)
|
||||
const ended = current?.path === path && current.record.end ? current.record : null
|
||||
current = { path, record: ended ?? { runtime, startedAt: now } }
|
||||
if (!exitHookInstalled) {
|
||||
exitHookInstalled = true
|
||||
process.once('exit', recordAgentSessionRuntimeEndOnExit)
|
||||
}
|
||||
try {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeRecord(current.path, current.record)
|
||||
prune(directory, name)
|
||||
} catch {
|
||||
// Unwritten, this runtime's owners name no cause when they die.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one "this Orca runtime is ending" entry point, for every graceful exit. Synchronous, so an
|
||||
* exit that cannot await still records it; the first call wins. Never throws.
|
||||
*/
|
||||
export function recordAgentSessionRuntimeEnd(
|
||||
trigger: AgentSessionResumeTrigger,
|
||||
now = Date.now()
|
||||
): void {
|
||||
if (!current || current.record.end) {
|
||||
return
|
||||
}
|
||||
current.record = { ...current.record, end: { trigger, at: now } }
|
||||
try {
|
||||
writeRecord(current.path, current.record)
|
||||
} catch {
|
||||
// The start alone would read as a crash; with no record its owners name no cause.
|
||||
try {
|
||||
rmSync(current.path, { force: true })
|
||||
} catch {
|
||||
// Nothing left to try: the start stays, and reads as a crash.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readTrigger(value: unknown): AgentSessionResumeTrigger | undefined {
|
||||
return AGENT_SESSION_RESUME_TRIGGERS.find((trigger) => trigger === value)
|
||||
}
|
||||
|
||||
/** How a recorded runtime ended: its quit or update, else a crash. Undefined when unreadable. */
|
||||
function readRecordedEnd(path: string, runtime: string): AgentSessionOrcaStopCause | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
readNodeFileSyncWithinLimit(path, MAX_RUNTIME_RECORD_BYTES).buffer.toString('utf8')
|
||||
)
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
!('runtime' in parsed) ||
|
||||
parsed.runtime !== runtime ||
|
||||
!('startedAt' in parsed) ||
|
||||
typeof parsed.startedAt !== 'number'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (!('end' in parsed) || parsed.end === undefined) {
|
||||
return 'crash'
|
||||
}
|
||||
const end = parsed.end
|
||||
return readTrigger(
|
||||
typeof end === 'object' && end !== null && 'trigger' in end ? end.trigger : undefined
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How each recorded runtime ended. A runtime missing from the map (never recorded, pruned, or
|
||||
* unreadable) names no cause. Null when the records cannot be listed at all.
|
||||
*/
|
||||
export function readAgentSessionRuntimeEnds(
|
||||
stateDirectory: string
|
||||
): ReadonlyMap<string, AgentSessionOrcaStopCause> | null {
|
||||
const directory = join(stateDirectory, RUNTIMES_DIRECTORY)
|
||||
let names: string[]
|
||||
try {
|
||||
names = readdirSync(directory)
|
||||
} catch (error) {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null
|
||||
}
|
||||
const ends = new Map<string, AgentSessionOrcaStopCause>()
|
||||
for (const name of names) {
|
||||
const runtime = RECORD_FILE.exec(name)?.[1]
|
||||
const end = runtime ? readRecordedEnd(join(directory, name), runtime) : undefined
|
||||
if (runtime && end) {
|
||||
ends.set(runtime, end)
|
||||
}
|
||||
}
|
||||
return ends
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop'
|
||||
import type { AgentSessionTabTable } from './agent-session-tab-table'
|
||||
|
||||
export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number }
|
||||
@@ -15,4 +16,7 @@ export type AgentSessionStoreState = {
|
||||
unreadableRecords: Map<string, { reason: string; raw: unknown }>
|
||||
/** Chat tab id → the conversation it shows; null until this store first records a tab. */
|
||||
sessionTabs: AgentSessionTabTable | null
|
||||
/** How each earlier runtime recorded here ended: its quit or update, or a crash when it started
|
||||
* and never ended; read once at load. A runtime it lacks, or null, attributes no death to Orca. */
|
||||
runtimeEnds?: ReadonlyMap<string, AgentSessionOrcaStopCause> | null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ import { journalOpenRefusalError } from '../native-chat/agent-session-journal/jo
|
||||
import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { writeAgentSessionStoreRows } from './agent-session-record-rows'
|
||||
import {
|
||||
agentSessionRuntimeIncarnation,
|
||||
attributeAgentSessionRuntime
|
||||
} from './agent-session-runtime-attribution'
|
||||
import {
|
||||
beginAgentSessionRuntimeRecord,
|
||||
readAgentSessionRuntimeEnds
|
||||
} from './agent-session-runtime-end-record'
|
||||
import {
|
||||
agentSessionStoreDraftRowWrites,
|
||||
draftAgentSessionStoreState,
|
||||
@@ -79,7 +87,16 @@ export class AgentSessionStoreTransactions {
|
||||
loaded: AgentSessionStoreState
|
||||
) {
|
||||
freezeRows(loaded, null)
|
||||
this.published = loaded
|
||||
this.published = {
|
||||
...loaded,
|
||||
runtimeEnds: readAgentSessionRuntimeEnds(journalDatabase.stateDirectory)
|
||||
}
|
||||
// Before any owner this runtime records: a crash is concluded only from a recorded start.
|
||||
beginAgentSessionRuntimeRecord(
|
||||
journalDatabase.stateDirectory,
|
||||
agentSessionRuntimeIncarnation(),
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
/** The committed state. A transaction in flight never shows here until its rows have landed. */
|
||||
@@ -148,6 +165,7 @@ export class AgentSessionStoreTransactions {
|
||||
const published = this.published
|
||||
const draft = draftAgentSessionStoreState(published)
|
||||
const result = apply(draft)
|
||||
attributeAgentSessionRuntime(published, draft)
|
||||
const writes = agentSessionStoreDraftRowWrites(published, draft)
|
||||
return {
|
||||
result,
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
requireStructuredHost,
|
||||
structuredCallerFor
|
||||
} from './structured-agent-session-gate'
|
||||
import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas'
|
||||
import {
|
||||
ContinueInterruptedParams,
|
||||
RestartResumableParams,
|
||||
RestartResumeParams
|
||||
} from './structured-agent-session-schemas'
|
||||
import { structuredAgentsReadBy } from './structured-agent-session-policy'
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [
|
||||
@@ -68,6 +72,18 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
// Continue on a reply an Orca stop cut off: the same continuation, bound to the cut turn
|
||||
// instead of an offer. Clients gate it on AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY.
|
||||
// It names one chat the client shows, as a send does, so no agent audience applies.
|
||||
name: 'agentSession.continueInterrupted',
|
||||
params: ContinueInterruptedParams,
|
||||
handler: async (params, ctx) => {
|
||||
await ensureStructuredHostInstalled(ctx)
|
||||
const host = requireStructuredHost(ctx)
|
||||
return host.restartResume.continueInterrupted(params.sessionId, params.turnItemId)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
// Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this
|
||||
// answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire
|
||||
|
||||
@@ -33,3 +33,4 @@ export {
|
||||
ThreadGoalParams,
|
||||
UnsubscribeParams
|
||||
} from '../../../../shared/rpc-contract/structured-agent-session-params'
|
||||
export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params'
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('capability gating', () => {
|
||||
}
|
||||
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
|
||||
// so an additive method is invisible to old clients and needs no protocol bump.
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(34)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker'
|
||||
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record'
|
||||
|
||||
export type InstalledRuntime = {
|
||||
host: StructuredAgentSessionHost
|
||||
@@ -35,6 +36,9 @@ export async function tearDownRuntime(
|
||||
installed: InstalledRuntime,
|
||||
trigger: AgentSessionResumeTrigger
|
||||
): Promise<void> {
|
||||
// First, before any wait: should this quit not finish, the next start must still know it was a
|
||||
// quit and not a crash.
|
||||
recordAgentSessionRuntimeEnd(trigger)
|
||||
// An exit settled while recovery drains wakes delivery, which would start a fresh child for
|
||||
// teardown to kill; queued messages wait for the next launch instead.
|
||||
installed.host.stopDelivery()
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { appMock, recordUpdaterLifecycleMock } = vi.hoisted(() => ({
|
||||
appMock: { exit: vi.fn() },
|
||||
recordUpdaterLifecycleMock: vi.fn()
|
||||
}))
|
||||
const { appMock, recordUpdaterLifecycleMock, recordAgentSessionRuntimeEndMock } = vi.hoisted(
|
||||
() => ({
|
||||
appMock: { exit: vi.fn() },
|
||||
recordUpdaterLifecycleMock: vi.fn(),
|
||||
recordAgentSessionRuntimeEndMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({ app: appMock }))
|
||||
vi.mock('./updater-lifecycle-diagnostics', () => ({
|
||||
recordUpdaterLifecycle: recordUpdaterLifecycleMock
|
||||
}))
|
||||
vi.mock('./runtime/agent-session-runtime-end-record', () => ({
|
||||
recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock
|
||||
}))
|
||||
|
||||
import {
|
||||
armUpdateInstallExitWatchdog,
|
||||
@@ -21,6 +27,7 @@ describe('update install exit watchdog', () => {
|
||||
vi.useFakeTimers()
|
||||
appMock.exit.mockClear()
|
||||
recordUpdaterLifecycleMock.mockClear()
|
||||
recordAgentSessionRuntimeEndMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -36,6 +43,8 @@ describe('update install exit watchdog', () => {
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0)
|
||||
// A wedged teardown may not have reached its own record of the update.
|
||||
expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('update')
|
||||
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
|
||||
'install_exit_watchdog_fired',
|
||||
{ timeoutMs: UPDATE_INSTALL_EXIT_TIMEOUT_MS },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from 'electron'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import { recordAgentSessionRuntimeEnd } from './runtime/agent-session-runtime-end-record'
|
||||
|
||||
// Why 20s: comfortably above a healthy shutdown (renderer buffer capture,
|
||||
// daemon final checkpoints, bounded 2s telemetry flush) but bounded — a wedged
|
||||
@@ -35,6 +36,7 @@ export function armUpdateInstallExitWatchdog(timeoutMs = UPDATE_INSTALL_EXIT_TIM
|
||||
)
|
||||
// Why exit(0): the quit is already committed and cleanup is wedged, not
|
||||
// failed — a clean code keeps ShipIt/launchd on the normal relaunch path.
|
||||
recordAgentSessionRuntimeEnd('update')
|
||||
app.exit(0)
|
||||
}, timeoutMs)
|
||||
// Why unref: the watchdog must never be the thing keeping the process alive.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { recordAgentSessionRuntimeEndMock } = vi.hoisted(() => ({
|
||||
recordAgentSessionRuntimeEndMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./createMainWindow-test-harness')).electronModuleMock()
|
||||
)
|
||||
@@ -13,6 +17,9 @@ vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harnes
|
||||
vi.mock('../browser/browser-manager', async () =>
|
||||
(await import('./createMainWindow-test-harness')).browserManagerMock()
|
||||
)
|
||||
vi.mock('../runtime/agent-session-runtime-end-record', () => ({
|
||||
recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock
|
||||
}))
|
||||
|
||||
import { createMainWindow } from './createMainWindow'
|
||||
import { ipcMain } from 'electron'
|
||||
@@ -140,6 +147,17 @@ describe('createMainWindow', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("records the Orca runtime's end, since Windows emits no will-quit for it", () => {
|
||||
setPlatform('win32')
|
||||
recordAgentSessionRuntimeEndMock.mockClear()
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
|
||||
createMainWindow(null)
|
||||
windowHandlers['session-end']?.({ reasons: ['logoff'] })
|
||||
|
||||
expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit')
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)(
|
||||
'does not mark session teardown state on %s',
|
||||
(platform) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
|
||||
import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
|
||||
import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change'
|
||||
import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record'
|
||||
|
||||
export { WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS }
|
||||
|
||||
@@ -155,6 +156,8 @@ export function createMainWindow(
|
||||
? event.reasons.filter((reason) => typeof reason === 'string').join(',')
|
||||
: ''
|
||||
})
|
||||
// Windows emits no will-quit for an OS shutdown or logoff, so its teardown never records it.
|
||||
recordAgentSessionRuntimeEnd('quit')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Until the launch decides whether it resumes this machine's chats itself, nothing else offers to
|
||||
// carry one on; from its decision on, the chats it resumes are named, with no moment in between.
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../store'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
import {
|
||||
_resetNativeChatRestartOffer,
|
||||
useNativeChatRestartOffer,
|
||||
useNativeChatRestartResuming
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
import {
|
||||
_resetNativeChatLaunchResumeDecision,
|
||||
useNativeChatLaunchResumePending
|
||||
} from './native-chat-launch-resume-decision'
|
||||
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: rpc,
|
||||
subscribeStructuredAgentSessionStatus: () => new Promise(() => {})
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: vi.fn() }))
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
let root: Root
|
||||
let container: HTMLDivElement
|
||||
const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({
|
||||
sessionId,
|
||||
workspaceId: 'workspace',
|
||||
agent: 'codex',
|
||||
trigger: 'quit',
|
||||
latestPrompt: `Prompt ${sessionId}`,
|
||||
recordedAt: 1_800_000_000_000,
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'git-worktree'
|
||||
}))
|
||||
|
||||
/** What Continue reads, rendered each time it changes. */
|
||||
const seen: { pending: boolean; resuming: readonly string[] }[] = []
|
||||
function Probe({ launch }: { launch: boolean }): null {
|
||||
useNativeChatRestartOffer(launch)
|
||||
seen.push({
|
||||
pending: useNativeChatLaunchResumePending(),
|
||||
resuming: useNativeChatRestartResuming()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function settings(autoResume: boolean | undefined): void {
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
experimentalStructuredNativeChat: true,
|
||||
...(autoResume === undefined ? {} : { nativeChatResumeWorkOnRestart: autoResume })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rpc.mockReset()
|
||||
_resetNativeChatRestartOffer()
|
||||
_resetNativeChatLaunchResumeDecision()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
seen.length = 0
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('holds Continue back from the settings wait until the opted-in launch names its chats', async () => {
|
||||
const read = Promise.withResolvers<unknown>()
|
||||
rpc.mockImplementation((_target, method) =>
|
||||
method === 'agentSession.restartResumable' ? read.promise : new Promise(() => {})
|
||||
)
|
||||
// Settings not loaded yet: the launch may still resume.
|
||||
await act(async () => root.render(<Probe launch={false} />))
|
||||
expect(seen.at(-1)).toEqual({ pending: true, resuming: [] })
|
||||
|
||||
settings(true)
|
||||
await act(async () => root.render(<Probe launch />))
|
||||
expect(seen.at(-1)).toEqual({ pending: true, resuming: [] })
|
||||
|
||||
await act(async () => read.resolve({ sessions: offered }))
|
||||
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: ['a', 'b'] })
|
||||
// Never a render where the launch had decided and its chats were not yet named.
|
||||
expect(seen.some((entry) => !entry.pending && entry.resuming.length === 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds nothing back once the launch found nothing to resume', async () => {
|
||||
settings(true)
|
||||
rpc.mockResolvedValue({ sessions: [] })
|
||||
await act(async () => root.render(<Probe launch />))
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: [] })
|
||||
})
|
||||
|
||||
it('holds nothing back when the launch asks instead of resuming', () => {
|
||||
settings(false)
|
||||
act(() => root.render(<Probe launch={false} />))
|
||||
expect(seen.at(-1)).toEqual({ pending: false, resuming: [] })
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Whether this launch has made its one resume decision: nothing offered, ask, or resume (from then
|
||||
// on the store's `resuming` names the chats). Until then an opted-in launch may still resume any of
|
||||
// this machine's chats, so nothing else offers to carry one on.
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
let decided = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getDecided(): boolean {
|
||||
return decided
|
||||
}
|
||||
|
||||
export function markNativeChatLaunchResumeDecided(): void {
|
||||
if (!decided) {
|
||||
decided = true
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this launch may still resume this machine's chats on its own: "resume automatically" is
|
||||
* on, or its setting has not loaded yet, and the launch has not decided. */
|
||||
export function useNativeChatLaunchResumePending(): boolean {
|
||||
const launchDecided = useSyncExternalStore(subscribe, getDecided, getDecided)
|
||||
const autoResume = useAppStore((state) => state.settings?.nativeChatResumeWorkOnRestart)
|
||||
return !launchDecided && autoResume !== false
|
||||
}
|
||||
|
||||
/** @internal - tests need a clean module between cases. */
|
||||
export function _resetNativeChatLaunchResumeDecision(): void {
|
||||
decided = false
|
||||
listeners.clear()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/runtime/structured-agent-session-status-feed'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import { useAppStore } from '../store'
|
||||
import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision'
|
||||
import {
|
||||
announceRestartResults,
|
||||
type RestartContinuationOutcome
|
||||
@@ -294,6 +295,8 @@ export async function continueNativeChatRestartOffer(
|
||||
let outcome: Parameters<typeof announceRestartResults>
|
||||
resumeBatches.add(batch)
|
||||
syncResuming()
|
||||
// `resuming` now names the chats, so the launch's one resume decision is made.
|
||||
markNativeChatLaunchResumeDecided()
|
||||
try {
|
||||
const result = await callStructuredAgentSession<
|
||||
HostOfferPayload & { continued?: RestartContinuationOutcome[] }
|
||||
@@ -401,7 +404,7 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
// Fetched after mount, never awaited by startup: the workspace is usable first.
|
||||
launch ??= loadLaunchOffer()
|
||||
launch ??= loadLaunchOffer().finally(markNativeChatLaunchResumeDecided)
|
||||
}
|
||||
}, [enabled])
|
||||
return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const hostLabel = (): string | null => 'studio-mac'
|
||||
const capability = (): 'unknown' | 'supported' | 'unsupported' => 'supported'
|
||||
const resuming: readonly string[] = []
|
||||
return {
|
||||
call: vi.fn(),
|
||||
capability: capability(),
|
||||
hostLabel: hostLabel(),
|
||||
resuming,
|
||||
launchPending: false
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-host-capability', () => ({
|
||||
useStructuredAgentSessionHostCapabilityState: () => mocks.capability
|
||||
}))
|
||||
vi.mock('../native-chat-resume-on-restart-store', () => ({
|
||||
useNativeChatRestartResuming: () => mocks.resuming
|
||||
}))
|
||||
vi.mock('../native-chat-launch-resume-decision', () => ({
|
||||
useNativeChatLaunchResumePending: () => mocks.launchPending
|
||||
}))
|
||||
vi.mock('./use-structured-agent-session-host-label', () => ({
|
||||
useStructuredAgentSessionHostLabel: () => mocks.hostLabel
|
||||
}))
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import {
|
||||
NativeChatInterruptedContinue,
|
||||
useNativeChatInterruptedContinuation
|
||||
} from './NativeChatInterruptedContinue'
|
||||
|
||||
const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 })
|
||||
const PAIRED: RuntimeClientTarget = { kind: 'environment', environmentId: 'studio-mac' }
|
||||
|
||||
const cutChat: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: TURN,
|
||||
revision: 2,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 }
|
||||
},
|
||||
{
|
||||
itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'stale-session:s:death-1-5' }),
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 6,
|
||||
turnScope: { kind: 'turn', turnItemId: TURN },
|
||||
body: { kind: 'status', text: 'Codex stopped.', tone: 'error', orcaStop: { cause: 'crash' } }
|
||||
}
|
||||
]
|
||||
|
||||
type Props = {
|
||||
journalItems?: readonly AgentJournalRenderItem[]
|
||||
submissions?: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
|
||||
isWorking?: boolean
|
||||
target?: RuntimeClientTarget
|
||||
}
|
||||
|
||||
function Harness(props: Props): React.JSX.Element {
|
||||
// The chat's own composer error, as NativeChatStructuredSession holds it.
|
||||
const [composerError, setComposerError] = useState<string | null>(null)
|
||||
const continuation = useNativeChatInterruptedContinuation({
|
||||
composer: { clearError: () => setComposerError(null) },
|
||||
target: props.target ?? PAIRED,
|
||||
sessionId: 'session-1',
|
||||
journalItems: props.journalItems ?? cutChat,
|
||||
submissions: props.submissions ?? [],
|
||||
isWorking: props.isWorking ?? false
|
||||
})
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<span data-testid="offered">{continuation.offeredTurnItemId ?? 'none'}</span>
|
||||
<span data-testid="available">{String(continuation.view.continueAvailable)}</span>
|
||||
<span data-testid="error">
|
||||
{composerError ?? continuation.continueError?.text ?? 'none'}
|
||||
</span>
|
||||
<button type="button" onClick={() => setComposerError(ATTACHMENTS)}>
|
||||
compose
|
||||
</button>
|
||||
<NativeChatInterruptedContinue continuation={continuation} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const continueButton = () => screen.queryByRole('button', { name: 'Continue' })
|
||||
const FAILED = "Couldn't continue this chat. Try again, or send a message."
|
||||
const ATTACHMENTS = 'Remove attachments before using a chat-session command.'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.capability = 'supported'
|
||||
mocks.resuming = []
|
||||
mocks.launchPending = false
|
||||
mocks.hostLabel = 'studio-mac'
|
||||
mocks.call.mockReset()
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Continue on a reply an Orca stop cut off', () => {
|
||||
it("asks the chat's own host, paired or local, to continue that cut turn", () => {
|
||||
mocks.call.mockResolvedValue({ outcome: 'pending' })
|
||||
render(<Harness />)
|
||||
expect(screen.getByTestId('offered')).toHaveTextContent(TURN)
|
||||
|
||||
fireEvent.click(continueButton()!)
|
||||
|
||||
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(PAIRED, 'agentSession.continueInterrupted', {
|
||||
sessionId: 'session-1',
|
||||
turnItemId: TURN
|
||||
})
|
||||
// Gone once asked, and the row gets its own way on back: the journal shows what came of it.
|
||||
expect(continueButton()).toBeNull()
|
||||
expect(screen.getByTestId('offered')).toHaveTextContent('none')
|
||||
})
|
||||
|
||||
it('says what it does, for a reader and on hover', () => {
|
||||
render(<Harness />)
|
||||
expect(continueButton()).toHaveAccessibleDescription(
|
||||
'Continue, and the agent first checks whether its last step finished.'
|
||||
)
|
||||
})
|
||||
|
||||
it('is not offered by a host without the operation; the user continues by sending', () => {
|
||||
mocks.capability = 'unsupported'
|
||||
render(<Harness />)
|
||||
expect(continueButton()).toBeNull()
|
||||
expect(screen.getByTestId('available')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('tells the rows the same thing before the host answers, after it answers, and after a click', () => {
|
||||
mocks.capability = 'unknown'
|
||||
mocks.call.mockResolvedValue({ outcome: 'pending' })
|
||||
const { rerender } = render(<Harness />)
|
||||
expect(screen.getByTestId('available')).toHaveTextContent('true')
|
||||
expect(continueButton()).toBeNull()
|
||||
|
||||
mocks.capability = 'supported'
|
||||
rerender(<Harness />)
|
||||
expect(screen.getByTestId('available')).toHaveTextContent('true')
|
||||
fireEvent.click(continueButton()!)
|
||||
|
||||
expect(screen.getByTestId('available')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('is not offered while the restart prompt or the launch is resuming this chat', () => {
|
||||
mocks.resuming = ['session-1']
|
||||
render(<Harness />)
|
||||
expect(continueButton()).toBeNull()
|
||||
})
|
||||
|
||||
it("is not offered on this machine's chats while the launch may still resume them", () => {
|
||||
mocks.launchPending = true
|
||||
render(<Harness target={{ kind: 'local' }} />)
|
||||
expect(continueButton()).toBeNull()
|
||||
cleanup()
|
||||
// The launch resumes only this machine's chats; a paired server's chat is its own.
|
||||
render(<Harness />)
|
||||
expect(continueButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is not offered once anything was sent, or while the agent works', () => {
|
||||
render(<Harness submissions={[{ dispatchState: 'pending' }]} />)
|
||||
expect(continueButton()).toBeNull()
|
||||
cleanup()
|
||||
render(<Harness isWorking />)
|
||||
expect(continueButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('is not offered for a cut no Orca stop explains', () => {
|
||||
render(<Harness journalItems={cutChat.slice(0, 1)} />)
|
||||
expect(continueButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('says so once, in the composer, and offers it again when the host refuses', async () => {
|
||||
mocks.call.mockResolvedValue({ outcome: 'refused', reason: 'agent_session_not_attached' })
|
||||
render(<Harness />)
|
||||
|
||||
fireEvent.click(continueButton()!)
|
||||
await waitFor(() => expect(continueButton()).toBeInTheDocument())
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(FAILED)
|
||||
// A retry clears the line until its own answer, which sets the same one line again.
|
||||
fireEvent.click(continueButton()!)
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('none')
|
||||
await waitFor(() => expect(continueButton()).toBeInTheDocument())
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(FAILED)
|
||||
})
|
||||
|
||||
it('says so and offers it again when the request fails', async () => {
|
||||
mocks.call.mockRejectedValue(new Error('offline'))
|
||||
render(<Harness />)
|
||||
|
||||
fireEvent.click(continueButton()!)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED))
|
||||
expect(continueButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces an older composer error with its own failure, and clears it when it goes through', async () => {
|
||||
mocks.call.mockResolvedValueOnce({ outcome: 'refused', reason: 'agent_session_conflict' })
|
||||
mocks.call.mockResolvedValueOnce({ outcome: 'pending' })
|
||||
render(<Harness />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'compose' }))
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS)
|
||||
|
||||
fireEvent.click(continueButton()!)
|
||||
await waitFor(() => expect(continueButton()).toBeInTheDocument())
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(FAILED)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'compose' }))
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS)
|
||||
fireEvent.click(continueButton()!)
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('none')
|
||||
})
|
||||
|
||||
it('leaves an unrelated composer error alone when the chat moves on by itself', () => {
|
||||
const { rerender } = render(<Harness />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'compose' }))
|
||||
rerender(<Harness submissions={[{ dispatchState: 'pending' }]} />)
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS)
|
||||
})
|
||||
|
||||
it('drops the line once the chat was continued, here or by another client', async () => {
|
||||
// The answer was lost after the host accepted it.
|
||||
mocks.call.mockRejectedValue(new Error('timed out'))
|
||||
const { rerender } = render(<Harness />)
|
||||
fireEvent.click(continueButton()!)
|
||||
await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED))
|
||||
|
||||
// The journal then shows the continuation on its way: the chat is no longer on that cut.
|
||||
rerender(<Harness submissions={[{ dispatchState: 'pending' }]} />)
|
||||
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('none')
|
||||
})
|
||||
|
||||
it('leaves no line when a retry finds the chat already continued', async () => {
|
||||
mocks.call.mockRejectedValueOnce(new Error('timed out'))
|
||||
mocks.call.mockResolvedValueOnce({ outcome: 'superseded' })
|
||||
render(<Harness />)
|
||||
fireEvent.click(continueButton()!)
|
||||
await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED))
|
||||
|
||||
fireEvent.click(continueButton()!)
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca-stop-cut'
|
||||
import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-continue-interrupted-capability'
|
||||
import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context'
|
||||
import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label'
|
||||
import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store'
|
||||
import { useNativeChatLaunchResumePending } from '../native-chat-launch-resume-decision'
|
||||
import type { NativeChatComposerNoticeContent } from './native-chat-composer-notice'
|
||||
|
||||
type ContinueAnswer = { outcome?: string }
|
||||
|
||||
export type NativeChatInterruptedContinuation = {
|
||||
/** What the chat's rows are told: the machine's name and whether its host can continue a cut. */
|
||||
view: NativeChatOrcaStopView
|
||||
/** The cut turn Continue is offered on right now, if any. */
|
||||
offeredTurnItemId: string | null
|
||||
/** A Continue that did not go through, for the composer's notice card: shown while the chat
|
||||
* still sits on that cut and gone once it is continued from anywhere. */
|
||||
continueError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null
|
||||
continueNow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue, while the chat's latest turn is a reply an Orca stop cut off and nothing was sent since.
|
||||
* Offered only by a host that has the operation; an older host writes no row naming the stop, so it
|
||||
* never has a cut to offer it on, and the user continues by sending a message.
|
||||
*/
|
||||
export function useNativeChatInterruptedContinuation(input: {
|
||||
target: RuntimeClientTarget
|
||||
sessionId: string
|
||||
journalItems: readonly AgentJournalRenderItem[]
|
||||
submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
|
||||
isWorking: boolean
|
||||
/** The composer's own error; a Continue click is the user's newer action, so it clears it. */
|
||||
composer: { clearError: () => void }
|
||||
}): NativeChatInterruptedContinuation {
|
||||
const { target, sessionId } = input
|
||||
const hostLabel = useStructuredAgentSessionHostLabel(target)
|
||||
const capability = useStructuredAgentSessionHostCapabilityState(
|
||||
target,
|
||||
AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY
|
||||
)
|
||||
// The restart prompt or the launch's own resume is carrying this chat on, or the launch may still
|
||||
// decide to; it resumes only this machine's chats.
|
||||
const launchPending = useNativeChatLaunchResumePending() && target.kind === 'local'
|
||||
const resuming = useNativeChatRestartResuming().includes(sessionId) || launchPending
|
||||
const cut = useMemo(
|
||||
() => latestNativeChatOrcaStopCut(input.journalItems, input.submissions),
|
||||
[input.journalItems, input.submissions]
|
||||
)
|
||||
// The cut this client already asked to continue: hidden until the journal shows what came of it.
|
||||
const [asked, setAsked] = useState<string | null>(null)
|
||||
const [failedOn, setFailedOn] = useState<string | null>(null)
|
||||
const offered =
|
||||
capability === 'supported' && cut && !input.isWorking && !resuming && asked !== cut.turnItemId
|
||||
? cut.turnItemId
|
||||
: null
|
||||
const continueNow = (): void => {
|
||||
if (offered === null) {
|
||||
return
|
||||
}
|
||||
const turnItemId = offered
|
||||
// Nothing was accepted: one line in the composer, which a retry replaces rather than repeats.
|
||||
const failed = (): void => {
|
||||
setAsked((current) => (current === turnItemId ? null : current))
|
||||
setFailedOn(turnItemId)
|
||||
}
|
||||
setAsked(turnItemId)
|
||||
setFailedOn(null)
|
||||
input.composer.clearError()
|
||||
void callStructuredAgentSession<ContinueAnswer>(target, 'agentSession.continueInterrupted', {
|
||||
sessionId,
|
||||
turnItemId
|
||||
}).then((answer) => (answer.outcome === 'refused' ? failed() : undefined), failed)
|
||||
}
|
||||
// Unknown counts as able: a host that writes cause rows has Continue, and the words stay put.
|
||||
const continueAvailable = capability !== 'unsupported'
|
||||
// One object per change, so the chat's rows re-render only when what they show changes.
|
||||
const view = useMemo(() => ({ hostLabel, continueAvailable }), [hostLabel, continueAvailable])
|
||||
const failedHere =
|
||||
failedOn !== null && cut?.turnItemId === failedOn
|
||||
? translate(
|
||||
'components.native-chat.interruptedContinue.failed',
|
||||
"Couldn't continue this chat. Try again, or send a message."
|
||||
)
|
||||
: null
|
||||
return {
|
||||
view,
|
||||
offeredTurnItemId: offered,
|
||||
continueError: failedHere ? { text: failedHere, onDismiss: () => setFailedOn(null) } : null,
|
||||
continueNow
|
||||
}
|
||||
}
|
||||
|
||||
export function NativeChatInterruptedContinue({
|
||||
continuation
|
||||
}: {
|
||||
continuation: NativeChatInterruptedContinuation
|
||||
}): React.JSX.Element | null {
|
||||
const explanationId = useId()
|
||||
if (continuation.offeredTurnItemId === null) {
|
||||
return null
|
||||
}
|
||||
const explanation = translate(
|
||||
'components.native-chat.interruptedContinue.explanation',
|
||||
'Continue, and the agent first checks whether its last step finished.'
|
||||
)
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center justify-end px-4 py-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-describedby={explanationId}
|
||||
onClick={continuation.continueNow}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{translate('components.native-chat.interruptedContinue.continue', 'Continue')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{explanation}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span id={explanationId} className="sr-only">
|
||||
{explanation}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,18 +5,122 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { MessageRow } from './NativeChatMessageRow'
|
||||
import {
|
||||
NativeChatOrcaStopContext,
|
||||
type NativeChatOrcaStopView
|
||||
} from './native-chat-orca-stop-context'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderStatus(body: AgentJournalStatusItem) {
|
||||
function orcaStopView(
|
||||
hostLabel: string | null,
|
||||
continueAvailable: boolean
|
||||
): NativeChatOrcaStopView {
|
||||
return { hostLabel, continueAvailable }
|
||||
}
|
||||
|
||||
function renderStatus(
|
||||
body: AgentJournalStatusItem,
|
||||
hostLabel: string | null = null,
|
||||
continueAvailable = false
|
||||
) {
|
||||
const [message] = projectStructuredItemsToNativeChat([
|
||||
{ itemId: 'notice', sequence: 1, revision: 1, observedAt: 1, body }
|
||||
{
|
||||
itemId: 'notice',
|
||||
sequence: 1,
|
||||
revision: 1,
|
||||
observedAt: 1,
|
||||
body,
|
||||
turnScope: { kind: 'turn', turnItemId: 'cut-turn' }
|
||||
}
|
||||
])
|
||||
const view = orcaStopView(hostLabel, continueAvailable)
|
||||
return render(
|
||||
<MessageRow message={message!} expandSignal={false} onScrollMessageToTop={vi.fn()} />
|
||||
<NativeChatOrcaStopContext.Provider value={view}>
|
||||
<MessageRow message={message!} expandSignal={false} onScrollMessageToTop={vi.fn()} />
|
||||
</NativeChatOrcaStopContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const LEGACY_TEXT =
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
|
||||
function orcaStopRow(cause: string): AgentJournalStatusItem {
|
||||
return {
|
||||
kind: 'status',
|
||||
text: LEGACY_TEXT,
|
||||
tone: 'error',
|
||||
presentation: 'orca-stop',
|
||||
orcaStop: { cause }
|
||||
}
|
||||
}
|
||||
|
||||
describe('the row an Orca stop leaves', () => {
|
||||
it.each([
|
||||
['update', 'Orca on studio-mac restarted for an update while this response was in progress.'],
|
||||
['quit', 'Orca on studio-mac was closed while this response was in progress.'],
|
||||
['crash', 'Orca on studio-mac stopped unexpectedly while this response was in progress.']
|
||||
])('names a %s and the machine, muted', (cause, sentence) => {
|
||||
renderStatus(orcaStopRow(cause), 'studio-mac')
|
||||
const row = screen.getByText(`${sentence} You can continue in this conversation.`)
|
||||
expect(row.parentElement?.parentElement).toHaveClass('text-muted-foreground')
|
||||
expect(screen.queryByText(LEGACY_TEXT)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the way on to Continue wherever its host can continue a cut', () => {
|
||||
renderStatus(orcaStopRow('update'), 'studio-mac', true)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Orca on studio-mac restarted for an update while this response was in progress.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A client that re-words unnamed host rows keeps this row's presentation and cause, neutral.
|
||||
it('stays neutral, naming the cause, as a reader that re-presented it neutral hands it on', () => {
|
||||
// Its words and tone replaced, and the failure fact the old words came from dropped with them.
|
||||
const represented: AgentJournalStatusItem = {
|
||||
kind: 'status',
|
||||
text: 'This response was interrupted. You can continue in this conversation.',
|
||||
tone: 'notice',
|
||||
presentation: 'orca-stop',
|
||||
orcaStop: { cause: 'crash' }
|
||||
}
|
||||
renderStatus(represented, 'studio-mac', true)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Orca on studio-mac stopped unexpectedly while this response was in progress.'
|
||||
).parentElement?.parentElement
|
||||
).toHaveClass('text-muted-foreground')
|
||||
cleanup()
|
||||
renderStatus(represented, null, true)
|
||||
expect(
|
||||
screen.getByText('This response was interrupted. You can continue in this conversation.')
|
||||
.parentElement?.parentElement
|
||||
).toHaveClass('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('keeps the host words for a cause this build does not know', () => {
|
||||
renderStatus(orcaStopRow('power-loss'), 'studio-mac')
|
||||
expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the host words when the chat has no machine to name, muted all the same', () => {
|
||||
renderStatus(orcaStopRow('update'), null)
|
||||
expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass(
|
||||
'text-muted-foreground'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the host words for a stop Orca did not cause', () => {
|
||||
const { orcaStop: _orcaStop, presentation: _presentation, ...agentExit } = orcaStopRow('update')
|
||||
renderStatus(agentExit, 'studio-mac')
|
||||
expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass(
|
||||
'text-destructive'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('notice rows', () => {
|
||||
it('renders compaction as a centered separator', () => {
|
||||
renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
type AgentSessionHostStatusPresentation
|
||||
} from '../../../../shared/agent-session-host-status-rows'
|
||||
import type { NativeChatTextBlock } from '../../../../shared/native-chat-types'
|
||||
import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context'
|
||||
import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words'
|
||||
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop'
|
||||
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
|
||||
|
||||
const HOST_STATUS_WORDS: Record<AgentSessionHostStatusPresentation, () => string> = {
|
||||
@@ -35,6 +38,7 @@ export function NativeChatNoticeRow({
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
allowFileUriLinks?: boolean
|
||||
}): React.JSX.Element {
|
||||
const orcaStopView = useNativeChatOrcaStopView()
|
||||
if (block.presentation === 'compaction') {
|
||||
const label = translate('components.native-chat.notices.compaction', 'Context compacted')
|
||||
return (
|
||||
@@ -87,7 +91,16 @@ export function NativeChatNoticeRow({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const tone = block.tone
|
||||
// The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the
|
||||
// agent. With no machine to name it keeps the host's own words.
|
||||
const { orcaStop } = block
|
||||
const { hostLabel, continueAvailable } = orcaStopView
|
||||
const named = orcaStop !== undefined && hostLabel !== null
|
||||
const text = named
|
||||
? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable })
|
||||
: block.text
|
||||
const tone =
|
||||
named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone
|
||||
const Icon =
|
||||
tone === 'warning'
|
||||
? AlertTriangle
|
||||
@@ -108,7 +121,7 @@ export function NativeChatNoticeRow({
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{Icon ? <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> : null}
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words">{block.text}</p>
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words">{text}</p>
|
||||
</div>
|
||||
{block.providerFrame ? (
|
||||
<ProviderFrameRow
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appea
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { useStructuredChatLiveSession } from './use-structured-chat-live-session'
|
||||
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
|
||||
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
|
||||
@@ -27,6 +27,11 @@ import { useStructuredNativeChatPaneCommands } from './use-structured-native-cha
|
||||
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
|
||||
import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus'
|
||||
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
|
||||
import {
|
||||
NativeChatInterruptedContinue,
|
||||
useNativeChatInterruptedContinuation
|
||||
} from './NativeChatInterruptedContinue'
|
||||
import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context'
|
||||
import { structuredSessionNotices } from './native-chat-structured-session-notices'
|
||||
import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices'
|
||||
import { useNativeChatComposerError } from './use-native-chat-composer-notice'
|
||||
@@ -103,39 +108,24 @@ export function NativeChatStructuredSession(
|
||||
})
|
||||
const historyPhase = structuredChatHistoryPhase(provisionalLaunch, controller.status)
|
||||
const hostOutage = useNativeChatHostOutage(props.target)
|
||||
const session = useMemo<NativeChatLiveSession>(
|
||||
() => ({
|
||||
messages: controller.messages,
|
||||
status:
|
||||
controller.status === 'error'
|
||||
? 'error'
|
||||
: historyPhase !== 'known'
|
||||
? 'loading'
|
||||
: controller.isWorking
|
||||
? 'working'
|
||||
: controller.messages.length === 0
|
||||
? 'empty'
|
||||
: 'ready',
|
||||
sessionId: props.sessionId,
|
||||
agent: props.agent,
|
||||
...(controller.error ? { error: controller.error } : {}),
|
||||
// Older pages can't load while the host is unreachable, so the row waits for it.
|
||||
hasMore: controller.hasOlder && hostOutage === null,
|
||||
loadingEarlier: controller.loadingOlder,
|
||||
olderHistoryGeneration: controller.olderHistoryGeneration,
|
||||
loadEarlier: controller.loadOlder,
|
||||
readPhase:
|
||||
controller.status === 'loading'
|
||||
? 'loading'
|
||||
: controller.status === 'error'
|
||||
? 'error'
|
||||
: 'ready'
|
||||
}),
|
||||
[controller, historyPhase, hostOutage, props.agent, props.sessionId]
|
||||
const session = useStructuredChatLiveSession(
|
||||
controller,
|
||||
historyPhase,
|
||||
props.sessionId,
|
||||
props.agent,
|
||||
hostOutage === null
|
||||
)
|
||||
const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry)
|
||||
const { revealLatest } = submits
|
||||
const agentLabel = structuredAgentLabel(props.agent)
|
||||
const continuation = useNativeChatInterruptedContinuation({
|
||||
target: props.target,
|
||||
sessionId: props.sessionId,
|
||||
journalItems: controller.journalItems,
|
||||
submissions: controller.submissions,
|
||||
isWorking: controller.isWorking,
|
||||
composer: { clearError: () => reportComposerError(null) }
|
||||
})
|
||||
const deliveryNotices = useStructuredAgentSessionDeliveryNotices({
|
||||
pending: controller.pending,
|
||||
submissions: controller.submissions,
|
||||
@@ -204,7 +194,12 @@ export function NativeChatStructuredSession(
|
||||
const sessionError =
|
||||
viewState.kind === 'error' || !readFailure ? controller.error : readFailure.text
|
||||
const launch = { ...provisionalLaunch, retry: submits.retryLaunch }
|
||||
const notices = structuredSessionNotices({ launch, agentLabel, sessionError, composerError })
|
||||
const notices = structuredSessionNotices({
|
||||
launch,
|
||||
agentLabel,
|
||||
sessionError,
|
||||
composerError: composerError ?? continuation.continueError
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -234,34 +229,37 @@ export function NativeChatStructuredSession(
|
||||
<NativeChatEmptyState kind="empty" agent={props.agent} />
|
||||
) : (
|
||||
<NativeChatRewindContext.Provider value={controller.rewind.surface}>
|
||||
<NativeChatMessageList
|
||||
// A rewind replaces the conversation; nothing the old transcript held carries over.
|
||||
key={controller.epoch ?? undefined}
|
||||
ref={submits.messageListRef}
|
||||
session={session}
|
||||
journalItems={controller.journalItems}
|
||||
journalSubmissions={controller.submissions}
|
||||
journalLatestTurn={controller.latestTurn}
|
||||
subagentRoster={controller.subagentRoster}
|
||||
railOutline={controller.railOutline}
|
||||
isVisible={props.isVisible}
|
||||
isWorking={controller.isWorking}
|
||||
expandSignal={false}
|
||||
workingStartedAt={controller.workingStartedAt}
|
||||
settledTurns={controller.settledTurns}
|
||||
awaitingInput={prompt === null ? null : 'shown'}
|
||||
turnActivity={controller.turnActivity}
|
||||
stopping={stopControls.stopping}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={onLinkClick !== undefined}
|
||||
runtimeContext={imageRuntimeContext}
|
||||
deliveryNotices={deliveryNotices}
|
||||
/>
|
||||
<NativeChatOrcaStopContext.Provider value={continuation.view}>
|
||||
<NativeChatMessageList
|
||||
// A rewind replaces the conversation; nothing the old transcript held carries over.
|
||||
key={controller.epoch ?? undefined}
|
||||
ref={submits.messageListRef}
|
||||
session={session}
|
||||
journalItems={controller.journalItems}
|
||||
journalSubmissions={controller.submissions}
|
||||
journalLatestTurn={controller.latestTurn}
|
||||
subagentRoster={controller.subagentRoster}
|
||||
railOutline={controller.railOutline}
|
||||
isVisible={props.isVisible}
|
||||
isWorking={controller.isWorking}
|
||||
expandSignal={false}
|
||||
workingStartedAt={controller.workingStartedAt}
|
||||
settledTurns={controller.settledTurns}
|
||||
awaitingInput={prompt === null ? null : 'shown'}
|
||||
turnActivity={controller.turnActivity}
|
||||
stopping={stopControls.stopping}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={onLinkClick !== undefined}
|
||||
runtimeContext={imageRuntimeContext}
|
||||
deliveryNotices={deliveryNotices}
|
||||
/>
|
||||
</NativeChatOrcaStopContext.Provider>
|
||||
</NativeChatRewindContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
{readFailedFinally ? null : (
|
||||
<>
|
||||
<NativeChatInterruptedContinue continuation={continuation} />
|
||||
{/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */}
|
||||
<NativeChatQueuedMessageList
|
||||
controller={submits.queuedMessages}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/** What a structured chat tells its rows about an Orca stop: the name of the machine whose Orca runs
|
||||
* it, as Orca shows that host everywhere (null when it has none to show), and whether its host can
|
||||
* continue a cut, which only a host known not to has no Continue for. */
|
||||
export type NativeChatOrcaStopView = {
|
||||
hostLabel: string | null
|
||||
continueAvailable: boolean
|
||||
}
|
||||
|
||||
export const NativeChatOrcaStopContext = createContext<NativeChatOrcaStopView>({
|
||||
hostLabel: null,
|
||||
continueAvailable: false
|
||||
})
|
||||
|
||||
export function useNativeChatOrcaStopView(): NativeChatOrcaStopView {
|
||||
return useContext(NativeChatOrcaStopContext)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The words for a reply Orca's own stop cut off, by cause and machine. One function, so the chat's
|
||||
// row wording can change in one place.
|
||||
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-orca-stop'
|
||||
import { joinSentences } from '../../../../shared/sentence-joining'
|
||||
|
||||
const STOPPED: Record<AgentSessionOrcaStopCause, (machine: string) => string> = {
|
||||
update: (machine) =>
|
||||
translate(
|
||||
'components.native-chat.notices.orcaStopUpdate',
|
||||
'Orca on {{machine}} restarted for an update while this response was in progress.',
|
||||
{ machine }
|
||||
),
|
||||
quit: (machine) =>
|
||||
translate(
|
||||
'components.native-chat.notices.orcaStopQuit',
|
||||
'Orca on {{machine}} was closed while this response was in progress.',
|
||||
{ machine }
|
||||
),
|
||||
crash: (machine) =>
|
||||
translate(
|
||||
'components.native-chat.notices.orcaStopCrash',
|
||||
'Orca on {{machine}} stopped unexpectedly while this response was in progress.',
|
||||
{ machine }
|
||||
)
|
||||
}
|
||||
|
||||
/** The row's sentence. Where the host can continue a cut, Continue is the way on, so the row does
|
||||
* not say it again; decided by the host, not the button, so the words never change on screen. */
|
||||
export function nativeChatOrcaStopRowText(
|
||||
cause: AgentSessionOrcaStopCause,
|
||||
machine: string,
|
||||
options: { continueAvailable: boolean }
|
||||
): string {
|
||||
return options.continueAvailable
|
||||
? STOPPED[cause](machine)
|
||||
: joinSentences([
|
||||
STOPPED[cause](machine),
|
||||
translate(
|
||||
'components.native-chat.notices.orcaStopCanContinue',
|
||||
'You can continue in this conversation.'
|
||||
)
|
||||
])
|
||||
}
|
||||
@@ -265,6 +265,47 @@ describe('a turn no message opened', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Stored red for clients that predate it, but Orca stopped, not the agent: no failure, never folded.
|
||||
it("keeps the row about Orca's stop on screen beside the reply it cut, which stays the answer", () => {
|
||||
const orcaStop: NativeChatMessage = {
|
||||
...failure('orca-stop'),
|
||||
blocks: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Codex stopped while this response was in progress.',
|
||||
tone: 'error',
|
||||
presentation: 'orca-stop',
|
||||
orcaStop: { cause: 'update' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const messages = [text('u1', 'go', 'user'), text('a1', 'Looking.'), toolRun('work'), orcaStop]
|
||||
const slots = build(messages, {
|
||||
turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } }
|
||||
})
|
||||
expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([
|
||||
['u1', false],
|
||||
['a1', false],
|
||||
['orca-stop', false]
|
||||
])
|
||||
// The same, once a reader re-presented it neutral.
|
||||
const neutral: NativeChatMessage = {
|
||||
...orcaStop,
|
||||
blocks: orcaStop.blocks.map((block) =>
|
||||
block.type === 'text' ? { ...block, tone: 'notice' } : block
|
||||
)
|
||||
}
|
||||
expect(
|
||||
build([...messages.slice(0, 3), neutral], {
|
||||
turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } }
|
||||
}).map((slot) => [slot.message.id, slot.folded])
|
||||
).toEqual([
|
||||
['u1', false],
|
||||
['a1', false],
|
||||
['orca-stop', false]
|
||||
])
|
||||
})
|
||||
|
||||
it('folds an error the agent recovered from behind the answer that followed it', () => {
|
||||
const messages = [text('u1', 'go', 'user'), failure('retry'), text('a1', 'Done.')]
|
||||
const slots = build(messages, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop'
|
||||
import {
|
||||
isBackgroundTaskBlock,
|
||||
isSubagentGroupBlock,
|
||||
@@ -149,9 +150,18 @@ export function buildNativeChatTranscriptSlots(
|
||||
isBackgroundTaskBlock(block) ||
|
||||
isStoppedBeforeStartBlock(block)
|
||||
),
|
||||
reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'),
|
||||
reportsCompaction: message.blocks.some(
|
||||
(block) => block.type === 'text' && block.presentation === 'compaction'
|
||||
// A row about Orca's own stop is stored red for clients that predate it; it reports no failure.
|
||||
reportsFailure: message.blocks.some(
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
block.tone === 'error' &&
|
||||
block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION
|
||||
),
|
||||
explainsTurn: message.blocks.some(
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
(block.presentation === 'compaction' ||
|
||||
block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION)
|
||||
)
|
||||
}))
|
||||
// Liveness is the turn's, not any one call's: the run at the frontier stays
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label'
|
||||
import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { useAppStore } from '../../store'
|
||||
|
||||
/** What Orca calls the chat's host; null for a paired server this client has no name for, whose
|
||||
* raw id is not a name. */
|
||||
export function useStructuredAgentSessionHostLabel(target: RuntimeClientTarget): string | null {
|
||||
return useAppStore((state) => {
|
||||
const label = selectExecutionHostDisplayLabel(state, executionHostIdForStructuredTarget(target))
|
||||
return target.kind === 'environment' && label === target.environmentId ? null : label
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import type { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
import type { structuredChatHistoryPhase } from './native-chat-view-state'
|
||||
|
||||
type StructuredChatController = Pick<
|
||||
ReturnType<typeof useStructuredAgentSession>,
|
||||
| 'messages'
|
||||
| 'status'
|
||||
| 'isWorking'
|
||||
| 'error'
|
||||
| 'hasOlder'
|
||||
| 'loadingOlder'
|
||||
| 'olderHistoryGeneration'
|
||||
| 'loadOlder'
|
||||
>
|
||||
|
||||
/** The structured chat as the shared transcript reads it: its rows, and a status that stays
|
||||
* `loading` until the chat's history is known. */
|
||||
export function useStructuredChatLiveSession(
|
||||
controller: StructuredChatController,
|
||||
historyPhase: ReturnType<typeof structuredChatHistoryPhase>,
|
||||
sessionId: string,
|
||||
agent: NativeChatLiveSession['agent'],
|
||||
hostReachable: boolean
|
||||
): NativeChatLiveSession {
|
||||
return useMemo<NativeChatLiveSession>(
|
||||
() => ({
|
||||
messages: controller.messages,
|
||||
status:
|
||||
controller.status === 'error'
|
||||
? 'error'
|
||||
: historyPhase !== 'known'
|
||||
? 'loading'
|
||||
: controller.isWorking
|
||||
? 'working'
|
||||
: controller.messages.length === 0
|
||||
? 'empty'
|
||||
: 'ready',
|
||||
sessionId,
|
||||
agent,
|
||||
...(controller.error ? { error: controller.error } : {}),
|
||||
// Older pages can't load while the host is unreachable, so the row waits for it.
|
||||
hasMore: controller.hasOlder && hostReachable,
|
||||
loadingEarlier: controller.loadingOlder,
|
||||
olderHistoryGeneration: controller.olderHistoryGeneration,
|
||||
loadEarlier: controller.loadOlder,
|
||||
readPhase:
|
||||
controller.status === 'loading'
|
||||
? 'loading'
|
||||
: controller.status === 'error'
|
||||
? 'error'
|
||||
: 'ready'
|
||||
}),
|
||||
[controller, historyPhase, agent, sessionId, hostReachable]
|
||||
)
|
||||
}
|
||||
@@ -18121,7 +18121,11 @@
|
||||
"historyItemTooLarge": "This part of the chat was too large to show.",
|
||||
"stoppedBeforeStart": "Stopped before the agent started",
|
||||
"dismiss": "Dismiss",
|
||||
"copyError": "Copy error"
|
||||
"copyError": "Copy error",
|
||||
"orcaStopUpdate": "Orca on {{machine}} restarted for an update while this response was in progress.",
|
||||
"orcaStopQuit": "Orca on {{machine}} was closed while this response was in progress.",
|
||||
"orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.",
|
||||
"orcaStopCanContinue": "You can continue in this conversation."
|
||||
},
|
||||
"composer": {
|
||||
"imageUnsupported": "Image paste is not supported for this agent.",
|
||||
@@ -18574,6 +18578,11 @@
|
||||
"offline": "{{hostName}} is offline",
|
||||
"reconnecting": "{{hostName}} is reconnecting…",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continue",
|
||||
"failed": "Couldn't continue this chat. Try again, or send a message.",
|
||||
"explanation": "Continue, and the agent first checks whether its last step finished."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -18084,7 +18084,11 @@
|
||||
"historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.",
|
||||
"stoppedBeforeStart": "Detenido antes de que el agente empezara",
|
||||
"dismiss": "Descartar",
|
||||
"copyError": "Copiar error"
|
||||
"copyError": "Copiar error",
|
||||
"orcaStopUpdate": "Orca en {{machine}} se reinició para actualizarse mientras esta respuesta estaba en curso.",
|
||||
"orcaStopQuit": "Orca en {{machine}} se cerró mientras esta respuesta estaba en curso.",
|
||||
"orcaStopCrash": "Orca en {{machine}} se detuvo inesperadamente mientras esta respuesta estaba en curso.",
|
||||
"orcaStopCanContinue": "Puedes continuar en esta conversación."
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} bytes"
|
||||
@@ -18140,6 +18144,11 @@
|
||||
"offline": "{{hostName}} está sin conexión",
|
||||
"reconnecting": "{{hostName}} se está reconectando…",
|
||||
"reconnect": "Reconectar"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continuar",
|
||||
"failed": "No se pudo continuar este chat. Inténtalo de nuevo o envía un mensaje.",
|
||||
"explanation": "Continúa y el agente comprobará primero si su último paso terminó."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -18075,7 +18075,11 @@
|
||||
"historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.",
|
||||
"stoppedBeforeStart": "Arrêté avant le démarrage de l'agent",
|
||||
"dismiss": "Ignorer",
|
||||
"copyError": "Copier l'erreur"
|
||||
"copyError": "Copier l'erreur",
|
||||
"orcaStopUpdate": "Orca sur {{machine}} a redémarré pour une mise à jour pendant que cette réponse était en cours.",
|
||||
"orcaStopQuit": "Orca sur {{machine}} a été fermé pendant que cette réponse était en cours.",
|
||||
"orcaStopCrash": "Orca sur {{machine}} s'est arrêté de manière inattendue pendant que cette réponse était en cours.",
|
||||
"orcaStopCanContinue": "Vous pouvez continuer dans cette conversation."
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} octets"
|
||||
@@ -18242,6 +18246,11 @@
|
||||
"offline": "{{hostName}} est hors ligne",
|
||||
"reconnecting": "{{hostName}} se reconnecte…",
|
||||
"reconnect": "Reconnecter"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "Continuer",
|
||||
"failed": "Impossible de poursuivre cette conversation. Réessayez ou envoyez un message.",
|
||||
"explanation": "Continuez, et l'agent vérifiera d'abord si sa dernière étape s'est terminée."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -18009,7 +18009,11 @@
|
||||
"historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。",
|
||||
"stoppedBeforeStart": "Agent の開始前に停止しました",
|
||||
"dismiss": "閉じる",
|
||||
"copyError": "エラーをコピー"
|
||||
"copyError": "エラーをコピー",
|
||||
"orcaStopUpdate": "この応答の途中で、{{machine}} の Orca がアップデートのため再起動しました。",
|
||||
"orcaStopQuit": "この応答の途中で、{{machine}} の Orca が終了しました。",
|
||||
"orcaStopCrash": "この応答の途中で、{{machine}} の Orca が予期せず停止しました。",
|
||||
"orcaStopCanContinue": "この会話で続行できます。"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}}バイト"
|
||||
@@ -18176,6 +18180,11 @@
|
||||
"offline": "{{hostName}} はオフラインです",
|
||||
"reconnecting": "{{hostName}} に再接続しています…",
|
||||
"reconnect": "再接続"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "続行",
|
||||
"failed": "このチャットを続行できませんでした。もう一度試すか、メッセージを送信してください。",
|
||||
"explanation": "続行すると、Agent はまず最後の手順が完了したかどうかを確認します。"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -18009,7 +18009,11 @@
|
||||
"historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.",
|
||||
"stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨",
|
||||
"dismiss": "닫기",
|
||||
"copyError": "오류 복사"
|
||||
"copyError": "오류 복사",
|
||||
"orcaStopUpdate": "이 응답이 진행되는 동안 {{machine}}의 Orca가 업데이트를 위해 다시 시작되었습니다.",
|
||||
"orcaStopQuit": "이 응답이 진행되는 동안 {{machine}}의 Orca가 종료되었습니다.",
|
||||
"orcaStopCrash": "이 응답이 진행되는 동안 {{machine}}의 Orca가 예기치 않게 중지되었습니다.",
|
||||
"orcaStopCanContinue": "이 대화에서 계속할 수 있습니다."
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}}바이트"
|
||||
@@ -18176,6 +18180,11 @@
|
||||
"offline": "{{hostName}}이(가) 오프라인입니다",
|
||||
"reconnecting": "{{hostName}}에 다시 연결하는 중…",
|
||||
"reconnect": "다시 연결"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "계속",
|
||||
"failed": "이 채팅을 계속할 수 없습니다. 다시 시도하거나 메시지를 보내세요.",
|
||||
"explanation": "계속하면 에이전트가 먼저 마지막 단계가 완료되었는지 확인합니다."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -17974,7 +17974,11 @@
|
||||
"historyItemTooLarge": "聊天的这一部分过大,无法显示。",
|
||||
"stoppedBeforeStart": "在智能体开始前已停止",
|
||||
"dismiss": "关闭",
|
||||
"copyError": "复制错误"
|
||||
"copyError": "复制错误",
|
||||
"orcaStopUpdate": "此回复进行期间,{{machine}} 上的 Orca 为更新而重新启动。",
|
||||
"orcaStopQuit": "此回复进行期间,{{machine}} 上的 Orca 已关闭。",
|
||||
"orcaStopCrash": "此回复进行期间,{{machine}} 上的 Orca 意外停止。",
|
||||
"orcaStopCanContinue": "你可以在此对话中继续。"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} 字节"
|
||||
@@ -18141,6 +18145,11 @@
|
||||
"offline": "{{hostName}} 已离线",
|
||||
"reconnecting": "正在重新连接 {{hostName}}…",
|
||||
"reconnect": "重新连接"
|
||||
},
|
||||
"interruptedContinue": {
|
||||
"continue": "继续",
|
||||
"failed": "无法继续此聊天。请重试,或发送一条消息。",
|
||||
"explanation": "继续后,代理会先检查其上一步是否已完成。"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -24,3 +24,22 @@ export function agentSessionAccountHome(
|
||||
): AgentSessionAccountHome {
|
||||
return { variable: agent.accountHomeVariable, path }
|
||||
}
|
||||
|
||||
const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/
|
||||
export const MAX_PATH_LENGTH = 4096
|
||||
|
||||
/** Shape only: whether the variable is the one the record's agent pins is a launch-time question
|
||||
* (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */
|
||||
export function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome {
|
||||
if (typeof value !== 'object' || value === null || !('variable' in value) || !('path' in value)) {
|
||||
return false
|
||||
}
|
||||
const { variable, path } = value
|
||||
return (
|
||||
typeof variable === 'string' &&
|
||||
ENVIRONMENT_VARIABLE_NAME.test(variable) &&
|
||||
typeof path === 'string' &&
|
||||
path.length > 0 &&
|
||||
path.length <= MAX_PATH_LENGTH
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Split out of protocol-version.ts, which lists it in RUNTIME_CAPABILITIES. Import it from here.
|
||||
|
||||
// Why: a client may call `agentSession.continueInterrupted` (Continue on a reply an Orca stop cut
|
||||
// off) only on a host that has it; an older host also writes no row naming that stop, so a client
|
||||
// never offers Continue there.
|
||||
export const AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY =
|
||||
'agent-session.continue-interrupted.v1' as const
|
||||
@@ -246,7 +246,8 @@ const KnownItemBody = z.discriminatedUnion('kind', [
|
||||
turnLifecycle: z.object(TurnLifecycleFields).optional(),
|
||||
providerFrame: ProviderFrame.optional(),
|
||||
threadGoal: AgentJournalThreadGoalStateSchema.optional(),
|
||||
failure: AgentSessionFailureFactSchema.optional()
|
||||
failure: AgentSessionFailureFactSchema.optional(),
|
||||
orcaStop: z.object({ cause: z.string().min(1) }).optional()
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('turn'),
|
||||
|
||||
@@ -326,6 +326,9 @@ type AgentJournalStatusItemFields = {
|
||||
}
|
||||
/** Present on thread-goal transitions; absent on rows from older hosts. */
|
||||
threadGoal?: AgentJournalThreadGoalState
|
||||
/** On the row about a reply Orca's own stop cut off: why Orca stopped. Its cause is open (a newer
|
||||
* host may name one this build does not know), so read it with `readAgentSessionOrcaStop`. */
|
||||
orcaStop?: { cause: string }
|
||||
}
|
||||
|
||||
/** A status row that reports no failure; its text is its writer's own. */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Why the Orca running a chat stopped in the middle of a reply: a quit, an update, or a crash. The
|
||||
// host states it on its row about the cut turn (`orcaStop`); a client names it, and offers Continue.
|
||||
// Open on the wire: a cause this build does not know reads as none, and the row keeps its words.
|
||||
|
||||
export const AGENT_SESSION_ORCA_STOP_CAUSES = ['update', 'quit', 'crash'] as const
|
||||
/** The row's presentation, so a client that re-words host rows it can't name passes it through. */
|
||||
export const AGENT_SESSION_ORCA_STOP_PRESENTATION = 'orca-stop'
|
||||
export type AgentSessionOrcaStopCause = (typeof AGENT_SESSION_ORCA_STOP_CAUSES)[number]
|
||||
export type AgentSessionOrcaStop = { cause: AgentSessionOrcaStopCause }
|
||||
|
||||
export function isAgentSessionOrcaStopCause(value: unknown): value is AgentSessionOrcaStopCause {
|
||||
return AGENT_SESSION_ORCA_STOP_CAUSES.some((cause) => cause === value)
|
||||
}
|
||||
|
||||
/** The stop a row states, when this build knows its cause. */
|
||||
export function readAgentSessionOrcaStop(value: unknown): AgentSessionOrcaStop | undefined {
|
||||
const cause =
|
||||
typeof value === 'object' && value !== null && 'cause' in value ? value.cause : undefined
|
||||
return isAgentSessionOrcaStopCause(cause) ? { cause } : undefined
|
||||
}
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
isAgentSessionProviderHandleInNamespace,
|
||||
isStructuredAgentId
|
||||
} from './agent-session-provider-handle-encoding'
|
||||
import type { AgentSessionAccountHome } from './agent-session-account-home'
|
||||
import {
|
||||
isAgentSessionAccountHome,
|
||||
MAX_PATH_LENGTH,
|
||||
type AgentSessionAccountHome
|
||||
} from './agent-session-account-home'
|
||||
|
||||
export type { AgentSessionAccountHome } from './agent-session-account-home'
|
||||
|
||||
@@ -71,6 +75,9 @@ export type AgentSessionProcessIdentity = {
|
||||
pid: number
|
||||
processStartTimeMs: number | null
|
||||
spawnToken: string
|
||||
/** The Orca runtime that started this process and holds its transport, stamped when the owner is
|
||||
* recorded; absent on owners older builds recorded. */
|
||||
runtime?: string
|
||||
}
|
||||
|
||||
export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number }
|
||||
@@ -93,6 +100,11 @@ export type AgentSessionDeathEvidence = {
|
||||
* proved it alive. Only a probe's proof records it: absent on a surface-release exit, a failed
|
||||
* start, and evidence older builds wrote. */
|
||||
lastProvenAliveAt?: number
|
||||
/** How the Orca runtime that held the owner ended, when the owner died with it: the quit or
|
||||
* update it had begun, else 'crash'. Absent when a provider died on its own while Orca ran, and
|
||||
* whenever that cannot be told. A string, since a newer build may write a cause this one does
|
||||
* not know; read it with `isAgentSessionOrcaStopCause`. */
|
||||
runtimeEnd?: string
|
||||
}
|
||||
|
||||
export type AgentSessionLease = {
|
||||
@@ -163,7 +175,6 @@ export type AgentSessionOptionsReplacement = {
|
||||
const MAX_ID_LENGTH = 512
|
||||
/** A death evidence's `detail` past this fails a load, so whoever writes one cuts it here. */
|
||||
export const MAX_AGENT_SESSION_DEATH_DETAIL_CHARS = MAX_ID_LENGTH
|
||||
const MAX_PATH_LENGTH = 4096
|
||||
const MAX_LAUNCH_ENV_ENTRIES = 256
|
||||
const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
|
||||
@@ -228,23 +239,8 @@ export function isAgentSessionProcessIdentity(
|
||||
(identity.processStartTimeMs === null ||
|
||||
(Number.isSafeInteger(identity.processStartTimeMs) &&
|
||||
(identity.processStartTimeMs as number) >= 0)) &&
|
||||
isBoundedString(identity.spawnToken, MAX_ID_LENGTH)
|
||||
)
|
||||
}
|
||||
|
||||
const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/
|
||||
|
||||
/** Shape only: whether the variable is the one the record's agent pins is a launch-time question
|
||||
* (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */
|
||||
function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const home = value as Partial<AgentSessionAccountHome>
|
||||
return (
|
||||
typeof home.variable === 'string' &&
|
||||
ENVIRONMENT_VARIABLE_NAME.test(home.variable) &&
|
||||
isBoundedString(home.path, MAX_PATH_LENGTH)
|
||||
isBoundedString(identity.spawnToken, MAX_ID_LENGTH) &&
|
||||
(identity.runtime === undefined || isBoundedString(identity.runtime, MAX_ID_LENGTH))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,7 +292,7 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath
|
||||
return false
|
||||
}
|
||||
const evidence = value as Partial<AgentSessionDeathEvidence>
|
||||
const { observedAt, lastProvenAliveAt, ownerFence } = evidence
|
||||
const { observedAt, lastProvenAliveAt, ownerFence, runtimeEnd } = evidence
|
||||
return (
|
||||
(evidence.kind === 'exit-observed' ||
|
||||
evidence.kind === 'pid-absent' ||
|
||||
@@ -309,7 +305,8 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath
|
||||
(lastProvenAliveAt === undefined ||
|
||||
(Number.isSafeInteger(lastProvenAliveAt) &&
|
||||
lastProvenAliveAt >= 0 &&
|
||||
lastProvenAliveAt <= observedAt))
|
||||
lastProvenAliveAt <= observedAt)) &&
|
||||
(runtimeEnd === undefined || isBoundedString(runtimeEnd, MAX_ID_LENGTH))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSessionFailureFact } from './agent-session-failure'
|
||||
import { agentSessionFailureWords } from './agent-session-failure-words'
|
||||
import { readAgentSessionOrcaStop } from './agent-session-orca-stop'
|
||||
import { agentJournalItemKey } from './agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem, AgentJournalTurnOutcome } from './agent-session-journal-types'
|
||||
import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice'
|
||||
import {
|
||||
latestNativeChatOrcaStopCut,
|
||||
orcaShutdownRowClientMessageId
|
||||
} from './native-chat-orca-stop-cut'
|
||||
|
||||
const LEGACY_TEXT =
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
|
||||
const turnId = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 })
|
||||
|
||||
function userMessage(sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: `user-${sequence}` }),
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
}
|
||||
}
|
||||
|
||||
function cutTurn(outcome?: AgentJournalTurnOutcome): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: turnId,
|
||||
revision: 2,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'turn',
|
||||
turnId: 'cut',
|
||||
state: 'interrupted',
|
||||
startedAt: 1,
|
||||
completedAt: 5,
|
||||
...(outcome ? { outcome } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's row, as a host this build or a newer one writes it: today's words and fact, and the
|
||||
* cause beside them. */
|
||||
function stopRow(orcaStop: { cause: string } | undefined, scoped = true): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: agentJournalItemKey({
|
||||
provider: 'orca',
|
||||
clientMessageId: orcaShutdownRowClientMessageId('session-1', 3, 'gen-1')
|
||||
}),
|
||||
revision: 1,
|
||||
sequence: 3,
|
||||
observedAt: 6,
|
||||
body: {
|
||||
kind: 'status',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('providerExited'), {
|
||||
agentName: 'Codex',
|
||||
surface: 'row'
|
||||
}),
|
||||
tone: 'error',
|
||||
...(orcaStop === undefined ? {} : { orcaStop })
|
||||
},
|
||||
...(scoped ? { turnScope: { kind: 'turn' as const, turnItemId: turnId } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const update = { cause: 'update' }
|
||||
|
||||
describe('the cause on a stopped row', () => {
|
||||
it('is read when known and dropped when a newer host names one this build does not know', () => {
|
||||
expect(readAgentSessionOrcaStop(update)).toEqual({ cause: 'update' })
|
||||
expect(readAgentSessionOrcaStop({ cause: 'power-loss' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rides beside today's sentence, so a client that reads no cause prints the same row", () => {
|
||||
expect(stopRow(update).body).toMatchObject({ text: LEGACY_TEXT, tone: 'error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a client that predates the cause', () => {
|
||||
it("reads the host row as the cut turn's one explanation and adds none of its own", () => {
|
||||
const items = [userMessage(1), cutTurn(), stopRow(update)]
|
||||
const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' })
|
||||
expect(read).toBe(items)
|
||||
expect(read.filter((item) => item.body.kind === 'status')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the cut Continue answers', () => {
|
||||
it('is the latest turn when an Orca stop cut it and nothing was sent since', () => {
|
||||
expect(latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update)], [])).toEqual({
|
||||
turnItemId: turnId,
|
||||
cause: 'update'
|
||||
})
|
||||
})
|
||||
|
||||
it('is gone once a message follows the cut', () => {
|
||||
expect(
|
||||
latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update), userMessage(4)], [])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stays for a steer the cut turn took, which asked for nothing new', () => {
|
||||
const steer: AgentJournalRenderItem = {
|
||||
...userMessage(4),
|
||||
turnScope: { kind: 'turn', turnItemId: turnId }
|
||||
}
|
||||
expect(
|
||||
latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update), steer], [])
|
||||
).toEqual({ turnItemId: turnId, cause: 'update' })
|
||||
})
|
||||
|
||||
it('stays across a conversation command run after the cut, and its turn', () => {
|
||||
const command: AgentJournalRenderItem = {
|
||||
...userMessage(4),
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: '/context' }],
|
||||
command: { name: 'context' }
|
||||
}
|
||||
}
|
||||
const commandTurn: AgentJournalRenderItem = {
|
||||
itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cmd', ordinal: 1 }),
|
||||
revision: 1,
|
||||
sequence: 5,
|
||||
observedAt: 7,
|
||||
body: {
|
||||
kind: 'turn',
|
||||
turnId: 'cmd',
|
||||
state: 'completed',
|
||||
outcome: 'success',
|
||||
userItemId: command.itemId
|
||||
}
|
||||
}
|
||||
expect(
|
||||
latestNativeChatOrcaStopCut(
|
||||
[userMessage(1), cutTurn(), stopRow(update), command, commandTurn],
|
||||
[]
|
||||
)
|
||||
).toEqual({ turnItemId: turnId, cause: 'update' })
|
||||
})
|
||||
|
||||
it('is gone while a send is on its way', () => {
|
||||
expect(
|
||||
latestNativeChatOrcaStopCut(
|
||||
[userMessage(1), cutTurn(), stopRow(update)],
|
||||
[{ dispatchState: 'pending' }]
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("is none for a stop with no Orca cause, a person's Stop, or an unscoped row", () => {
|
||||
expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(undefined)], [])).toBeNull()
|
||||
expect(latestNativeChatOrcaStopCut([cutTurn('cancellation'), stopRow(update)], [])).toBeNull()
|
||||
expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(update, false)], [])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// A reply cut off because the Orca running the chat stopped (an update, a quit, a crash), as the
|
||||
// host's row about it says, and whether the chat is still sitting on that cut. Derived from the
|
||||
// journal each time, never stored: the client shows Continue from it, and the host re-checks it
|
||||
// under the session lock before Continue sends anything.
|
||||
|
||||
import { readAgentSessionOrcaStop, type AgentSessionOrcaStopCause } from './agent-session-orca-stop'
|
||||
import { isRootAgentJournalItem } from './agent-session-journal-producer'
|
||||
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import { STALE_SESSION_ROW_PREFIX } from './agent-session-stop-row-identity'
|
||||
import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record'
|
||||
import { agentTurnVerdict } from './agent-turn-outcome'
|
||||
import { structuredAgentSessionCommandTurnItemIds } from './structured-agent-session-command-entry'
|
||||
import { isStructuredAgentSessionNonRequestRow } from './structured-agent-session-latest-request'
|
||||
|
||||
/** The row a quit or update writes for the turn it cut, keyed by the child the quit stopped, so a
|
||||
* retried stop of that child writes no second row. The prefix is one older clients already read
|
||||
* as explaining a cut turn, so they add no row of their own beside it. */
|
||||
export function orcaShutdownRowClientMessageId(
|
||||
sessionId: string,
|
||||
fence: number,
|
||||
generation: string
|
||||
): string {
|
||||
return `${STALE_SESSION_ROW_PREFIX}${sessionId}:shutdown-${fence}-${generation}`
|
||||
}
|
||||
|
||||
/** Why Orca stopped, from a status row the host wrote about it; null for any other row. */
|
||||
export function orcaStopCauseOfRow(item: AgentJournalRenderItem): AgentSessionOrcaStopCause | null {
|
||||
return item.body.kind === 'status'
|
||||
? (readAgentSessionOrcaStop(item.body.orcaStop)?.cause ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
export type NativeChatOrcaStopCut = {
|
||||
turnItemId: string
|
||||
cause: AgentSessionOrcaStopCause
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest root turn, when an Orca stop cut it and nothing was sent since: no request after it,
|
||||
* and no send still on its way. A steer into that turn or a conversation command asks for nothing
|
||||
* new (`isStructuredAgentSessionNonRequestRow`). Null otherwise.
|
||||
*/
|
||||
export function latestNativeChatOrcaStopCut(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
|
||||
): NativeChatOrcaStopCut | null {
|
||||
if (submissions.some((submission) => submission.dispatchState === 'pending')) {
|
||||
return null
|
||||
}
|
||||
const commandTurns = structuredAgentSessionCommandTurnItemIds(items)
|
||||
let turnIndex = -1
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index]!
|
||||
if (
|
||||
!isRootAgentJournalItem(item) ||
|
||||
isStructuredAgentSessionNonRequestRow(item, commandTurns)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (item.body.kind === 'message' && item.body.role === 'user') {
|
||||
return null
|
||||
}
|
||||
if (readAgentJournalTurn(item.body)) {
|
||||
turnIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
const turnItem = items[turnIndex]
|
||||
const turn = turnItem ? readAgentJournalTurn(turnItem.body) : null
|
||||
if (
|
||||
!turnItem ||
|
||||
!turn ||
|
||||
agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) !==
|
||||
'interruption'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
for (const item of items) {
|
||||
const cause =
|
||||
item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItem.itemId
|
||||
? orcaStopCauseOfRow(item)
|
||||
: null
|
||||
if (cause) {
|
||||
return { turnItemId: turnItem.itemId, cause }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function row(overrides: Partial<NativeChatTurnFoldRow> = {}): NativeChatTurnFold
|
||||
draws: true,
|
||||
outlivesTurn: false,
|
||||
reportsFailure: false,
|
||||
reportsCompaction: false,
|
||||
explainsTurn: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -214,9 +214,9 @@ describe('nativeChatTurnFold', () => {
|
||||
const rows = [
|
||||
row({ role: 'user' }),
|
||||
row(),
|
||||
row({ role: 'system', reportsCompaction: true }),
|
||||
row({ role: 'system', explainsTurn: true }),
|
||||
row(),
|
||||
row({ role: 'system', reportsCompaction: true })
|
||||
row({ role: 'system', explainsTurn: true })
|
||||
]
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows,
|
||||
|
||||
@@ -27,9 +27,10 @@ export type NativeChatTurnFoldRow = {
|
||||
/** Whether a system row reports a failure. It competes with prose to be the
|
||||
* turn's answer, so a turn that failed shows the error as its end. */
|
||||
reportsFailure: boolean
|
||||
/** Whether the row reports a compaction's result. It never folds: hiding it
|
||||
* would leave the turn's status as the only trace the context was rewritten. */
|
||||
reportsCompaction: boolean
|
||||
/** Whether the row explains what became of the turn: a compaction's result, or
|
||||
* that the response was interrupted. It never folds: hiding it would leave the
|
||||
* turn's status as the only trace of it. */
|
||||
explainsTurn: boolean
|
||||
}
|
||||
|
||||
export type NativeChatTurnFold = {
|
||||
@@ -87,13 +88,14 @@ export function nativeChatTurnFold({
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const { turnKey } = row
|
||||
// Outside the fold by construction: the reader's own message anchors the
|
||||
// turn, a roster or background-task row outlives it, and a compaction report explains it.
|
||||
// turn, a roster or background-task row outlives it, and a compaction report or interruption
|
||||
// notice explains it.
|
||||
if (
|
||||
turnKey === undefined ||
|
||||
row.role === 'user' ||
|
||||
!row.draws ||
|
||||
row.outlivesTurn ||
|
||||
row.reportsCompaction ||
|
||||
row.explainsTurn ||
|
||||
!settledTurnKeys.has(turnKey)
|
||||
) {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AgentSessionBackgroundTaskRunState
|
||||
} from './agent-session-background-task-wire'
|
||||
import type { AgentSessionTokenUsage } from './agent-session-context-usage'
|
||||
import type { AgentSessionOrcaStop } from './agent-session-orca-stop'
|
||||
import type { AgentSessionFailureFact } from './agent-session-failure'
|
||||
import type {
|
||||
AgentJournalMessageSendMode,
|
||||
@@ -63,6 +64,8 @@ export type NativeChatTextBlock = {
|
||||
}
|
||||
/** On a status line that reports a failure: what failed, typed. */
|
||||
failure?: AgentSessionFailureFact
|
||||
/** On the line about a reply Orca's own stop cut off: why, and the turn it cut. */
|
||||
orcaStop?: AgentSessionOrcaStop & { turnItemId?: string }
|
||||
}
|
||||
|
||||
/** A tool invocation by the agent. `input` is the (already-serialized) tool
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from './orchestration-runtime-capabilities'
|
||||
import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities'
|
||||
import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability'
|
||||
import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability'
|
||||
import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities'
|
||||
export {
|
||||
AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY,
|
||||
@@ -394,6 +395,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
// turn starts, so a client may gate on either.
|
||||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY,
|
||||
...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES,
|
||||
...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES,
|
||||
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -487,6 +487,7 @@ import {
|
||||
SpeechModelAction
|
||||
} from './speech-params'
|
||||
import { SshTarget } from './ssh-params'
|
||||
import { ContinueInterruptedParams } from './structured-agent-session-continue-params'
|
||||
import {
|
||||
AcknowledgeAttentionParams,
|
||||
AgentsParams,
|
||||
@@ -609,6 +610,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'agentSession.cancel': CancelParams,
|
||||
'agentSession.close': OptionsParams,
|
||||
'agentSession.commands': OptionsParams,
|
||||
'agentSession.continueInterrupted': ContinueInterruptedParams,
|
||||
'agentSession.conversationCommand': ConversationCommandParams,
|
||||
'agentSession.conversationOutline': OptionsParams,
|
||||
'agentSession.create': CreateParams,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Split out of structured-agent-session-params.ts, which is at its line limit.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { SessionId } from './structured-agent-session-params'
|
||||
|
||||
/** Continue on the chat's latest turn, which an Orca stop cut off. The host re-checks that this
|
||||
* turn is still that cut before sending anything, so a stale or second click sends nothing. */
|
||||
export const ContinueInterruptedParams = z
|
||||
.object({ sessionId: SessionId, turnItemId: z.string().min(1).max(1024) })
|
||||
.strict()
|
||||
@@ -39,6 +39,19 @@ export type StructuredAgentSessionLatestRequest = {
|
||||
settledAt: number | undefined
|
||||
}
|
||||
|
||||
/** A root row that is no request of its own: a conversation command or a row its turn produced, or
|
||||
* a send its handover placed inside a running turn (a steer), which that turn answers for. */
|
||||
export function isStructuredAgentSessionNonRequestRow(
|
||||
item: Pick<AgentJournalRenderItem, 'itemId' | 'body' | 'turnScope'>,
|
||||
commandTurnItemIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
return (
|
||||
commandTurnItemIds.has(item.itemId) ||
|
||||
isStructuredAgentSessionCommandRow(item, commandTurnItemIds) ||
|
||||
(item.body.kind === 'message' && item.body.role === 'user' && item.turnScope?.kind === 'turn')
|
||||
)
|
||||
}
|
||||
|
||||
/** Null when the journal holds no request with a verdict to give. Accepted and unanswered sends
|
||||
* are passed over — the session is working until their turn records — and so are sends that
|
||||
* failed nobody (withdrawn, or left undelivered by a restart or a close). */
|
||||
@@ -54,8 +67,7 @@ export function latestStructuredAgentSessionRequest(
|
||||
if (
|
||||
!item ||
|
||||
!isRootAgentJournalItem(item) ||
|
||||
commandTurns.has(item.itemId) ||
|
||||
isStructuredAgentSessionCommandEntry(item.body)
|
||||
isStructuredAgentSessionNonRequestRow(item, commandTurns)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -70,12 +82,7 @@ export function latestStructuredAgentSessionRequest(
|
||||
}
|
||||
}
|
||||
const submission = rejected.get(item.itemId)
|
||||
if (
|
||||
submission &&
|
||||
classifyDispatchRejection(submission).verdict === 'failure' &&
|
||||
// Handed into a running turn (a steer): that turn answers for it.
|
||||
item.turnScope?.kind !== 'turn'
|
||||
) {
|
||||
if (submission && classifyDispatchRejection(submission).verdict === 'failure') {
|
||||
return {
|
||||
kind: 'refused-send',
|
||||
id: item.itemId,
|
||||
|
||||
@@ -133,7 +133,7 @@ function itemBlocks(item: AgentJournalRenderItem): {
|
||||
if (body.kind !== 'status' || body.turnLifecycle) {
|
||||
return null
|
||||
}
|
||||
return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body)] }
|
||||
return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body, item.turnScope)] }
|
||||
}
|
||||
|
||||
function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { readAgentSessionFailureFact } from './agent-session-failure'
|
||||
import type { AgentJournalStatusItem } from './agent-session-journal-types'
|
||||
import type { AgentJournalStatusItem, AgentJournalTurnScope } from './agent-session-journal-types'
|
||||
import { readAgentSessionOrcaStop } from './agent-session-orca-stop'
|
||||
import type { NativeChatTextBlock } from './native-chat-types'
|
||||
|
||||
/** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */
|
||||
export function structuredAgentSessionStatusBlock(
|
||||
body: AgentJournalStatusItem
|
||||
body: AgentJournalStatusItem,
|
||||
turnScope?: AgentJournalTurnScope
|
||||
): NativeChatTextBlock {
|
||||
const failure = readAgentSessionFailureFact(body.failure)
|
||||
const orcaStop = readAgentSessionOrcaStop(body.orcaStop)
|
||||
return {
|
||||
type: 'text',
|
||||
text: body.text,
|
||||
...(body.presentation !== undefined ? { presentation: body.presentation } : {}),
|
||||
...(body.tone !== undefined ? { tone: body.tone } : {}),
|
||||
...(body.providerFrame ? { providerFrame: body.providerFrame } : {}),
|
||||
...(failure ? { failure } : {})
|
||||
...(failure ? { failure } : {}),
|
||||
...(orcaStop
|
||||
? {
|
||||
orcaStop: {
|
||||
...orcaStop,
|
||||
...(turnScope?.kind === 'turn' ? { turnItemId: turnScope.turnItemId } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function structuredHostStub(
|
||||
restartResumableDismiss: vi.fn(async () => 0),
|
||||
restartResumeAll: vi.fn(async () => []),
|
||||
restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })),
|
||||
continueInterrupted: vi.fn(async () => ({ sessionId, outcome: 'superseded' })),
|
||||
attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })),
|
||||
// Attach-shaped entries take a client-supplied location, so the host is asked whether it
|
||||
// supports creating there. A real host always answers; leaving it unstubbed made every
|
||||
@@ -116,7 +117,8 @@ export function installableHost(
|
||||
listFailures: hostCalls.restartResumableFailures,
|
||||
dismiss: hostCalls.restartResumableDismiss,
|
||||
resume: hostCalls.restartResumeAll,
|
||||
continueAfterRestart: hostCalls.restartContinueAll
|
||||
continueAfterRestart: hostCalls.restartContinueAll,
|
||||
continueInterrupted: hostCalls.continueInterrupted
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a spy map standing in for the host; the dispatcher reaches only the members stubbed above, and a missing one fails the call rather than type-checking.
|
||||
|
||||
@@ -170,6 +170,12 @@ export const STRUCTURED_CALLS: {
|
||||
hostMethod: 'restartContinueAll',
|
||||
result: { resumed: [], continued: [] }
|
||||
},
|
||||
// Continue on a reply an Orca stop cut off. Clients call it only on a host advertising it.
|
||||
{
|
||||
method: 'agentSession.continueInterrupted',
|
||||
hostMethod: 'continueInterrupted',
|
||||
result: { sessionId: SESSION, outcome: 'superseded' }
|
||||
},
|
||||
{ method: 'agentSession.release', hostMethod: null, result: { released: true } },
|
||||
{
|
||||
method: 'agentSession.history',
|
||||
@@ -345,6 +351,8 @@ export function paramsFor(method: string): unknown {
|
||||
case 'agentSession.restartContinue':
|
||||
// Whole-surface calls: they name no session, and resume/continue narrow by an optional list.
|
||||
return {}
|
||||
case 'agentSession.continueInterrupted':
|
||||
return { sessionId: SESSION, turnItemId: 'legacy:codex:s:turn-1' }
|
||||
default:
|
||||
return { sessionId: SESSION }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user