mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
Hooks admit the lead's final reply with its turn; the launcher records undelivered prompts and proven exits (#38239)
This commit is contained in:
@@ -25,6 +25,14 @@ export function agentHookRuntimeDeps(
|
||||
? server.getStatusSnapshot()
|
||||
: server.getProviderSessionSourceRowsForTab(tabId),
|
||||
getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey),
|
||||
getAcceptedAgentReply: (paneKey, leadTurnKey) => server.getAcceptedReply(paneKey, leadTurnKey),
|
||||
subscribeAgentStatusRowChanges: (listener) =>
|
||||
server.subscribeStatusRowMutations(({ before, after }) => {
|
||||
const paneKey = after?.paneKey ?? before?.paneKey
|
||||
if (paneKey) {
|
||||
listener(paneKey)
|
||||
}
|
||||
}),
|
||||
subscribeAgentProviderSessionChanges: (listener) =>
|
||||
server.subscribeProviderSessionChanges(listener),
|
||||
// Use identity captured at observation; resolving now could attribute cached rows to a new process.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { buildBody, PANE } from './server.test-fixtures'
|
||||
import { mainAgentDoneAt } from '../../shared/agent-status/lifecycle/main-agent-status'
|
||||
import type { AutomationRun } from '../../shared/automations-types'
|
||||
import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: () => ({ nth_repo_added: 2 })
|
||||
}))
|
||||
|
||||
const CHILD = 'a6324370b7bede0c7'
|
||||
// Markers past the 8,000-char status preview prove the whole reply survived.
|
||||
const REPLY = `START ${'a'.repeat(12_000)} MIDDLE-MARKER ${'b'.repeat(12_000)} END-MARKER`
|
||||
|
||||
let userDataPath: string
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
_internals.resetCachesForTests()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-accepted-reply-'))
|
||||
server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function post(payload: Record<string, unknown>): Promise<void> {
|
||||
const env = server.buildPtyEnv()
|
||||
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload))
|
||||
})
|
||||
}
|
||||
|
||||
/** What a run watcher reads: the reply for the lead turn its selected row reports finished. */
|
||||
function replyForCurrentRow(): string | undefined {
|
||||
const row = server.getStatusSnapshotForPane(PANE)[0]
|
||||
const leadTurnKey = row ? mainAgentDoneAt(row) : undefined
|
||||
return leadTurnKey === undefined ? undefined : server.getAcceptedReply(PANE, leadTurnKey)
|
||||
}
|
||||
|
||||
describe('accepted lead reply', () => {
|
||||
it('keeps the whole reply for its turn, out of saved and public status, until a new prompt', async () => {
|
||||
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'write the report' })
|
||||
await post({ hook_event_name: 'Stop', last_assistant_message: REPLY })
|
||||
|
||||
expect(replyForCurrentRow()).toBe(REPLY)
|
||||
server.flushStatusPersistSync()
|
||||
for (const published of [
|
||||
readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8'),
|
||||
JSON.stringify(server.getStatusSnapshot())
|
||||
]) {
|
||||
expect(published).not.toContain('MIDDLE-MARKER')
|
||||
expect(published).not.toContain('acceptedReply')
|
||||
expect(published).not.toContain('replyCandidate')
|
||||
}
|
||||
|
||||
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'next task' })
|
||||
await post({ hook_event_name: 'Stop' })
|
||||
expect(replyForCurrentRow()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'a running child', permission: false },
|
||||
{ name: "a child's visible permission prompt", permission: true }
|
||||
])('keeps a reply the lead gave while $name held the turn', async ({ permission }) => {
|
||||
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'research in background' })
|
||||
await post({ hook_event_name: 'SubagentStart', agent_id: CHILD, agent_type: 'general-purpose' })
|
||||
if (permission) {
|
||||
await post({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: CHILD,
|
||||
agent_type: 'general-purpose',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'make' }
|
||||
})
|
||||
}
|
||||
await post({
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: REPLY,
|
||||
background_tasks: [{ id: CHILD, type: 'subagent', status: 'running' }]
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: permission ? 'waiting' : 'working',
|
||||
mainAgent: { state: 'done' }
|
||||
})
|
||||
|
||||
await post({ hook_event_name: 'SubagentStop', agent_id: CHILD, agent_type: 'general-purpose' })
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
expect(replyForCurrentRow()).toBe(REPLY)
|
||||
})
|
||||
|
||||
it("saves the reply of an agent that exits right after it, though exit cleanup clears the pane's status", async () => {
|
||||
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'write the report' })
|
||||
const paneKey: string = PANE
|
||||
const observer = createRuntimeAutomationRunTerminalObserver(
|
||||
{
|
||||
getTerminalHandleForPaneKey: () => 'terminal-1',
|
||||
readTerminal: async () => ({ tail: [] }),
|
||||
waitForTerminal: async () => {
|
||||
await post({ hook_event_name: 'Stop', last_assistant_message: REPLY })
|
||||
server.reconcileEndedProcessForPaneKeys([PANE])
|
||||
throw Object.assign(new Error('terminal_exited'), {
|
||||
terminalExitCause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
getAgentStatusRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey),
|
||||
getAcceptedReply: (paneKey, leadTurnKey) => server.getAcceptedReply(paneKey, leadTurnKey),
|
||||
subscribeStatusChanges: (listener) =>
|
||||
server.subscribeStatusRowMutations(({ after }) => after && listener(after.paneKey)),
|
||||
agentCommandsForRun: () => ['claude']
|
||||
}
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the observer reads only these run fields.
|
||||
const run = { terminalPaneKey: paneKey, startedAt: 0, dispatchedAt: 0 } as AutomationRun
|
||||
|
||||
const observation = await observer.observeCompletion('terminal-1', {
|
||||
signal: new AbortController().signal,
|
||||
run
|
||||
})
|
||||
|
||||
expect(replyForCurrentRow()).toBeUndefined()
|
||||
expect(observation).toMatchObject({
|
||||
status: 'completed',
|
||||
outputSnapshot: { content: REPLY, truncated: false }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mainAgentDoneAt } from '../../../shared/agent-status/lifecycle/main-agent-status'
|
||||
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
|
||||
/** The reply an event offers its lead's turn; a child's reply never answers it. */
|
||||
export function leadReplyOf(event: AgentHookEventPayload): string | undefined {
|
||||
return event.toolAgentId === undefined ? event.replyCandidate : undefined
|
||||
}
|
||||
|
||||
/** Admit a lead event's reply with the lead completion this row commits; otherwise keep the previous
|
||||
* reply only for the same owner and lead turn. Returns `row` itself when nothing changes. */
|
||||
export function withAcceptedReply(
|
||||
row: EnrichedAgentHookEventPayload,
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
leadReply: string | undefined,
|
||||
sameOwner: boolean
|
||||
): EnrichedAgentHookEventPayload {
|
||||
// Readers name the turn the same way from the public row they selected.
|
||||
const leadTurnKey = mainAgentDoneAt({
|
||||
state: row.payload.state,
|
||||
stateStartedAt: row.stateStartedAt,
|
||||
mainAgent: row.payload.mainAgent
|
||||
})
|
||||
const carried = previous?.acceptedReply
|
||||
const accepted =
|
||||
leadTurnKey === undefined
|
||||
? undefined
|
||||
: leadReply
|
||||
? { text: leadReply, leadTurnKey }
|
||||
: sameOwner && carried?.leadTurnKey === leadTurnKey
|
||||
? carried
|
||||
: undefined
|
||||
if (accepted === row.acceptedReply) {
|
||||
return row
|
||||
}
|
||||
const { acceptedReply: _stale, ...rest } = row
|
||||
return accepted ? { ...rest, acceptedReply: accepted } : rest
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isClaudePermissionSettledByReplay
|
||||
} from '../../../shared/agent-hook-listener/claude-settled-permission-requests'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
import { leadReplyOf, withAcceptedReply } from './server-accepted-reply'
|
||||
import {
|
||||
claudePermissionNamesToolUse,
|
||||
type ClaudeToolRequestFields
|
||||
@@ -28,10 +29,17 @@ export function pairedClaudeNonAgentWork(
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Keep main-agent progress behind a child's permission prompt so restart cannot settle live work. */
|
||||
/** Keep main-agent progress, and the reply the lead finished with, behind a child's permission prompt so restart cannot settle live work. */
|
||||
export function withHeldChildWaitMainAgent(
|
||||
previous: EnrichedAgentHookEventPayload,
|
||||
next: AgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload {
|
||||
return withAcceptedReply(heldChildWaitRow(previous, next), previous, leadReplyOf(next), true)
|
||||
}
|
||||
|
||||
function heldChildWaitRow(
|
||||
previous: EnrichedAgentHookEventPayload,
|
||||
next: AgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload {
|
||||
const mainAgent = next.payload.mainAgent
|
||||
if (!previous.toolAgentId || !mainAgent) {
|
||||
|
||||
@@ -265,6 +265,15 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The lead's full final reply for the finished turn `leadTurnKey` names, when this runtime admitted one. */
|
||||
getAcceptedReply(paneKey: string, leadTurnKey: number): string | undefined {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type.
|
||||
const row = this.state.lastStatusByPaneKey.get(paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
return row?.acceptedReply?.leadTurnKey === leadTurnKey ? row.acceptedReply.text : undefined
|
||||
}
|
||||
|
||||
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
|
||||
return this.hydratedAuthorityCommitments
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio
|
||||
// Terminal handles belong to this runtime and cannot safely rejoin a hydrated row.
|
||||
terminalHandle: _terminalHandle,
|
||||
hostTurnRevision: _hostTurnRevision,
|
||||
// Full replies serve this runtime's run records only.
|
||||
acceptedReply: _acceptedReply,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = enrichedPayload
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deriveCliWriterTopology } from './cli-writer-observation'
|
||||
import { leadReplyOf, withAcceptedReply } from './server-accepted-reply'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
|
||||
@@ -159,18 +160,24 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
|
||||
turnEndedAt: _turnEndedAt,
|
||||
turnEndDisposition: _turnEndDisposition,
|
||||
hostTurn: _hostTurn,
|
||||
replyCandidate: _replyCandidate,
|
||||
...event
|
||||
} = payload
|
||||
return {
|
||||
...event,
|
||||
receivedAt: now,
|
||||
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
|
||||
stateStartedAt,
|
||||
// Producers cannot declare a main-agent turn start.
|
||||
turnStartedAt,
|
||||
...turnEnd,
|
||||
...(hostTurn ? { hostTurn } : {})
|
||||
}
|
||||
return withAcceptedReply(
|
||||
{
|
||||
...event,
|
||||
receivedAt: now,
|
||||
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
|
||||
stateStartedAt,
|
||||
// Producers cannot declare a main-agent turn start.
|
||||
turnStartedAt,
|
||||
...turnEnd,
|
||||
...(hostTurn ? { hostTurn } : {})
|
||||
},
|
||||
previous,
|
||||
leadReplyOf(payload),
|
||||
!!previous && this.sameTerminalOwner(previous, payload)
|
||||
)
|
||||
}
|
||||
|
||||
/** Replay keeps evidence age separate from arrival order; unknown age falls back to now. */
|
||||
|
||||
@@ -54,6 +54,8 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
programStatus?: ProgramStatusFacet
|
||||
/** Facet-only publication; state readers must not infer a transition. */
|
||||
programStatusOnly?: true
|
||||
/** The lead's full final reply for the turn `leadTurnKey` names; main-process only, never persisted or published. */
|
||||
acceptedReply?: { text: string; leadTurnKey: number }
|
||||
}
|
||||
|
||||
// Persist non-agent work too: mainAgent cannot describe shell/cron/notification work that affects hydration.
|
||||
@@ -71,6 +73,8 @@ export type PersistedAgentHookEventPayload = Omit<
|
||||
| 'observation'
|
||||
// Terminal handles belong to one runtime.
|
||||
| 'terminalHandle'
|
||||
| 'acceptedReply'
|
||||
| 'replyCandidate'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
|
||||
@@ -50,11 +50,24 @@ import {
|
||||
recoverCreatedWorkspace
|
||||
} from './agent-launch-created-workspace'
|
||||
|
||||
import { opening, type AgentLaunchOpening } from './agent-launch-opening'
|
||||
|
||||
export type { AgentLaunchExecution } from './agent-launch-execution'
|
||||
|
||||
export async function executeAgentLaunch(
|
||||
execution: AgentLaunchExecution
|
||||
): Promise<AgentLaunchResult> {
|
||||
const settled = await (await openAgentLaunch(execution)).settlement
|
||||
if (settled.status === 'rejected') {
|
||||
throw settled.reason
|
||||
}
|
||||
return settled.value
|
||||
}
|
||||
|
||||
/** Resolve once the surface is published; prompt delivery continues in `settlement`. */
|
||||
export async function openAgentLaunch(
|
||||
execution: AgentLaunchExecution
|
||||
): Promise<AgentLaunchOpening> {
|
||||
const { intent, runtime } = execution
|
||||
const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
|
||||
const preflight = execution.decidedMode ?? decideAgentLaunchIntentMode(execution)
|
||||
@@ -64,7 +77,8 @@ export async function executeAgentLaunch(
|
||||
if (execution.promptPolicy === 'legacy-host') {
|
||||
assertLegacyHostTarget(execution)
|
||||
const placed = await resolveWorkspace(execution, preflight)
|
||||
return published(execution, legacyHostCreateResult(execution, placed, preflight))
|
||||
const surface = published(execution, legacyHostCreateResult(execution, placed, preflight))
|
||||
return opening(surface, async () => surface)
|
||||
}
|
||||
|
||||
// A reused process cannot accept new argv; deliver through its PTY.
|
||||
@@ -75,15 +89,14 @@ export async function executeAgentLaunch(
|
||||
receipt: preflight,
|
||||
...promptReceipt(intent, settledAtCreation(intent, {}))
|
||||
})
|
||||
return {
|
||||
const { handle } = intent.reuseTerminal
|
||||
return opening(reused, async () => ({
|
||||
...reused,
|
||||
...promptReceipt(
|
||||
intent,
|
||||
await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, {
|
||||
freshLaunch: false
|
||||
})
|
||||
await deliverTerminalLaunchPrompt(execution, handle, { freshLaunch: false })
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const launch =
|
||||
@@ -109,18 +122,23 @@ export async function executeAgentLaunch(
|
||||
})
|
||||
surfacePublished = true
|
||||
launch?.release()
|
||||
return {
|
||||
...startup,
|
||||
...promptReceipt(
|
||||
intent,
|
||||
placed.promptRodeLaunchCommand
|
||||
? HANDED_TO_TERMINAL
|
||||
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, {
|
||||
freshLaunch: true,
|
||||
terminalCreation: placed.startupTerminalCreation
|
||||
})
|
||||
)
|
||||
}
|
||||
const { startupTerminalHandle, startupTerminalCreation, promptRodeLaunchCommand } = placed
|
||||
return opening(
|
||||
startup,
|
||||
async () => ({
|
||||
...startup,
|
||||
...promptReceipt(
|
||||
intent,
|
||||
promptRodeLaunchCommand
|
||||
? HANDED_TO_TERMINAL
|
||||
: await deliverTerminalLaunchPrompt(execution, startupTerminalHandle, {
|
||||
freshLaunch: true,
|
||||
terminalCreation: startupTerminalCreation
|
||||
})
|
||||
)
|
||||
}),
|
||||
startupTerminalCreation
|
||||
)
|
||||
}
|
||||
|
||||
// Recover a created workspace on cancellation before an agent exists.
|
||||
@@ -175,10 +193,14 @@ export async function executeAgentLaunch(
|
||||
})
|
||||
surfacePublished = true
|
||||
launch?.release()
|
||||
return {
|
||||
...surface,
|
||||
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
|
||||
}
|
||||
return opening(
|
||||
surface,
|
||||
async () => ({
|
||||
...surface,
|
||||
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
|
||||
}),
|
||||
created.terminalCreation
|
||||
)
|
||||
} catch (error) {
|
||||
const createdWorktreeId = recoverableWorktreeId ?? launch?.createdWorktreeId()
|
||||
if (!surfacePublished && intent.target.kind !== 'existing' && createdWorktreeId) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AgentLaunchResult } from '../../shared/agent-launch/contracts/agent-launch-intent'
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-create-contract'
|
||||
|
||||
/** A published surface and, separately, its prompt settlement, which never rejects. */
|
||||
export type AgentLaunchOpening = {
|
||||
surface: AgentLaunchResult
|
||||
/** The terminal the surface opened, as its factory created it. */
|
||||
terminalCreation?: RuntimeTerminalCreate
|
||||
settlement: Promise<PromiseSettledResult<AgentLaunchResult>>
|
||||
}
|
||||
|
||||
/** Start delivery now; its outcome is captured so a late rejection is never unhandled. */
|
||||
export function opening(
|
||||
surface: AgentLaunchResult,
|
||||
settle: () => Promise<AgentLaunchResult>,
|
||||
terminalCreation?: RuntimeTerminalCreate
|
||||
): AgentLaunchOpening {
|
||||
return {
|
||||
surface,
|
||||
...(terminalCreation ? { terminalCreation } : {}),
|
||||
settlement: settle().then(
|
||||
(value) => ({ status: 'fulfilled', value }),
|
||||
(reason: unknown) => ({ status: 'rejected', reason })
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,13 @@ export async function requestAutomationDispatch(
|
||||
automation,
|
||||
run,
|
||||
target,
|
||||
// The status check and the store write share one synchronous step, so a settled run stays settled.
|
||||
transition: (result) => {
|
||||
const current = readRun()
|
||||
return current.status === 'dispatching'
|
||||
? { applied: true, run: ctx.runs.updateRun({ runId: run.id, ...result }) }
|
||||
: { applied: false, run: returnDurable(current) }
|
||||
},
|
||||
dispatcher: (request) => {
|
||||
if (readRun().status !== 'dispatching') {
|
||||
throw new AutomationDispatchCancelledError('The run changed before its agent could launch.')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AutomationRun } from '../../shared/automations-types'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status/contracts/agent-status-types'
|
||||
import { mainAgentDoneAt } from '../../shared/agent-status/lifecycle/main-agent-status'
|
||||
import {
|
||||
describeTerminalExitCause,
|
||||
type TerminalExitCause
|
||||
} from '../../shared/terminal/pty/lifecycle/terminal-exit-cause'
|
||||
import {
|
||||
automationRunFailure,
|
||||
type AutomationRunFailure
|
||||
@@ -11,8 +17,20 @@ import {
|
||||
|
||||
export { AGENT_START_GRACE_MS } from '../runtime/agent-state-rules/agent-turn-idle-verdict'
|
||||
|
||||
type AutomationRunStatusRow = AgentTurnStatusRow &
|
||||
Partial<
|
||||
Pick<
|
||||
ParsedAgentStatusPayload,
|
||||
'mainAgent' | 'lastAssistantMessage' | 'lastAssistantMessageIsToolOutput'
|
||||
>
|
||||
>
|
||||
|
||||
export type AutomationRunAgentEvidence = {
|
||||
getAgentStatusRowsForPane(paneKey: string): readonly AgentTurnStatusRow[]
|
||||
getAgentStatusRowsForPane(paneKey: string): readonly AutomationRunStatusRow[]
|
||||
/** The lead's full final reply for that finished turn; rows carry only a capped preview. */
|
||||
getAcceptedReply(paneKey: string, leadTurnKey: number): string | undefined
|
||||
/** Calls back synchronously on every status row change, including the clear when an agent exits. */
|
||||
subscribeStatusChanges(listener: (paneKey: string) => void): () => void
|
||||
/** Executable aliases used to distinguish a launch refusal from ordinary output. */
|
||||
agentCommandsForRun(run: AutomationRun): readonly string[]
|
||||
}
|
||||
@@ -75,3 +93,71 @@ export function judgeHeldRunTurn(
|
||||
? { kind: 'failed', ...automationRunFailure('agentInterrupted') }
|
||||
: { kind: 'completed' }
|
||||
}
|
||||
|
||||
export type RunAgentReply = { text: string; truncated: boolean }
|
||||
|
||||
/** This run's latest finished turn: whether it was cut off, and what the agent answered. */
|
||||
export type RunAgentTurn = { interrupted: boolean; reply: RunAgentReply | null }
|
||||
|
||||
/** A run watched with its agent's status evidence; `turn` is its latest captured finished turn. */
|
||||
export type JudgedRun = {
|
||||
evidence: AutomationRunAgentEvidence
|
||||
run: AutomationRun
|
||||
turn: () => RunAgentTurn | null
|
||||
}
|
||||
|
||||
/** The run's latest finished turn. Its reply is the full accepted one, else the status preview,
|
||||
* which the status cap may have cut by an amount its length cannot show. */
|
||||
export function runAgentTurn(
|
||||
evidence: AutomationRunAgentEvidence,
|
||||
run: AutomationRun,
|
||||
runStartedAt: number
|
||||
): RunAgentTurn | null {
|
||||
const paneKey = run.terminalPaneKey
|
||||
const sentAt = run.dispatchedAt ?? runStartedAt
|
||||
const row = (paneKey ? evidence.getAgentStatusRowsForPane(paneKey) : [])
|
||||
.filter((entry) => isTurnDoneSince(entry, sentAt))
|
||||
.toSorted((left, right) => right.stateStartedAt - left.stateStartedAt)[0]
|
||||
if (!paneKey || !row) {
|
||||
return null
|
||||
}
|
||||
const interrupted = row.interrupted === true
|
||||
const leadTurnKey = mainAgentDoneAt(row)
|
||||
const accepted =
|
||||
leadTurnKey === undefined ? undefined : evidence.getAcceptedReply(paneKey, leadTurnKey)
|
||||
if (accepted?.trim()) {
|
||||
return { interrupted, reply: { text: accepted, truncated: false } }
|
||||
}
|
||||
// Tool stdout is not the agent's answer.
|
||||
const preview = row.lastAssistantMessageIsToolOutput ? undefined : row.lastAssistantMessage
|
||||
return { interrupted, reply: preview?.trim() ? { text: preview, truncated: true } : null }
|
||||
}
|
||||
|
||||
/** Holds this run's latest finished turn as its status changes, so the clear when the agent exits
|
||||
* cannot erase it before the run settles. */
|
||||
export function captureRunAgentTurn(
|
||||
evidence: AutomationRunAgentEvidence,
|
||||
run: AutomationRun,
|
||||
runStartedAt: number
|
||||
): JudgedRun & { stop: () => void } {
|
||||
let latest = runAgentTurn(evidence, run, runStartedAt)
|
||||
const stop = evidence.subscribeStatusChanges((paneKey) => {
|
||||
if (paneKey === run.terminalPaneKey) {
|
||||
latest = runAgentTurn(evidence, run, runStartedAt) ?? latest
|
||||
}
|
||||
})
|
||||
return { evidence, run, turn: () => runAgentTurn(evidence, run, runStartedAt) ?? latest, stop }
|
||||
}
|
||||
|
||||
/** A proven agent exit's verdict: exit 0 completes the run unless its latest turn was cut off. */
|
||||
export function judgeRunExit(
|
||||
exit: TerminalExitCause,
|
||||
judged: JudgedRun | null
|
||||
): Exclude<IdleRunVerdict, { kind: 'wait' }> {
|
||||
if (judged?.turn()?.interrupted) {
|
||||
return { kind: 'failed', ...automationRunFailure('agentInterrupted') }
|
||||
}
|
||||
return exit.kind === 'exited' && exit.exitCode === 0
|
||||
? { kind: 'completed' }
|
||||
: { kind: 'failed', error: `${describeTerminalExitCause(exit)}.` }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EVERY_AGENT_CHAT } from '../../shared/native-chat/every-agent-chat.test-fixture'
|
||||
import { launchHeadlessAutomationAgent } from './headless-automation-agent-launch'
|
||||
|
||||
function launch(deliverStartupFollowup: () => Promise<boolean>) {
|
||||
const runtime = {
|
||||
requireStore: () => ({ getSettings: () => ({ nativeChatProviders: EVERY_AGENT_CHAT }) }),
|
||||
resolveManagedTerminalWorkspaceTarget: vi.fn(async () => ({
|
||||
managedWorktree: { displayName: 'repo' },
|
||||
scope: { folderWorkspace: null }
|
||||
})),
|
||||
launchAgentTerminal: vi.fn(async () => ({
|
||||
handle: 'term-1',
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Nightly'
|
||||
})),
|
||||
getPtyExecutionHostMetadata: vi.fn(() => ({ executionHostId: 'local' })),
|
||||
deliverStartupFollowup: vi.fn(deliverStartupFollowup)
|
||||
}
|
||||
return launchHeadlessAutomationAgent(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the existing-workspace launch reads only these runtime members.
|
||||
runtime as never,
|
||||
{
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only these automation fields are read.
|
||||
automation: {
|
||||
id: 'auto-1',
|
||||
agentId: 'aider',
|
||||
prompt: 'fix the flaky test',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt-1'
|
||||
} as never,
|
||||
run: { id: 'run-1', title: 'Nightly', scheduledFor: 1, trigger: 'scheduled' },
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the repo is read.
|
||||
target: { ok: true, repo: { id: 'repo-1', path: '/repo', displayName: 'repo' } } as never,
|
||||
runs: []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('headless automation agent launch', () => {
|
||||
it.each([
|
||||
['is refused', async () => false, "Aider started, but Orca could not send this run's prompt."],
|
||||
[
|
||||
'throws',
|
||||
async () => {
|
||||
throw new Error('writer broke')
|
||||
},
|
||||
'writer broke'
|
||||
]
|
||||
])(
|
||||
'opens the terminal and reports the prompt undelivered when delivery %s',
|
||||
async (_, deliver, error) => {
|
||||
const opened = await launch(deliver)
|
||||
|
||||
expect(opened).toMatchObject({
|
||||
terminalHandle: 'term-1',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:leaf-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
await expect(opened.delivery).resolves.toEqual({ error })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -3,16 +3,18 @@ import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
// Automations observe PTYs, so launch terminal agents regardless of the chat default.
|
||||
|
||||
import type { AgentLaunchResult as AgentLaunchPublishedSurface } from '../../shared/agent-launch/contracts/agent-launch-intent'
|
||||
|
||||
import type { AgentLaunchSurfaceExecution } from '../agent-launch/agent-launch-execution'
|
||||
import { executeAgentLaunch } from '../agent-launch/agent-launch-executor'
|
||||
import { openAgentLaunch } from '../agent-launch/agent-launch-executor'
|
||||
import { tuiAgentDisplayName } from '../../shared/agent-launch/catalog/tui-agent-display-names'
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-create-contract'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import { findReusableAutomationRun } from '../../shared/automation-session-reuse'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { deliverTerminalAgentLaunchPrompt } from '../runtime/rpc/methods/agent-launch/agent-launch-terminal-prompt'
|
||||
import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch'
|
||||
import {
|
||||
AutomationRunLaunchError,
|
||||
type HeadlessAutomationDispatchLaunch
|
||||
} from './headless-dispatch'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
|
||||
@@ -67,8 +69,6 @@ export async function launchHeadlessAutomationAgent(
|
||||
return reused
|
||||
}
|
||||
}
|
||||
let workspace: { id: string; displayName: string | null } | undefined
|
||||
let terminal: LaunchedTerminal | undefined
|
||||
const execution: AgentLaunchSurfaceExecution = {
|
||||
runtime,
|
||||
intent: {
|
||||
@@ -100,17 +100,22 @@ export async function launchHeadlessAutomationAgent(
|
||||
startupAgent,
|
||||
...(startupPrompt ? { startupPrompt } : {})
|
||||
})
|
||||
workspace = {
|
||||
id: created.worktree.id,
|
||||
displayName: created.worktree.displayName ?? null
|
||||
}
|
||||
const startup = created.startupTerminal
|
||||
if (!startup?.handle) {
|
||||
throw new Error(
|
||||
const message =
|
||||
created.warning || 'Automation workspace was created, but no agent terminal started.'
|
||||
)
|
||||
// A terminal that started before a later startup step failed still belongs to the run.
|
||||
throw startupTerminalCreation
|
||||
? new AutomationRunLaunchError(
|
||||
message,
|
||||
runTargetOf(
|
||||
startupTerminalCreation,
|
||||
created.worktree.id,
|
||||
created.worktree.displayName ?? null
|
||||
)
|
||||
)
|
||||
: new Error(message)
|
||||
}
|
||||
terminal = { ...startup, handle: startup.handle }
|
||||
return {
|
||||
worktreeId: created.worktree.id,
|
||||
startupTerminalHandle: startup.handle,
|
||||
@@ -148,8 +153,6 @@ export async function launchHeadlessAutomationAgent(
|
||||
folderWorkspace: selectedTarget?.scope.folderWorkspace ?? undefined
|
||||
}
|
||||
)
|
||||
terminal = launched
|
||||
workspace = { id: launched.worktreeId, displayName: resolved.displayName ?? null }
|
||||
return {
|
||||
handle: launched.handle,
|
||||
terminalCreation: launched,
|
||||
@@ -161,13 +164,39 @@ export async function launchHeadlessAutomationAgent(
|
||||
runtime.deliverStartupFollowup(handle, { agent, prompt: prompt.text }, { terminalCreation })
|
||||
}
|
||||
}
|
||||
await launchUntilPublished(execution)
|
||||
if (!workspace || !terminal) {
|
||||
throw new Error('Automation agent launch did not report its terminal.')
|
||||
const opened = await openAgentLaunch(execution)
|
||||
const { outcome, worktreeId } = opened.surface
|
||||
if (outcome.kind !== 'terminal') {
|
||||
throw new Error(TERMINAL_ONLY_MESSAGE)
|
||||
}
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
workspaceDisplayName: workspace.displayName,
|
||||
...runTargetOf(
|
||||
{ paneKey: outcome.paneKey, ...opened.terminalCreation, handle: outcome.handle },
|
||||
worktreeId,
|
||||
selectedWorkspace?.displayName ?? null
|
||||
),
|
||||
delivery: opened.settlement.then((settled) => ({
|
||||
error:
|
||||
settled.status === 'rejected'
|
||||
? settled.reason instanceof Error
|
||||
? settled.reason.message
|
||||
: String(settled.reason)
|
||||
: settled.value.prompt?.outcome === 'not-delivered'
|
||||
? `${tuiAgentDisplayName(agent)} started, but Orca could not send this run's prompt.`
|
||||
: null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** A null display name lets the run store resolve it from the workspace's metadata. */
|
||||
function runTargetOf(
|
||||
terminal: LaunchedTerminal,
|
||||
workspaceId: string,
|
||||
workspaceDisplayName: string | null
|
||||
): HeadlessAutomationAgentLaunch {
|
||||
return {
|
||||
workspaceId,
|
||||
workspaceDisplayName,
|
||||
terminalHandle: terminal.handle,
|
||||
terminalSessionId: terminal.tabId ?? null,
|
||||
terminalPaneKey: terminal.paneKey ?? null,
|
||||
@@ -256,28 +285,6 @@ export function runOwnHandle(
|
||||
: null
|
||||
}
|
||||
|
||||
/** Return on terminal publication; post-start prompt delivery may still be pending. */
|
||||
function launchUntilPublished(
|
||||
execution: AgentLaunchSurfaceExecution
|
||||
): Promise<AgentLaunchPublishedSurface> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let published = false
|
||||
executeAgentLaunch({
|
||||
...execution,
|
||||
onSurfacePublished: (surface) => {
|
||||
published = true
|
||||
resolve(surface)
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
if (!published) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
console.warn('[automations] the agent launch failed after its terminal was published', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function requireWorkspaceId(automation: Automation): string {
|
||||
if (!automation.workspaceId) {
|
||||
throw new Error('The target workspace is no longer available.')
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { closeTestStores, createStore, testState } from '../persistence/persistence-test-harness'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { AutomationService } from './service'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => testState.dir } }))
|
||||
|
||||
const OPENED = {
|
||||
workspaceId: 'r1::/repo/wt',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
|
||||
terminalPtyId: 'pty-1'
|
||||
}
|
||||
|
||||
async function heldLaunch(hold: 'launch' | 'delivery') {
|
||||
const store = await createStore()
|
||||
store.addRepo({ id: 'r1', path: '/repo', displayName: 'repo', badgeColor: '#fff', addedAt: 1 })
|
||||
const automation = store.createAutomation({
|
||||
name: 'Nightly',
|
||||
prompt: 'Fix the flaky test',
|
||||
agentId: 'aider',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: Date.UTC(2026, 4, 12)
|
||||
})
|
||||
let release: (error: string | null) => void = () => {}
|
||||
const released = new Promise<string | null>((resolve) => (release = resolve))
|
||||
let reach: () => void = () => {}
|
||||
const reached = new Promise<void>((resolve) => (reach = resolve))
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher: async () => {
|
||||
reach()
|
||||
if (hold === 'launch') {
|
||||
await released
|
||||
return OPENED
|
||||
}
|
||||
return { ...OPENED, delivery: released.then((error) => ({ error })) }
|
||||
}
|
||||
})
|
||||
const run = await service.runNow(automation.id)
|
||||
const row = () => store.listAutomationRuns(automation.id).find((entry) => entry.id === run.id)
|
||||
const settle = async (error: string | null) => {
|
||||
release(error)
|
||||
await service.runInAutomationLane(automation.id, async () => undefined)
|
||||
}
|
||||
return { store, service, run, row, reached, settle }
|
||||
}
|
||||
|
||||
describe('headless dispatch prompt delivery', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automation-delivery-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('links the terminal while delivery is held, then fails an undelivered run keeping it', async () => {
|
||||
const { service, row, settle } = await heldLaunch('delivery')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(row()).toMatchObject({
|
||||
status: 'dispatching',
|
||||
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
)
|
||||
await settle("Aider started, but Orca could not send this run's prompt.")
|
||||
|
||||
expect(row()).toMatchObject({
|
||||
status: 'dispatch_failed',
|
||||
terminalPaneKey: 'tab-1:9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
|
||||
terminalPtyId: 'pty-1',
|
||||
error: "Aider started, but Orca could not send this run's prompt."
|
||||
})
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it.each(['launch', 'delivery'] as const)(
|
||||
'keeps a run settled while its %s was held when that launch later succeeds',
|
||||
async (hold) => {
|
||||
const { store, service, run, row, reached, settle } = await heldLaunch(hold)
|
||||
await reached
|
||||
if (hold === 'delivery') {
|
||||
await vi.waitFor(() => expect(row()?.terminalPaneKey).toBe(OPENED.terminalPaneKey))
|
||||
}
|
||||
store.updateAutomationRun({ runId: run.id, status: 'dispatch_failed', error: 'Stranded.' })
|
||||
|
||||
await settle(null)
|
||||
|
||||
expect(row()).toMatchObject({ status: 'dispatch_failed', error: 'Stranded.' })
|
||||
service.stop()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
} from './headless-dispatch'
|
||||
import { AutomationRunLaunchError } from './headless-dispatch'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
import type { AutomationRunWriter } from './automation-run-writer'
|
||||
import {
|
||||
automationRunFailure,
|
||||
type AutomationRunFailure
|
||||
@@ -26,7 +25,11 @@ export type HeadlessAutomationDispatchContext = {
|
||||
run: AutomationRun
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
dispatcher: HeadlessAutomationDispatcher
|
||||
runs: AutomationRunWriter
|
||||
/** Write only while this dispatch still owns a dispatching run; throws AutomationDispatchCancelledError once the service or automation is gone. */
|
||||
transition: (result: Omit<AutomationDispatchResult, 'runId'>) => {
|
||||
applied: boolean
|
||||
run: Promise<AutomationRun>
|
||||
}
|
||||
runPrecheck: () => Promise<AutomationPrecheckResult | null>
|
||||
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
|
||||
watchRun: (run: AutomationRun) => void
|
||||
@@ -52,17 +55,16 @@ function heldForStoppedWslDistro(error: unknown): AutomationRunFailure | null {
|
||||
export async function runHeadlessAutomationDispatch(
|
||||
ctx: HeadlessAutomationDispatchContext
|
||||
): Promise<AutomationRun> {
|
||||
const { automation, run, target, runs } = ctx
|
||||
const { automation, run, target } = ctx
|
||||
const precheckResult =
|
||||
run.trigger === 'scheduled' && automation.precheck ? await ctx.runPrecheck() : null
|
||||
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
|
||||
return runs.updateRun({
|
||||
runId: run.id,
|
||||
return ctx.transition({
|
||||
status: 'skipped_precheck',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: formatAutomationPrecheckFailure(precheckResult)
|
||||
})
|
||||
}).run
|
||||
}
|
||||
let launch: HeadlessAutomationDispatchLaunch
|
||||
// The agent can finish before launch returns.
|
||||
@@ -74,26 +76,30 @@ export async function runHeadlessAutomationDispatch(
|
||||
error instanceof AutomationDispatchRefusedError
|
||||
? error.failure
|
||||
: heldForStoppedWslDistro(error)
|
||||
return runs.updateRun({
|
||||
runId: run.id,
|
||||
return ctx.transition({
|
||||
status: refusal ? 'skipped_unavailable' : 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
...(error instanceof AutomationRunLaunchError ? error.target : {}),
|
||||
...(automation.surface === 'chat' ? { dispatchedAt, precheckResult } : {}),
|
||||
...(refusal ?? { error: describeDispatchError(error) })
|
||||
}).run
|
||||
}
|
||||
const launchRunTarget = launchRunTargetOf(launch)
|
||||
if (launch.delivery) {
|
||||
// A startup dialog can hold delivery, so link the terminal while the run is still dispatching.
|
||||
const linked = ctx.transition({ status: 'dispatching', ...launchRunTarget })
|
||||
if (!linked.applied) {
|
||||
return linked.run
|
||||
}
|
||||
linked.run.catch((error: unknown) => {
|
||||
console.warn('[automations] failed to record the opened run terminal:', error)
|
||||
})
|
||||
const { error } = await launch.delivery
|
||||
if (error !== null) {
|
||||
return ctx.transition({ status: 'dispatch_failed', ...launchRunTarget, error }).run
|
||||
}
|
||||
}
|
||||
const launchRunTarget = {
|
||||
sessionKind: launch.sessionKind ?? 'terminal',
|
||||
chatSessionId: launch.chatSessionId ?? null,
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
terminalPaneKey: launch.terminalPaneKey ?? null,
|
||||
terminalPtyId: launch.terminalPtyId ?? null
|
||||
}
|
||||
const updated = runs.updateRun({
|
||||
runId: run.id,
|
||||
const updated = ctx.transition({
|
||||
status: 'dispatched',
|
||||
dispatchedAt,
|
||||
...launchRunTarget,
|
||||
@@ -102,6 +108,9 @@ export async function runHeadlessAutomationDispatch(
|
||||
...(launch.setupSkippedReason ? { setupSkippedReason: launch.setupSkippedReason } : {}),
|
||||
error: null
|
||||
})
|
||||
if (!updated.applied) {
|
||||
return updated.run
|
||||
}
|
||||
// Start observation without waiting for persistence acknowledgement.
|
||||
if (!launch.completion) {
|
||||
ctx.watchRun({
|
||||
@@ -135,5 +144,17 @@ export async function runHeadlessAutomationDispatch(
|
||||
)
|
||||
.catch((error) => console.error('[automations] Failed to persist run completion:', error))
|
||||
}
|
||||
return updated
|
||||
return updated.run
|
||||
}
|
||||
|
||||
function launchRunTargetOf(launch: HeadlessAutomationDispatchLaunch) {
|
||||
return {
|
||||
sessionKind: launch.sessionKind ?? 'terminal',
|
||||
chatSessionId: launch.chatSessionId ?? null,
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
terminalPaneKey: launch.terminalPaneKey ?? null,
|
||||
terminalPtyId: launch.terminalPtyId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export type HeadlessAutomationDispatchLaunch = {
|
||||
terminalSessionId: string | null
|
||||
terminalPaneKey?: string | null
|
||||
terminalPtyId?: string | null
|
||||
/** Settles once a newly opened terminal's prompt is handed over, or with why it was not; never rejects. */
|
||||
delivery?: Promise<{ error: string | null }>
|
||||
setupSkippedReason?: AutomationRunSetupSkippedReason
|
||||
completion?: Promise<
|
||||
{
|
||||
|
||||
@@ -37,6 +37,8 @@ export function createRuntimeAutomationService(input: {
|
||||
const launchedHandles = new Map<string, string>()
|
||||
const observer = createRuntimeAutomationRunTerminalObserver(runtime, {
|
||||
getAgentStatusRowsForPane: (paneKey) => runtime.getAgentStatusRowsForPane(paneKey),
|
||||
getAcceptedReply: (paneKey, leadTurnKey) => runtime.getAcceptedAgentReply(paneKey, leadTurnKey),
|
||||
subscribeStatusChanges: (listener) => runtime.subscribeAgentStatusRowChanges(listener),
|
||||
agentCommandsForRun: (run) =>
|
||||
automationAgentCommands(
|
||||
store.listAutomations().find((entry) => entry.id === run.automationId)?.agentId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusState } from '../../shared/agent-status/contracts/agent-status-types'
|
||||
import type { AutomationRun } from '../../shared/automations-types'
|
||||
import type { TerminalExitCause } from '../../shared/terminal/pty/lifecycle/terminal-exit-cause'
|
||||
import { AGENT_START_GRACE_MS } from './automation-run-agent-evidence'
|
||||
import {
|
||||
createRuntimeAutomationRunTerminalObserver,
|
||||
@@ -17,6 +18,7 @@ type StatusRow = {
|
||||
stateStartedAt: number
|
||||
sessionBoundary?: boolean
|
||||
interrupted?: boolean
|
||||
lastAssistantMessage?: string
|
||||
}
|
||||
|
||||
function createPane(initial: {
|
||||
@@ -25,12 +27,14 @@ function createPane(initial: {
|
||||
idleEdgeOnly?: boolean
|
||||
/** Model status cleanup landing while the observer reads the screen. */
|
||||
clearRowsOnRead?: boolean
|
||||
exitCause?: TerminalExitCause
|
||||
}) {
|
||||
let idle = initial.idle
|
||||
// Model runtimes that satisfy tui-idle only once per idle edge.
|
||||
let edgeSpent = false
|
||||
let tail = initial.tail
|
||||
let rows: StatusRow[] = []
|
||||
const acceptedReplies = new Map<number, string>()
|
||||
const waiters = new Set<() => void>()
|
||||
const runtime: AutomationRunTerminalHost = {
|
||||
getTerminalHandleForPaneKey: () => HANDLE,
|
||||
@@ -44,6 +48,11 @@ function createPane(initial: {
|
||||
if (options?.signal?.aborted) {
|
||||
return Promise.reject(new Error('request_aborted'))
|
||||
}
|
||||
if (initial.exitCause) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('terminal_exited'), { terminalExitCause: initial.exitCause })
|
||||
)
|
||||
}
|
||||
if (idle && !(initial.idleEdgeOnly && edgeSpent)) {
|
||||
edgeSpent = true
|
||||
return Promise.resolve({ satisfied: true })
|
||||
@@ -69,6 +78,7 @@ function createPane(initial: {
|
||||
return {
|
||||
runtime,
|
||||
agentRows: () => rows,
|
||||
acceptedReplies,
|
||||
report: (row: Partial<StatusRow> = {}) =>
|
||||
(rows = [{ state: 'done', receivedAt: Date.now(), stateStartedAt: Date.now(), ...row }]),
|
||||
setTail: (next: string[]) => (tail = next),
|
||||
@@ -95,6 +105,9 @@ function observe(
|
||||
) {
|
||||
const observer = createRuntimeAutomationRunTerminalObserver(pane.runtime, {
|
||||
getAgentStatusRowsForPane: (paneKey) => (paneKey === PANE_KEY ? pane.agentRows() : []),
|
||||
getAcceptedReply: (paneKey, leadTurnKey) =>
|
||||
paneKey === PANE_KEY ? pane.acceptedReplies.get(leadTurnKey) : undefined,
|
||||
subscribeStatusChanges: () => () => {},
|
||||
agentCommandsForRun: () => ['goose']
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the observer reads only these run fields.
|
||||
@@ -177,6 +190,59 @@ describe('observing a run with agent evidence', () => {
|
||||
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }))
|
||||
})
|
||||
|
||||
it("records the agent's accepted full reply as the run output, not its screen", async () => {
|
||||
const pane = createPane({ idle: false, tail: ['TUI chrome'] })
|
||||
const run = observe(pane)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
const doneAt = Date.now()
|
||||
pane.report({ stateStartedAt: doneAt })
|
||||
pane.acceptedReplies.set(doneAt, 'the full final reply')
|
||||
pane.becomeIdle()
|
||||
await run.promise
|
||||
expect(run.settled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
outputSnapshot: expect.objectContaining({
|
||||
content: 'the full final reply',
|
||||
truncated: false
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a status preview fallback truncated, since the cap can cut it below its own length', async () => {
|
||||
const pane = createPane({ idle: false, tail: ['TUI chrome'] })
|
||||
const run = observe(pane)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
pane.report({ lastAssistantMessage: 'a cut preview' })
|
||||
pane.becomeIdle()
|
||||
await run.promise
|
||||
expect(run.settled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outputSnapshot: expect.objectContaining({ content: 'a cut preview', truncated: true })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ exitCode: 0, interrupted: false, status: 'completed' },
|
||||
{ exitCode: 0, interrupted: true, status: 'dispatch_failed' },
|
||||
{ exitCode: 3, interrupted: false, status: 'dispatch_failed' }
|
||||
])(
|
||||
'settles a proven agent exit $exitCode after a turn with interrupted $interrupted as $status',
|
||||
async ({ exitCode, interrupted, status }) => {
|
||||
const pane = createPane({
|
||||
idle: false,
|
||||
tail: ['bye'],
|
||||
exitCause: { kind: 'exited', exitCode }
|
||||
})
|
||||
pane.report({ interrupted })
|
||||
const run = observe(pane)
|
||||
await run.promise
|
||||
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status }))
|
||||
}
|
||||
)
|
||||
|
||||
it('stops on cancellation without recording any result', async () => {
|
||||
const pane = createPane({ idle: true, tail: ['$'] })
|
||||
const run = observe(pane)
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
AutomationRunCompletionObservation,
|
||||
AutomationRunTerminalObserver
|
||||
} from './run-completion-watcher'
|
||||
import type { AutomationRun, AutomationRunOutputSnapshot } from '../../shared/automations-types'
|
||||
import type { AutomationRunOutputSnapshot } from '../../shared/automations-types'
|
||||
import {
|
||||
AUTOMATION_RUN_FAILURE_COPY,
|
||||
automationRunFailure,
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
import {
|
||||
judgeHeldRunTurn,
|
||||
judgeIdleRun,
|
||||
captureRunAgentTurn,
|
||||
judgeRunExit,
|
||||
type AutomationRunAgentEvidence,
|
||||
type JudgedRun,
|
||||
type IdleRunVerdict
|
||||
} from './automation-run-agent-evidence'
|
||||
import { readProvenTerminalExit } from '../runtime/terminal/wait/terminal-wait-results'
|
||||
|
||||
const TERMINAL_SNAPSHOT_LIMIT = 2_000
|
||||
|
||||
@@ -53,22 +57,36 @@ function snapshotOf(tail: readonly string[]): AutomationRunOutputSnapshot | null
|
||||
return snapshotBuffer.snapshot()
|
||||
}
|
||||
|
||||
/** A finished run keeps what the agent answered; the screen is the fallback for agents without hooks. */
|
||||
function completedSnapshot(
|
||||
tail: readonly string[],
|
||||
judged: JudgedRun | null
|
||||
): AutomationRunOutputSnapshot | null {
|
||||
const reply = judged?.turn()?.reply ?? null
|
||||
const snapshot = snapshotOf(reply ? [reply.text] : tail)
|
||||
return snapshot && reply?.truncated ? { ...snapshot, truncated: true } : snapshot
|
||||
}
|
||||
|
||||
async function judgeIdleTail(
|
||||
tail: readonly string[],
|
||||
evidence: AutomationRunAgentEvidence,
|
||||
run: AutomationRun,
|
||||
judged: JudgedRun,
|
||||
runStartedAt: number
|
||||
): Promise<AutomationRunCompletionObservation | null> {
|
||||
const verdict = judgeIdleRun(evidence, run, tail, runStartedAt, Date.now())
|
||||
return verdict.kind === 'wait' ? null : observationOf(verdict, tail)
|
||||
const verdict = judgeIdleRun(judged.evidence, judged.run, tail, runStartedAt, Date.now())
|
||||
return verdict.kind === 'wait' ? null : observationOf(verdict, tail, judged)
|
||||
}
|
||||
|
||||
function observationOf(
|
||||
verdict: Exclude<IdleRunVerdict, { kind: 'wait' }>,
|
||||
tail: readonly string[]
|
||||
tail: readonly string[],
|
||||
judged: JudgedRun | null
|
||||
): AutomationRunCompletionObservation {
|
||||
if (verdict.kind === 'completed') {
|
||||
return { status: 'completed', outputSnapshot: snapshotOf(tail), error: null }
|
||||
return {
|
||||
status: 'completed',
|
||||
outputSnapshot: completedSnapshot(tail, judged),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
const { kind: _kind, ...failure } = verdict
|
||||
return { status: 'dispatch_failed', outputSnapshot: snapshotOf(tail), ...failure }
|
||||
@@ -78,7 +96,7 @@ function observationOf(
|
||||
async function settleIdlePane(
|
||||
runtime: AutomationRunTerminalHost,
|
||||
handle: string,
|
||||
judged: { evidence: AutomationRunAgentEvidence; run: AutomationRun },
|
||||
judged: JudgedRun,
|
||||
runStartedAt: number,
|
||||
signal: AbortSignal,
|
||||
deadlineAt: number
|
||||
@@ -89,7 +107,7 @@ async function settleIdlePane(
|
||||
if (tail.join('\n') !== idleTail) {
|
||||
return null
|
||||
}
|
||||
const observation = await judgeIdleTail(tail, judged.evidence, judged.run, runStartedAt)
|
||||
const observation = await judgeIdleTail(tail, judged, runStartedAt)
|
||||
if (observation) {
|
||||
return observation
|
||||
}
|
||||
@@ -218,70 +236,86 @@ export function createRuntimeAutomationRunTerminalObserver(
|
||||
run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null,
|
||||
observeCompletion: async (handle, { signal, run }) => {
|
||||
const startedAt = Date.now()
|
||||
const judged = evidence && run?.terminalPaneKey ? { evidence, run } : null
|
||||
const runStartedAt = run?.startedAt ?? run?.dispatchedAt ?? startedAt
|
||||
// Without agent evidence, require a busy edge to exclude a previous run's idle state.
|
||||
if (!judged && (await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
|
||||
const started = await waitForAgentStart(
|
||||
runtime,
|
||||
handle,
|
||||
signal,
|
||||
startedAt + AGENT_START_DEADLINE_MS
|
||||
)
|
||||
if (!started) {
|
||||
return await buildUnobservedObservation(
|
||||
runtime,
|
||||
handle,
|
||||
automationRunFailure('agentNeverStarted')
|
||||
)
|
||||
}
|
||||
const judged =
|
||||
evidence && run?.terminalPaneKey ? captureRunAgentTurn(evidence, run, runStartedAt) : null
|
||||
try {
|
||||
return await observeTerminalRun(runtime, handle, signal, judged, startedAt, runStartedAt)
|
||||
} finally {
|
||||
judged?.stop()
|
||||
}
|
||||
const deadlineAt = startedAt + OBSERVE_DEADLINE_MS
|
||||
while (Date.now() < deadlineAt) {
|
||||
try {
|
||||
const wait = await runtime.waitForTerminal(handle, {
|
||||
condition: 'tui-idle',
|
||||
signal,
|
||||
...(judged ? { timeoutMs: HELD_DONE_SETTLE_MS } : {})
|
||||
})
|
||||
if (!judged || !wait.satisfied) {
|
||||
return await buildObservation(runtime, handle, wait)
|
||||
}
|
||||
const observation = await settleIdlePane(
|
||||
runtime,
|
||||
handle,
|
||||
judged,
|
||||
runStartedAt,
|
||||
signal,
|
||||
deadlineAt
|
||||
)
|
||||
if (observation) {
|
||||
return observation
|
||||
}
|
||||
} catch (error) {
|
||||
// Individual wait timeouts do not prove the agent stopped working.
|
||||
if (signal.aborted || !isTerminalWaitTimeout(error)) {
|
||||
throw error
|
||||
}
|
||||
const held = judged
|
||||
? judgeHeldRunTurn(
|
||||
judged.evidence,
|
||||
judged.run,
|
||||
runStartedAt,
|
||||
Date.now(),
|
||||
HELD_DONE_SETTLE_MS
|
||||
)
|
||||
: null
|
||||
if (held) {
|
||||
return observationOf(held, await readTail(runtime, handle))
|
||||
}
|
||||
}
|
||||
}
|
||||
return await buildUnobservedObservation(
|
||||
runtime,
|
||||
handle,
|
||||
automationRunFailure('watchTimedOut')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function observeTerminalRun(
|
||||
runtime: AutomationRunTerminalHost,
|
||||
handle: string,
|
||||
signal: AbortSignal,
|
||||
judged: JudgedRun | null,
|
||||
startedAt: number,
|
||||
runStartedAt: number
|
||||
): Promise<AutomationRunCompletionObservation> {
|
||||
// Without agent evidence, require a busy edge to exclude a previous run's idle state.
|
||||
if (!judged && (await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
|
||||
const started = await waitForAgentStart(
|
||||
runtime,
|
||||
handle,
|
||||
signal,
|
||||
startedAt + AGENT_START_DEADLINE_MS
|
||||
)
|
||||
if (!started) {
|
||||
return await buildUnobservedObservation(
|
||||
runtime,
|
||||
handle,
|
||||
automationRunFailure('agentNeverStarted')
|
||||
)
|
||||
}
|
||||
}
|
||||
const deadlineAt = startedAt + OBSERVE_DEADLINE_MS
|
||||
while (Date.now() < deadlineAt) {
|
||||
try {
|
||||
const wait = await runtime.waitForTerminal(handle, {
|
||||
condition: 'tui-idle',
|
||||
signal,
|
||||
...(judged ? { timeoutMs: HELD_DONE_SETTLE_MS } : {})
|
||||
})
|
||||
if (!judged || !wait.satisfied) {
|
||||
return await buildObservation(runtime, handle, wait)
|
||||
}
|
||||
const observation = await settleIdlePane(
|
||||
runtime,
|
||||
handle,
|
||||
judged,
|
||||
runStartedAt,
|
||||
signal,
|
||||
deadlineAt
|
||||
)
|
||||
if (observation) {
|
||||
return observation
|
||||
}
|
||||
} catch (error) {
|
||||
const exit = signal.aborted ? null : readProvenTerminalExit(error)
|
||||
if (exit) {
|
||||
return observationOf(judgeRunExit(exit, judged), await readTail(runtime, handle), judged)
|
||||
}
|
||||
// Individual wait timeouts do not prove the agent stopped working.
|
||||
if (signal.aborted || !isTerminalWaitTimeout(error)) {
|
||||
throw error
|
||||
}
|
||||
const held = judged
|
||||
? judgeHeldRunTurn(
|
||||
judged.evidence,
|
||||
judged.run,
|
||||
runStartedAt,
|
||||
Date.now(),
|
||||
HELD_DONE_SETTLE_MS
|
||||
)
|
||||
: null
|
||||
if (held) {
|
||||
return observationOf(held, await readTail(runtime, handle), judged)
|
||||
}
|
||||
}
|
||||
}
|
||||
return await buildUnobservedObservation(runtime, handle, automationRunFailure('watchTimedOut'))
|
||||
}
|
||||
|
||||
@@ -176,7 +176,10 @@ describe('AutomationService prechecks', () => {
|
||||
run,
|
||||
target: { ok: true, cwd: '/repo', repo: store.getRepo('r1')! },
|
||||
dispatcher: headlessDispatcher,
|
||||
runs: createAutomationRunWriter(store, null),
|
||||
transition: (result) => ({
|
||||
applied: true,
|
||||
run: createAutomationRunWriter(store, null).updateRun({ runId: run.id, ...result })
|
||||
}),
|
||||
runPrecheck: () => service.runPrecheck(automation.id, run.id),
|
||||
markDispatchResult: (result) => service.markDispatchResult(result),
|
||||
watchRun: () => {}
|
||||
|
||||
@@ -28,7 +28,8 @@ function headlessRuntime() {
|
||||
waitForTerminal: vi.fn(async () => ({ satisfied: true })),
|
||||
readTerminal: vi.fn(async () => ({ tail: ['done'] })),
|
||||
getTerminalHandleForPaneKey: vi.fn(() => null),
|
||||
getAgentStatusRowsForPane: vi.fn(() => [])
|
||||
getAgentStatusRowsForPane: vi.fn(() => []),
|
||||
subscribeAgentStatusRowChanges: vi.fn(() => () => {})
|
||||
}
|
||||
return { runtime, service: () => bound }
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export type RuntimeConstructionDependencies = {
|
||||
readObservedAgentStatusPaneIdentity?: (paneKey: string) => ObservedAgentStatusPaneIdentity
|
||||
getAgentProviderSessionSnapshot?: (tabId?: string) => AgentStatusIpcPayload[]
|
||||
getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[]
|
||||
getAcceptedAgentReply?: (paneKey: string, leadTurnKey: number) => string | undefined
|
||||
subscribeAgentStatusRowChanges?: (listener: (paneKey: string) => void) => () => void
|
||||
subscribeAgentProviderSessionChanges?: (
|
||||
listener: (sessions: readonly { paneKey: string; sessionId: string }[]) => void
|
||||
) => () => void
|
||||
|
||||
@@ -121,6 +121,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
|
||||
| ((paneKey: string) => AgentStatusIpcPayload[])
|
||||
| null
|
||||
|
||||
/** The agent's full final reply for that finished lead turn; status rows carry only a capped preview. */
|
||||
readonly getAcceptedAgentReply: RuntimeConstructionBindings['getAcceptedAgentReply']
|
||||
/** Synchronous on every hook status row change, including clears. */
|
||||
readonly subscribeAgentStatusRowChanges: RuntimeConstructionBindings['subscribeAgentStatusRowChanges']
|
||||
|
||||
protected readonly machineName = new RuntimeMachineName(
|
||||
() => this.store?.getSettings?.().machineName
|
||||
)
|
||||
@@ -246,6 +251,8 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
|
||||
this.getAgentProviderSessionSnapshotFn =
|
||||
deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null
|
||||
this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null
|
||||
this.getAcceptedAgentReply = deps?.getAcceptedAgentReply ?? (() => undefined)
|
||||
this.subscribeAgentStatusRowChanges = deps?.subscribeAgentStatusRowChanges ?? (() => () => {})
|
||||
this.attestAgentHookCompatibilityAuthorityFn =
|
||||
deps?.attestAgentHookCompatibilityAuthority ?? null
|
||||
this.retireAgentHookCompatibilityAuthorityFn =
|
||||
|
||||
@@ -16,6 +16,16 @@ export function buildPtyTerminalWaitExitError(pty: ReadonlyTerminalStateRecord):
|
||||
return Object.assign(new Error('terminal_exited'), { terminalExitCause: pty.lastExitCause })
|
||||
}
|
||||
|
||||
/** The process exit a terminal wait error proves; operator closes and unreported causes prove nothing. */
|
||||
export function readProvenTerminalExit(error: unknown): TerminalExitCause | null {
|
||||
const cause =
|
||||
error instanceof Error && error.message === 'terminal_exited' && 'terminalExitCause' in error
|
||||
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyTerminalWaitExitError attaches the PTY's recorded cause.
|
||||
(error.terminalExitCause as TerminalExitCause | null | undefined)
|
||||
: null
|
||||
return cause?.kind === 'exited' || cause?.kind === 'signaled' ? cause : null
|
||||
}
|
||||
|
||||
export function getTerminalState(leaf: ReadonlyTerminalStateRecord): RuntimeTerminalState {
|
||||
if (leaf.connected) {
|
||||
return 'running'
|
||||
|
||||
@@ -76,6 +76,8 @@ export type AgentHookEventPayload = {
|
||||
structuredHost?: StructuredHostStatus
|
||||
/** Rejoin moved pane rows by runtime terminal handle; never persist beyond its issuing runtime. */
|
||||
terminalHandle?: string
|
||||
/** Full reply text this hook carried; main's ingest consumes it and never stores, relays or persists it. */
|
||||
replyCandidate?: string
|
||||
/** Execution-host clock retained for age-preserving projection replay; never sent verbatim. */
|
||||
hostEvidenceObservedAt?: number
|
||||
payload: ParsedAgentStatusPayload
|
||||
|
||||
@@ -37,6 +37,8 @@ export type HookListenerState = {
|
||||
warnedEnvs: Set<string>
|
||||
lastPromptByPaneKey: Map<string, string>
|
||||
lastToolByPaneKey: Map<string, ToolSnapshot>
|
||||
/** Reply text the event being normalized carried; normalizeProviderEvent takes and clears it, so it never outlives that event. */
|
||||
eventReplyCandidate: string | undefined
|
||||
/** Read-only compatibility view. All writes pass through the isolated legacy adapter. */
|
||||
lastStatusByPaneKey: ReadonlyMap<string, AgentHookEventPayload>
|
||||
antigravityCompletedTranscriptByPaneKey: Map<string, string>
|
||||
@@ -131,6 +133,7 @@ export function createHookListenerState(
|
||||
warnedEnvs: new Set(),
|
||||
lastPromptByPaneKey: new Map(),
|
||||
lastToolByPaneKey: new Map(),
|
||||
eventReplyCandidate: undefined,
|
||||
lastStatusByPaneKey: adapter.view,
|
||||
antigravityCompletedTranscriptByPaneKey: new Map(),
|
||||
antigravitySubagentParentByPaneKey: new Map(),
|
||||
|
||||
@@ -302,6 +302,7 @@ export function normalizeHookPayload(
|
||||
...(statusAdmissionSession ? { statusAdmissionSession } : {}),
|
||||
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
|
||||
...dispatched.hostTurnSignals,
|
||||
...(dispatched.replyCandidate ? { replyCandidate: dispatched.replyCandidate } : {}),
|
||||
payload: transportPayload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHookListenerState } from '../listener-state'
|
||||
import { normalizeAndAccept } from './agent-hook-listener-test-harness'
|
||||
|
||||
describe('hook reply candidate', () => {
|
||||
it('offers only the reply text the event itself carried, never the cached one', () => {
|
||||
const state = createHookListenerState()
|
||||
normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
|
||||
|
||||
const stop = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'the answer'
|
||||
})
|
||||
const next = normalizeAndAccept(state, 'claude', {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/a' }
|
||||
})
|
||||
|
||||
expect(stop?.replyCandidate).toBe('the answer')
|
||||
expect(next?.payload.lastAssistantMessage).toBe('the answer')
|
||||
expect(next?.replyCandidate).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -153,5 +153,10 @@ export function resolveToolState(
|
||||
: update.lastAssistantMessageIsToolOutput
|
||||
}
|
||||
state.lastToolByPaneKey.set(paneKey, merged)
|
||||
// Only text this event carried can answer its turn; cached and tool-output text never can.
|
||||
state.eventReplyCandidate =
|
||||
update.lastAssistantMessageIsToolOutput || update.clearLastAssistantMessage
|
||||
? undefined
|
||||
: update.lastAssistantMessage
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export type ProviderDispatchResult = {
|
||||
/** The event the payload was normalized as, which a provider may restate (Codex ended turns). */
|
||||
eventName: unknown
|
||||
hostTurnSignals: Pick<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'>
|
||||
/** Full reply text this hook carried, outside the cached tool snapshot; refreshes carry none. */
|
||||
replyCandidate: string | undefined
|
||||
}
|
||||
|
||||
/** Exhaustive provider routing with provider-specific transcript locality and attribution. */
|
||||
@@ -73,6 +75,7 @@ export function normalizeProviderEvent(input: {
|
||||
let promptInteractionKey: string | undefined
|
||||
let hasTranscriptPromptEvidence = false
|
||||
let payload: ParsedAgentStatusPayload | null
|
||||
state.eventReplyCandidate = undefined
|
||||
|
||||
switch (source) {
|
||||
case 'codebuddy':
|
||||
@@ -229,6 +232,8 @@ export function normalizeProviderEvent(input: {
|
||||
case 'kiro':
|
||||
payload = normalizeKiroEvent(state, eventName, promptText, paneKey, hookPayload)
|
||||
}
|
||||
const replyCandidate = state.eventReplyCandidate
|
||||
state.eventReplyCandidate = undefined
|
||||
|
||||
return {
|
||||
payload,
|
||||
@@ -236,6 +241,7 @@ export function normalizeProviderEvent(input: {
|
||||
promptInteractionKey,
|
||||
hasTranscriptPromptEvidence,
|
||||
eventName,
|
||||
hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey)
|
||||
hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey),
|
||||
replyCandidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export function mainAgentState(row: {
|
||||
return row.mainAgent?.state ?? row.state
|
||||
}
|
||||
|
||||
/** When the main agent's current done state began, naming the turn it finished; undefined unless done. */
|
||||
export function mainAgentDoneAt(row: {
|
||||
state: AgentStatusState
|
||||
stateStartedAt: number
|
||||
mainAgent?: AgentMainAgentStatus
|
||||
}): number | undefined {
|
||||
const lead = row.mainAgent ?? row
|
||||
return lead.state === 'done' ? lead.stateStartedAt : undefined
|
||||
}
|
||||
|
||||
export function mainAgentStatusEqual(
|
||||
a: AgentMainAgentStatus | undefined,
|
||||
b: AgentMainAgentStatus | undefined
|
||||
|
||||
Reference in New Issue
Block a user