fix(notifications): announce finished only when the agent is fully done (#36992)

This commit is contained in:
Kelvin Amoaba
2026-11-01 16:03:29 +00:00
committed by GitHub
parent 9ce650839c
commit d95e2324dc
37 changed files with 891 additions and 211 deletions
@@ -100,8 +100,8 @@ describe('Persisted Claude lead boundaries', () => {
lastAssistantMessage: 'Lead finished.',
subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })]
})
const turnCompletedAt = firstServer.getStatusSnapshot()[0]?.turnCompletedAt
expect(turnCompletedAt).toEqual(expect.any(Number))
// A subagent holds the lead's finish, so its Stop is not stamped for announcement.
expect(firstServer.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined()
expect(firstServer._getStateForTests().lastStatusByPaneKey.get(PANE)?.hookEventName).toBe(
'Stop'
)
@@ -121,7 +121,7 @@ describe('Persisted Claude lead boundaries', () => {
agentType: 'claude',
prompt: 'finish after child',
lastAssistantMessage: 'Lead finished.',
turnCompletedAt
mainAgent: expect.objectContaining({ state: 'done' })
})
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
@@ -166,7 +166,7 @@ describe('Persisted Claude lead boundaries', () => {
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
agentType: 'claude',
turnCompletedAt: expect.any(Number)
mainAgent: expect.objectContaining({ state: 'done' })
})
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
} finally {
@@ -0,0 +1,30 @@
import { BoundedMap } from '../../shared/bounded-map'
import { runsOrcaOrchestrationWait } from '../../shared/orca-cli-invocations'
import {
claudeRecord,
claudeToolUses,
readClaudeMessageEnvelope
} from './structured/journal/claude-structured-item-translation'
/** Bash calls that block on `orca orchestration check --wait`, by tool_use_id, until their background task starts. */
export class ClaudeAgentWaitCalls {
// A foreground wait never starts a task (or is moved to the background later), so bound what waits for one.
private readonly pending = new BoundedMap<string, true>({ maxEntries: 256 })
observe(message: Record<string, unknown>): void {
const envelope = readClaudeMessageEnvelope(message)
for (const tool of envelope ? claudeToolUses(envelope) : []) {
if (runsOrcaOrchestrationWait(claudeRecord(tool.input)?.command)) {
this.pending.set(tool.id, true)
}
}
}
take(toolUseId: string | undefined): boolean {
return toolUseId !== undefined && this.pending.delete(toolUseId)
}
clear(): void {
this.pending.clear()
}
}
@@ -100,6 +100,15 @@ describe('a background Stop acts on the host child records', () => {
task_id: 'agent-1',
patch: { status: 'completed' }
}
},
{
at: 7_000,
frame: {
type: 'system',
subtype: 'task_notification',
task_id: 'agent-1',
status: 'completed'
}
}
])
@@ -1,6 +1,7 @@
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-turn-scope'
import { isSettledBackgroundTaskState } from '../../shared/native-chat/input/native-chat-background-task-row'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { BoundedMap } from '../../shared/bounded-map'
import { record, taskAliasId } from './claude-background-task-frames'
import {
claudeBackgroundTaskPatchChange,
@@ -79,11 +80,29 @@ export class ClaudeBackgroundTaskRows {
return { ...this.ledgers.sizes, overflowTerminalRows: this.overflowTerminalRows.size }
}
/** Notification frames that already opened a wake turn; a redelivered one must not open another. */
private readonly wakeFrames = new BoundedMap<string, true>({ maxEntries: 64 })
/** Null outside observe so teardown writes cannot reopen a turn. */
private journaling: { frame: Record<string, unknown>; observedAt: number } | null = null
observe(message: Record<string, unknown>, observedAt: number = this.now()): boolean {
this.journaling = { frame: message, observedAt }
// Claude wakes the lead for any task the lead launched, rowless subagents included; open that turn at the notification, once per frame.
const toolUseId =
message.subtype === 'task_notification' ? taskAliasId(message.tool_use_id) : undefined
const frameId = typeof message.uuid === 'string' ? message.uuid : undefined
if (
message.type === 'system' &&
toolUseId &&
this.deps.isForwardedParentTool(toolUseId) &&
!(frameId && this.wakeFrames.has(frameId))
) {
if (frameId) {
this.wakeFrames.set(frameId, true)
}
this.deps.openOutputTurn?.(message, observedAt)
}
try {
return this.observeFrame(message)
} finally {
@@ -53,6 +53,48 @@ describe('Claude child-work decoder', () => {
expect(decoder.drain(600)).toEqual([])
})
it('marks a background orca orchestration check --wait as the worker wait it is', () => {
const decoder = new ClaudeChildWorkDecoder()
decoder.observe({
type: 'assistant',
session_id: 'provider-1',
uuid: 'assistant-1',
parent_tool_use_id: null,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'toolu_wait',
name: 'Bash',
input: {
command: `ORCA_RUN=${'x'.repeat(200)} orca orchestration check --wait --json`,
run_in_background: true
}
}
]
}
})
decoder.observe(
system('task_started', {
task_id: 'wait-1',
tool_use_id: 'toolu_wait',
task_type: 'local_bash',
description: 'Wait for next worker message',
is_backgrounded: true
})
)
expect(decoder.drain(500)).toMatchObject([
{
type: 'live',
child: {
kind: 'command',
operation: { toolName: 'Bash', input: 'orca orchestration check --wait' }
}
}
])
})
it("carries a child's progress: the tool it last ran, its summary and its usage", () => {
const decoder = decoderWith(foregroundAgent)
decoder.observe(
@@ -117,6 +159,16 @@ describe('Claude child-work decoder', () => {
])
})
it('ends a background agent at the notification that wakes its launcher, not the update before it', () => {
for (const status of ['completed', 'failed', 'killed']) {
const decoder = decoderWith(backgroundAgent)
decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { status } }))
expect(decoder.drain(500)).toEqual([])
decoder.observe(system('task_notification', { task_id: 'agent-bg', status }))
expect(decoder.drain(600)).toEqual([expect.objectContaining({ type: 'ended' })])
}
})
it("reads nothing from a roster, a turn's end or a spawn call's result", () => {
const decoder = decoderWith(foregroundAgent, backgroundAgent)
decoder.observe(system('background_tasks_changed', { tasks: [] }))
+75 -18
View File
@@ -22,6 +22,8 @@ import {
terminalClaudeTaskRunState
} from './claude-background-task-frames'
import { isClaudeSubagentTask } from './claude-subagent-task-frames'
import { ClaudeAgentWaitCalls } from './claude-agent-wait-calls'
import { ORCA_ORCHESTRATION_WAIT_COMMAND } from '../../shared/orca-cli-invocations'
import {
claudeChildWorkOutcome,
claudeTaskProgressFacts,
@@ -36,8 +38,12 @@ type DecodedClaudeTask = {
name?: string
description?: string
toolUseId?: string
/** Runs `orca orchestration check --wait`: waiting on worker agents, so agent work. */
awaitsAgents?: true
}
type ClaudeToolOwner = (toolUseId: string) => string | null
type PendingEdge = (observedAt: number) => AgentChildWorkEvidence
function observation(
@@ -49,7 +55,14 @@ function observation(
): AgentChildWorkLiveObservation {
const operation: AgentChildWorkOperation | undefined = facts.toolName
? { toolName: facts.toolName, basis: 'reported', observedAt }
: undefined
: task.awaitsAgents
? {
toolName: 'Bash',
input: ORCA_ORCHESTRATION_WAIT_COMMAND,
basis: 'reported',
observedAt
}
: undefined
return {
handle: {
idKind: 'task_id',
@@ -74,12 +87,21 @@ export class ClaudeChildWorkDecoder {
/** Retain ended runs for the session to distinguish wakes from late starts. */
private readonly ended = new Map<
string,
{ run: string | undefined; outcome: AgentChildWorkOutcome }
{
run: string | undefined
outcome: AgentChildWorkOutcome
task?: DecodedClaudeTask
}
>()
private waiting: ReadonlySet<string> = new Set()
private pending: PendingEdge[] = []
observe(message: Record<string, unknown>): void {
private readonly agentWaits = new ClaudeAgentWaitCalls()
/** `ownerOf` names the subagent that made a child tool call; an ending task wakes it. */
observe(message: Record<string, unknown>, ownerOf?: ClaudeToolOwner): void {
if (message.type === 'assistant') {
this.agentWaits.observe(message)
return
}
if (message.type !== 'system') {
return
}
@@ -95,14 +117,19 @@ export class ClaudeChildWorkDecoder {
this.progressed(id, message)
return
case 'task_updated':
this.updated(id, record(message.patch))
this.updated(id, record(message.patch), ownerOf)
return
case 'task_notification':
this.end(id, message.status, {
runId: taskAliasId(message.tool_use_id),
lastMessage: taskText(message.summary),
totalTokens: taskUsageTotalTokens(message)
})
this.end(
id,
message.status,
{
runId: taskAliasId(message.tool_use_id),
lastMessage: taskText(message.summary),
totalTokens: taskUsageTotalTokens(message)
},
ownerOf
)
}
}
@@ -120,6 +147,7 @@ export class ClaudeChildWorkDecoder {
clear(): void {
this.live.clear()
this.ended.clear()
this.agentWaits.clear()
this.waiting = new Set()
this.pending.push((observedAt) => ({ type: 'session-ended', observedAt }))
}
@@ -131,10 +159,11 @@ export class ClaudeChildWorkDecoder {
}
private started(id: string, message: Record<string, unknown>): void {
const toolUseId = taskAliasId(message.tool_use_id)
const awaitsAgents = this.agentWaits.take(toolUseId)
if (message.ambient === true || message.skip_transcript === true) {
return
}
const toolUseId = taskAliasId(message.tool_use_id)
const existing = this.live.get(id)
const ended = existing ? undefined : this.ended.get(id)
const restart = ended !== undefined
@@ -155,13 +184,23 @@ export class ClaudeChildWorkDecoder {
running: liveClaudeTaskRunState(message.status) !== null || existing?.running === true,
name: taskName(message) ?? existing?.name,
description: taskDescription(message.description) ?? existing?.description,
toolUseId: toolUseId ?? existing?.toolUseId
toolUseId: toolUseId ?? existing?.toolUseId,
...(awaitsAgents || existing?.awaitsAgents ? { awaitsAgents: true as const } : {})
},
{},
restart
)
}
/** Claude resumes a finished subagent when a task it launched notifies; show it working from that frame, not its restart a moment later. */
private wakeOwner(toolUseId: string | undefined, ownerOf: ClaudeToolOwner | undefined): void {
const ownerId = toolUseId ? ownerOf?.(toolUseId) : null
const owner = ownerId ? this.ended.get(ownerId) : undefined
if (ownerId && owner?.outcome === 'succeeded' && owner.task?.kind === 'agent') {
this.upsert(ownerId, { ...owner.task, running: true }, {}, true)
}
}
private progressed(id: string, message: Record<string, unknown>): void {
const task = this.live.get(id)
if (!task) {
@@ -175,15 +214,22 @@ export class ClaudeChildWorkDecoder {
)
}
private updated(id: string, patch: Record<string, unknown> | null): void {
private updated(
id: string,
patch: Record<string, unknown> | null,
ownerOf: ClaudeToolOwner | undefined
): void {
if (!patch) {
return
}
const existing = this.live.get(id)
if (terminalClaudeTaskRunState(patch.status) !== null) {
this.end(id, patch.status, { lastMessage: taskText(patch.error) })
// A background agent's ending always notifies its launcher at once; ending here would show the launcher finished before that wake.
if (existing?.backgrounded !== true || existing.kind !== 'agent') {
this.end(id, patch.status, { lastMessage: taskText(patch.error) }, ownerOf)
}
return
}
const existing = this.live.get(id)
// Late updates cannot reopen an ended task.
if (!existing && this.ended.has(id)) {
return
@@ -204,7 +250,8 @@ export class ClaudeChildWorkDecoder {
running: running || existing?.running === true,
name: name ?? existing?.name,
description: description ?? existing?.description,
toolUseId: existing?.toolUseId
toolUseId: existing?.toolUseId,
...(existing?.awaitsAgents ? { awaitsAgents: true as const } : {})
},
{},
false
@@ -239,12 +286,22 @@ export class ClaudeChildWorkDecoder {
runId?: string
lastMessage?: string
totalTokens?: number
}
},
ownerOf?: ClaudeToolOwner
): void {
const task = this.live.get(id)
this.live.delete(id)
const outcome = claudeChildWorkOutcome(status)
this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome })
this.ended.set(id, {
run: reported.runId ?? task?.toolUseId,
outcome,
// Kept only for an agent a task it launched can wake.
...(task?.kind === 'agent' ? { task } : {})
})
// Every ending but a stop notifies, which wakes the subagent that launched the task.
if (outcome !== 'cancelled') {
this.wakeOwner(reported.runId ?? task?.toolUseId, ownerOf)
}
this.pending.push((observedAt) => ({
type: 'ended',
observedAt,
@@ -26,7 +26,7 @@ export function emitClaudeStructuredSessionEvent({
session?.childWork.clear()
session?.backgroundTasks.clear()
} else if (event.type === 'message') {
session?.childWork.observe(event.message)
session?.childWork.observe(event.message, session.translator?.childToolOwner)
session?.backgroundTasks.observe(event.message, event.startsTurn === true)
} else if (event.type === 'prompt-cancelled') {
// Clear waiting before journaling cancellation so status cannot wait on a closed card.
@@ -98,7 +98,8 @@ export class ConversationEvents {
deliver: (item) => this.told(item),
setTimer: (run, delayMs) => this.deps.setTimer(run, delayMs),
started: (item) => this.started(item),
stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row))
stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row)),
everyLeadTurn: true
})
const unsubscribe = this.deps.onAgentPaneChange((change) => {
this.settleRejected(change.paneKey)
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload'
import type { AgentPhase, AgentTransition } from '../../shared/agent-transitions'
import {
AGENT_DONE_QUIET_MS,
type AgentPhase,
type AgentTransition
} from '../../shared/agent-transitions'
import type { ConversationEvent, ConversationSnapshot } from './external-conversation-events'
import type { ConversationKey } from './external-conversation-types'
import {
@@ -223,6 +227,56 @@ describe('ExternalConversationSeam watch', () => {
})
})
it('tells the reply of a turn its subagent holds open, then the wake-up turn that follows', async () => {
const { seam, host, hookRows, paneChange } = await linked()
const seen: ConversationEvent[] = []
seam.watch(host, (event) => {
if (event.kind !== 'snapshot') {
seen.push(event)
}
})
const observedWorking: AgentPaneChange = {
kind: 'observed',
paneKey: PANE,
workspace: { worktreeId: WORKTREE, sshHostId: null },
phase: 'working',
at: 2
}
hookRows.set(PANE, hookRow({ turnStartedAt: 100 }))
paneChange(moved('rest', 'working', 'prompt-submitted'))
paneChange(observedWorking)
// The lead's turn ended; its running subagent keeps the row working and holds the notification.
const launched = { state: 'done' as const, stateStartedAt: 300, outcome: 'success' as const }
hookRows.set(
PANE,
hookRow({ turnStartedAt: 100, lastAssistantMessage: 'Launched.', mainAgent: launched })
)
paneChange(observedWorking)
hookRows.set(PANE, hookRow({ turnStartedAt: 400 }))
paneChange(observedWorking)
hookRows.set(
PANE,
hookRow({
state: 'done',
stateStartedAt: 500,
turnStartedAt: 400,
lastAssistantMessage: 'Subagent done.',
mainAgent: { state: 'done', stateStartedAt: 500, outcome: 'success' }
})
)
vi.useFakeTimers()
try {
paneChange(moved('working', 'done', 'turn-finished'))
vi.advanceTimersByTime(AGENT_DONE_QUIET_MS)
} finally {
vi.useRealTimers()
}
expect(
seen.flatMap((event) => (event.kind === 'turn-ended' ? [event.lastAssistantMessage] : []))
).toEqual(['Launched.', 'Subagent done.'])
})
it('names the turn a reply belongs to as turn events do, only while watched and linked', async () => {
const { seam, host, hookRows, paneChange } = await linked()
expect(seam.replyTarget(PANE)).toBeNull()
@@ -10,6 +10,11 @@ import { isSubagentBlock } from '../../../shared/native-chat/native-chat-types'
import { activeStructuredAgentSessionTurnId } from '../../../shared/agent-session/view/structured-agent-session-live-turn'
import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support'
import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture'
import {
result,
says,
type CapturedFrame
} from '../../claude/claude-captured-frame-builders.test-fixture'
import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture'
import { ClaudeStructuredSessionAdapter } from '../../claude/structured/claude-structured-session-adapter'
import {
@@ -213,6 +218,42 @@ it("keeps an agent whose helper the store refused past idle cleanup, until the s
await eventually(() => expect(strip()?.incomplete).toBeUndefined())
})
it('announces a helper that outlives the turn once, after the lead answers its final result', async () => {
const connection = claude.connections[0]!
const completions: string[] = []
host.subscribeTurnCompletions({
id: 'completions',
emit: (event) => {
if (event.type === 'completion') {
completions.push(event.completion.turnId)
}
}
})
await sendAndEcho(connection, 'Sleep in the background.')
const start = clock
// The capture omits the lead's replies: Claude wakes it after each of the helper's notifications.
const leadWake = (at: number, uuid: string): CapturedFrame[] => [
{ at, frame: says(uuid, 'Noted.', null) },
{ at: at + 1, frame: result('success') }
]
const frames: CapturedFrame[] = WAKES_AFTER_ITS_SHELL.flatMap((captured, index) =>
captured.frame.subtype === 'task_notification' && captured.frame.task_id === 'agent-1'
? [captured, ...leadWake(captured.at + 1_000, `lead-wake-${index}`)]
: [captured]
)
for (const [index, captured] of frames.entries()) {
clock = start + captured.at
frame(connection, { uuid: `frame-${index}`, ...captured.frame })
await host.flushStreamedEvents(SESSION)
}
// The first turn and the wake while the helper's own shell runs stay quiet; the wake its final notification opens announces.
const finalNotification = frames.findLastIndex(
({ frame }) => frame.subtype === 'task_notification'
)
await eventually(() => expect(completions).toEqual([`frame-${finalNotification}`]))
})
it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => {
const connection = claude.connections[0]!
await sendAndEcho(connection, 'Sleep in the background.')
@@ -246,7 +287,8 @@ it('keeps a helper working while its own shell runs and through its wake-up, end
state: 'completed',
tokens: 18_181,
startedAt: start + 15,
settledAt: start + 46_198
// Settled by its notification, which wakes the lead, not the update a millisecond before.
settledAt: start + 46_199
})
)
expect(strip()).toEqual([])
@@ -78,7 +78,8 @@ export class StructuredAgentSessionClientDelivery {
sessions,
now,
readStatusState: (sessionId, journal) =>
this.statusFeed.journalProjection(sessionId, journal)?.state ?? null
this.statusFeed.journalProjection(sessionId, journal)?.state ?? null,
readChildWork: (sessionId) => this.statusFeed.readChildWork(sessionId)
})
this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) =>
this.settlementReading(sessionId, this.requireJournal(sessionId))
@@ -173,7 +174,11 @@ export class StructuredAgentSessionClientDelivery {
sessionId: string,
evidence: AgentChildWorkEvidence[],
producer?: StructuredAgentSessionChildWorkProducer
): void => this.statusFeed.publishChildWork(sessionId, evidence, producer)
): void => {
this.statusFeed.publishChildWork(sessionId, evidence, producer)
// A drain can be the moment a held turn becomes finished.
this.turnCompletionFeed.observe(sessionId)
}
readStopping = (sessionId: string): boolean => {
const projection = this.statusFeed.journalProjection(sessionId)
@@ -92,7 +92,9 @@ async function sessionWithRunningTurn() {
const completions = new StructuredAgentSessionTurnCompletionFeed({
sessions,
now: () => clock,
readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null
readStatusState: (sessionId, source) =>
feed.journalProjection(sessionId, source)?.state ?? null,
readChildWork: (sessionId) => feed.readChildWork(sessionId)
})
const completionEvents: AgentSessionTurnCompletionEvent[] = []
completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) })
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import type {
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnLifecycle
} from '../../../shared/agent-session/journal/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key'
import type { AgentChildWorkLivenessCandidate } from '../../../shared/agent-status-child-work-liveness'
import { projectStructuredAgentSessionStatusState } from '../../../shared/agent-session/view/structured-agent-session-projection'
import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed'
const LOCATION = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
} as const
const SUBAGENT = { kind: 'agent', state: 'working' } as const
const DEV_SERVER = { kind: 'command', state: 'working' } as const
function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence = 2): AgentJournalRenderItem {
return {
itemId: `codex:turn:${lifecycle.turnId}`,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'turn', ...lifecycle }
}
}
// A turn the agent opened itself keys its lifecycle row to itself.
function selfStarted(lifecycle: AgentJournalTurnLifecycle): AgentJournalTurnLifecycle {
return { ...lifecycle, userItemId: `codex:turn:${lifecycle.turnId}` }
}
function harness(childWork: AgentChildWorkLivenessCandidate[]) {
let items: AgentJournalRenderItem[] = []
let submissions: AgentJournalSubmission[] = []
let sequence = 0
const journal = { cursor: () => ({ epoch: 'epoch-1', sequence }) }
const feed = new StructuredAgentSessionTurnCompletionFeed({
sessions: new Map([['session-1', { journal, params: { location: LOCATION } }]]),
now: () => 1_700,
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions),
readChildWork: () => childWork
})
const outcomes: [string, string][] = []
feed.subscribe({
id: 'sub',
emit: (event) => {
if (event.type === 'completion') {
outcomes.push([event.completion.turnId, event.completion.outcome])
}
}
})
const commit = (next: AgentJournalRenderItem[], nextSubmissions: AgentJournalSubmission[]) => {
items = next
submissions = nextSubmissions
sequence += 1
feed.observe('session-1')
}
return {
outcomes,
feed,
commit,
settle: (lifecycle: AgentJournalTurnLifecycle) => {
commit([turnItem({ ...lifecycle, state: 'running', outcome: undefined })], [])
commit([turnItem(lifecycle)], [])
}
}
}
describe('a turn that settles while its child work is still live', () => {
it('holds the finish until the last subagent drains, then announces that turn once', () => {
const childWork: AgentChildWorkLivenessCandidate[] = [SUBAGENT]
const h = harness(childWork)
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
expect(h.outcomes).toEqual([])
childWork[0] = { ...SUBAGENT, state: 'done' }
h.feed.observe('session-1')
h.feed.observe('session-1')
expect(h.outcomes).toEqual([['turn-1', 'success']])
})
it('holds a coordinator waiting on workers through a background orca orchestration check --wait', () => {
const h = harness([{ ...DEV_SERVER, operation: { input: 'orca orchestration check --wait' } }])
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
expect(h.outcomes).toEqual([])
})
it('does not hold a failed turn', () => {
const h = harness([SUBAGENT])
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'failure' })
expect(h.outcomes).toEqual([['turn-1', 'failure']])
})
it('lets a dev server hold only a turn the agent started itself, with nothing steered into it', () => {
const user = harness([DEV_SERVER])
user.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
expect(user.outcomes).toEqual([['turn-1', 'success']])
const woken = harness([DEV_SERVER])
woken.settle(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' }))
expect(woken.outcomes).toEqual([])
const steered = harness([DEV_SERVER])
const steer: AgentJournalSubmission = {
clientMessageId: 'm1',
fence: 1,
payloadFingerprint: 'm1',
providerItemId: null,
reason: null,
submittedAt: 10,
resolvedAt: 20,
handoverRecorded: true,
dispatchState: 'accepted',
answeredInTurn: { turnItemId: 'codex:turn:wake-1', via: 'steer' }
}
const message: AgentJournalRenderItem = {
itemId: agentJournalSubmissionKey('m1'),
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'm1' }] }
}
steered.commit(
[message, turnItem(selfStarted({ turnId: 'wake-1', state: 'running' }))],
[steer]
)
steered.commit(
[
message,
turnItem(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' }))
],
[steer]
)
expect(steered.outcomes).toEqual([['wake-1', 'success']])
})
})
@@ -100,7 +100,8 @@ function harness(): {
const feed = new StructuredAgentSessionTurnCompletionFeed({
sessions,
now: () => 1_700,
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence)
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence),
readChildWork: () => []
})
const events: AgentSessionTurnCompletionEvent[] = []
return {
@@ -10,6 +10,14 @@ import type {
AgentSessionAttentionEdge,
StructuredAttentionState
} from '../../../shared/agent-session/agent-session-attention'
import {
foldAgentLeadStatus,
heldTurnCountsAsFinished
} from '../../../shared/agent-lead-status-fold'
import {
agentChildWorkLiveness,
type AgentChildWorkLivenessCandidate
} from '../../../shared/agent-status-child-work-liveness'
import type { StructuredAgentSessionLatestRequest } from '../../../shared/agent-session/requests/structured-agent-session-latest-request'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed'
@@ -38,6 +46,7 @@ export type StructuredAgentSessionTurnCompletionFeedDeps = {
sessionId: string,
journal?: AgentSessionJournal
) => StructuredAgentSessionStatusState | null
readChildWork: (sessionId: string) => readonly AgentChildWorkLivenessCandidate[] | undefined
}
type RequestMark = Pick<StructuredAgentSessionLatestRequest, 'kind' | 'id'>
@@ -173,7 +182,8 @@ export class StructuredAgentSessionTurnCompletionFeed {
if (
state.owesWork ||
!request ||
(baseline.settled?.kind === request.kind && baseline.settled.id === request.id)
(baseline.settled?.kind === request.kind && baseline.settled.id === request.id) ||
this.heldByLiveWork(sessionId, state, request)
) {
return null
}
@@ -193,6 +203,25 @@ export class StructuredAgentSessionTurnCompletionFeed {
}
}
/** A successful turn is finished only when the combined row would be done; a held request stays unaccounted so a later observe can announce it. */
private heldByLiveWork(
sessionId: string,
state: StructuredAgentSessionStatusState,
request: StructuredAgentSessionLatestRequest
): boolean {
if (request.outcome !== 'success' || state.summary.status === 'attention') {
return false
}
const held = foldAgentLeadStatus({
leadState: 'done',
childWorkLiveness: agentChildWorkLiveness(this.deps.readChildWork(sessionId))
})
return (
held.stateName !== 'done' &&
!heldTurnCountsAsFinished(held, request.outcome, request.selfStarted === true)
)
}
private broadcast(
event: AgentSessionAttentionEdge,
audience: 'all' | 'legacy' | 'prompt-aware'
@@ -114,6 +114,7 @@ describe('host prompt delivery and retirement', () => {
]
]),
readStatusState: () => projectStructuredAgentSessionStatusState(items),
readChildWork: () => undefined,
now: () => 42
})
feed.subscribe({ id: 'legacy', emit: (event) => legacy.push(event) })
+21 -5
View File
@@ -1,5 +1,6 @@
// Share desktop completion/input timing and hook rows; phone/connector listeners choose wording and transport.
import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector'
import { mainAgentTurnInterrupted } from '../../shared/agent-lead-status-fold'
import { agentTurnEndedOnPurpose } from '../../shared/agent-main-agent-verdict'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload'
import { AGENT_DONE_QUIET_MS } from '../../shared/agent-transitions'
@@ -23,6 +24,8 @@ export type TerminalAgentTurnNewsDeps<Prepared> = {
stopped?: (news: TerminalAgentTurnNewsItem) => void
/** Opt-in: a finish is news only once the pane's children settle too, not when its lead agent does. */
allClearOnly?: true
/** Opt-in: every turn the lead agent ends is news, even one the host holds back from notifications while children run. */
everyLeadTurn?: true
}
type PendingFinish = {
@@ -60,10 +63,11 @@ export class TerminalAgentTurnNews<Prepared> {
const finished = transition === 'turn-finished' || transition === 'errored'
const row = finished || transition === 'needs-input' ? this.deps.readHookRow(paneKey) : null
// The all-clear of a turn announced when its lead agent finished: that finish still stands.
const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined
if (
finished &&
row?.turnCompletedAt !== undefined &&
this.finishedTurnByPane.get(paneKey) === row.turnCompletedAt
leadTurnEndedAt !== undefined &&
this.finishedTurnByPane.get(paneKey) === leadTurnEndedAt
) {
return
}
@@ -116,7 +120,7 @@ export class TerminalAgentTurnNews<Prepared> {
if (!row || row.state !== 'working' || !worktreeId) {
return
}
const { turnCompletedAt } = row
const turnCompletedAt = this.leadTurnEndedAt(row)
if (turnCompletedAt === undefined || this.deps.allClearOnly) {
this.workingSeen.add(paneKey)
return
@@ -140,14 +144,26 @@ export class TerminalAgentTurnNews<Prepared> {
private seed(paneKey: string): void {
const row = this.deps.readHookRow(paneKey)
// An all-clear listener never heard the lead's finish, so its stamp announces nothing.
if (row?.turnCompletedAt !== undefined && !this.deps.allClearOnly) {
this.finishedTurnByPane.set(paneKey, row.turnCompletedAt)
const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined
if (leadTurnEndedAt !== undefined && !this.deps.allClearOnly) {
this.finishedTurnByPane.set(paneKey, leadTurnEndedAt)
}
if (row?.state === 'working') {
this.workingSeen.add(paneKey)
}
}
/** The host's announce stamp; for every-lead-turn listeners, else the end of a lead turn its children still hold open. */
private leadTurnEndedAt(row: AgentStatusIpcPayload): number | undefined {
if (row.turnCompletedAt !== undefined || !this.deps.everyLeadTurn) {
return row.turnCompletedAt
}
const lead = row.mainAgent
return lead?.state === 'done' && !mainAgentTurnInterrupted(lead)
? lead.stateStartedAt
: undefined
}
private announce(
paneKey: string,
worktreeId: string,
@@ -155,41 +155,90 @@ describe('Claude background-turn completion notifications', () => {
return { banners, rows }
}
it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => {
const { banners, rows } = await play([
// Real Claude 2.1.287 PTY hooks (see the fixture's scenarios): every background-subagent run announces once, after the wake-up turn that consumes the last result.
it.each([
'one-subagent',
'two-subagents-together',
'subagent-resumed',
'subagent-with-own-shell',
'subagent-idle-ctrl-c'
])('announces the %s capture once, when everything has drained', async (scenario) => {
const records: {
scenario: string
t: number
kind: string
payload?: Record<string, unknown>
}[] = (
await readFile(
new URL(
'../../../shared/__fixtures__/claude-task-notification-hooks.jsonl',
import.meta.url
),
'utf8'
)
)
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
const events = records.flatMap((record) =>
record.scenario === scenario && record.kind === 'hook' && record.payload
? [{ at: 1_700_000_000_000 + Math.round(record.t * 1000), payload: record.payload }]
: []
)
const lastStop = events.findLast((event) => event.payload.hook_event_name === 'Stop')
const { banners } = await play(events)
// A plain done waits out the quiet window before it is announced.
expect(banners.map((banner) => banner.at)).toEqual([(lastStop?.at ?? 0) + HOOK_DONE_QUIET_MS])
})
it('announces a coordinator waiting on workers in the background once, at its final turn', async () => {
const wait = 'orca orchestration check --wait --types worker_done,escalation --json'
const waitFor = (id: string, at: number) => [
{
at: 1_700_000_000_000,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }
},
{
at: 1_700_000_001_000,
at,
payload: {
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: wait, run_in_background: true },
tool_response: { backgroundTaskId: id }
}
},
{
at: 1_700_000_006_000,
at: at + 1_000,
payload: {
hook_event_name: 'Stop',
last_assistant_message: 'Which cells need hand-verification?',
background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }]
last_assistant_message: 'Waiting for workers.',
background_tasks: [{ id, type: 'shell', status: 'running', command: wait }]
}
},
}
]
const wake = (id: string, at: number) => ({
at,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: `<task-notification>\n<task-id>${id}</task-id>\n<status>completed</status>\n</task-notification>`
}
})
const { banners } = await play([
{ at: 1_700_000_000_000, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'run it' } },
...waitFor('w1', 1_700_000_001_000),
wake('w1', 1_700_000_060_000),
...waitFor('w2', 1_700_000_061_000),
wake('w2', 1_700_000_120_000),
{
at: 1_700_000_055_000,
payload: { hook_event_name: 'SubagentStop', agent_id: 'a1' }
at: 1_700_000_121_000,
payload: {
hook_event_name: 'Stop',
last_assistant_message: 'All workers done.',
background_tasks: []
}
}
])
const stop = rows.find((row) => row.lastAssistantMessage?.includes('hand-verification'))
expect(stop?.state).toBe('working')
expect(stop?.turnCompletedAt).toBe(1_700_000_006_000)
expect(banners).toHaveLength(1)
expect(banners[0]?.at).toBe(1_700_000_006_000)
expect(banners[0]?.body).toBe('Which cells need hand-verification?')
expect(banners[0]?.stateStartedAt).toBe(1_700_000_006_000)
expect(banners).toEqual([expect.objectContaining({ body: 'All workers done.' })])
})
it('notifies each background-shell turn with a distinct notification identity', async () => {
@@ -234,7 +283,7 @@ describe('Claude background-turn completion notifications', () => {
expect(banners[0]?.stateStartedAt).not.toBe(banners[1]?.stateStartedAt)
})
it('applies the provider retirement to visible rows and quit capture without announcing the parent twice', async () => {
it('applies the provider retirement to visible rows and quit capture, announcing the parent once it is done', async () => {
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
actualStore = createTestStore()
@@ -321,14 +370,14 @@ describe('Claude background-turn completion notifications', () => {
}
const before = store.getState().agentStatusByPaneKey[PANE_KEY]!
const beforeEvent = current!
expect(beforeEvent.payload.turnCompletedAt).toBe(Date.parse(kill.timestamp) - 1_000)
expect(beforeEvent.payload.turnCompletedAt).toBeUndefined()
expect(before.turnEndedAt).toBeUndefined()
expect(before.turnEndDisposition).toBeUndefined()
expect(before.subagents?.[0]?.id).toBe('a123')
poller.schedule(current!)
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
await poller.poll(PANE_KEY)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
await appendFile(path, `${JSON.stringify(kill)}\n`)
await poller.poll(PANE_KEY)
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
@@ -338,7 +387,6 @@ describe('Claude background-turn completion notifications', () => {
expect(after.mainAgent).toEqual(before.mainAgent)
expect(current?.hookEventName).toBe('SubagentStop')
expect(current?.toolAgentId).toBe('a123')
expect(current?.payload.turnCompletedAt).toBe(beforeEvent.payload.turnCompletedAt)
expect(after.turnEndedAt).toBeUndefined()
expect(after.turnEndDisposition).toBeUndefined()
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
@@ -41,47 +41,6 @@ describe('shared agent-hook-listener', () => {
expect(stop?.payload.turnCompletedAt).toBeUndefined()
})
it('stamps turnCompletedAt on a gated lead Stop and repeats it on the all-clear', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(1_700_000_005_000)
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const stop = claudeEvent({
hook_event_name: 'Stop',
last_assistant_message: 'Which cells need hand-verification?',
background_tasks: [
{
id: 'a1',
type: 'subagent',
status: 'running',
description: 'Review loop',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.state).toBe('working')
expect(stop?.payload.turnCompletedAt).toBe(1_700_000_005_000)
expect(stop?.payload.lastAssistantMessage).toBe('Which cells need hand-verification?')
vi.setSystemTime(1_700_000_055_000)
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
expect(drained?.payload.state).toBe('done')
expect(drained?.payload.turnCompletedAt).toBe(1_700_000_005_000)
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'next turn' })
const nextStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(nextStop?.payload.state).toBe('done')
expect(nextStop?.payload.turnCompletedAt).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('does not stamp an interrupted Stop even while a child still gates the pane', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
claudeEvent({
@@ -717,14 +676,12 @@ describe('shared agent-hook-listener', () => {
// The lead is done; answering resumes the child and holds working only while it runs.
expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({
state: 'working',
turnCompletedAt: expect.any(Number),
// The main agent's own fact rides beside the gated state: it finished, no verdict was given.
mainAgent: { state: 'done', stateStartedAt: expect.any(Number) }
})
expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({
state: 'done',
stateStartedAt: expect.any(Number),
turnCompletedAt: expect.any(Number)
stateStartedAt: expect.any(Number)
})
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
@@ -134,14 +134,8 @@ describe('Claude owed task notifications outside the captures', () => {
// A stamped working row is announced at once; this Stop is ~100 ms from the wake-up turn.
const owedOnly = post({ hook_event_name: 'Stop', background_tasks: [] })
expect(owedOnly?.payload.state).toBe('working')
expect(owedOnly?.payload.turnCompletedAt).toBeUndefined()
launchAgent(post, 'a2')
const childRunning = post({
hook_event_name: 'Stop',
background_tasks: [{ id: 'a2', type: 'subagent', status: 'running' }]
})
expect(childRunning?.payload.turnCompletedAt).toEqual(expect.any(Number))
})
it('drops what a replaced conversation was owed', () => {
@@ -1,5 +1,8 @@
import type { ParsedAgentStatusPayload } from '../../agent-status-types'
import { isAgentStatusHeldOpenByChildWork } from '../../agent-lead-status-fold'
import {
heldTurnCountsAsFinished,
isAgentStatusHeldOpenByChildWork
} from '../../agent-lead-status-fold'
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory'
import {
@@ -281,7 +284,8 @@ export function normalizeClaudeEvent(
})
// Reuse resolved state for compact ownership instead of folding twice (#15202).
const effectiveState = resolvedStatus.stateName
// Pin held turn identity by end time for later all-clear pairing; cancelled turns earn no completion stamp.
const ownPreviousLead = previousLead?.waitingAgentId ? previousLead.stateBeforeWait : previousLead
// Stamp (announce now, pair the later all-clear) only a held turn that already counts as finished; cancelled turns earn none.
const turnCompletedAt =
eventAgentId === undefined &&
isTurnBoundary &&
@@ -290,6 +294,7 @@ export function normalizeClaudeEvent(
state: resolvedStatus.stateName,
mainAgent: { state: reportedStateName }
}) &&
heldTurnCountsAsFinished(resolvedStatus, outcome, ownPreviousLead?.taskWakeupTurn === true) &&
// Owed notifications wake the lead within ~100ms; announcing first would duplicate completion.
claudePaneHasRunningChildWork(state, paneKey)
? Date.now()
@@ -1,13 +1,16 @@
import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold'
import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness'
import { claudeLiveOwedTaskNotificationKinds } from '../../claude-owed-task-notifications'
import {
claudeLiveOwedTaskNotificationKinds,
claudeTasksAwaitAgents
} from '../../claude-owed-task-notifications'
import { claudeRosterHasWorkingSubagent } from '../../claude-subagent-roster'
import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state'
import { mainAgentIdleSince } from '../main-agent-turn-state'
/** Everything that can hold a pane whose main agent's own turn is over. */
type ClaudePaneHoldEvidence = {
/** A sub-agent the roster tracks as working. */
/** A sub-agent the roster tracks as working, or a shell waiting on worker agents. */
runningAgent: boolean
/** A running shell or a scheduled session cron. */
runningNonAgent: boolean
@@ -24,13 +27,16 @@ export function claudePaneHoldEvidence(
paneKey
)
): ClaudePaneHoldEvidence {
const launched = state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey)
const owed = claudeLiveOwedTaskNotificationKinds(
state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey),
launched,
mainAgent?.state === 'done',
mainAgentIdleSince(state.claudeLeadStateByPaneKey.get(paneKey))
)
return {
runningAgent: claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)),
runningAgent:
claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)) ||
claudeTasksAwaitAgents(launched),
runningNonAgent:
state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey),
@@ -23,7 +23,7 @@ export function trackClaudeTaskNotificationDelivery(
if (eventName === 'PostToolUse') {
const toolName = readString(hookPayload, 'tool_name')
const response = hookPayload['tool_response']
const launch = readClaudeBackgroundTaskLaunch(toolName, response)
const launch = readClaudeBackgroundTaskLaunch(toolName, response, hookPayload['tool_input'])
if (launch) {
const launched = tasks ?? new Map()
state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, launched)
@@ -57,8 +57,22 @@ export function trackClaudeTaskNotificationDelivery(
settleClaudeTaskNotification(tasks, notification.taskId)
}
return knownTask || continuesWakeupTurn
} else if (tasks && (eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) {
oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now())
} else if ((eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) {
if (inventory.runningAgentWaitIds.length > 0) {
tasks ??= new Map()
state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks)
for (const id of inventory.runningAgentWaitIds) {
recordClaudeBackgroundTaskLaunch(tasks, {
id,
kind: 'shell',
awaitsAgents: true,
listedOnly: true
})
}
}
if (tasks) {
oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now())
}
}
return false
}
+9
View File
@@ -40,6 +40,15 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS
return { stateName: 'done' }
}
/** A turn held open by live work still counts as finished when it failed, or when only monitoring holds a turn the user started; agent work holds it until the row reaches done, and a turn the agent started itself stays held by a monitor that will wake it again. */
export function heldTurnCountsAsFinished(
resolution: AgentLeadStatusResolution,
outcome: AgentMainAgentStatus['outcome'],
selfStarted: boolean
): boolean {
return outcome === 'failure' || (resolution.workingMode === 'monitoring' && !selfStarted)
}
/** Derive child-held status from lead and combined states; storing it could contradict either fact. */
export function isAgentStatusHeldOpenByChildWork(row: {
state: AgentStatusState
@@ -33,6 +33,8 @@ export type StructuredAgentSessionLatestRequest = {
outcome: AgentJournalTurnOutcome | null
/** When it settled: the turn's end, or the refusal. Undefined while it runs. */
settledAt: number | undefined
/** The agent opened this turn itself (e.g. woken by a background task) and the user steered nothing into it. */
selfStarted?: true
}
/** Commands and steers have no independent request; their turns own any response. */
@@ -78,7 +80,10 @@ export function latestStructuredAgentSessionRequest(
id: turn.turnId,
turnState: turn.state,
outcome: readAgentJournalTurnOutcome(turn),
settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn)
settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn),
...(turn.userItemId === item.itemId && !steeredInto(submissions, item.itemId)
? { selfStarted: true as const }
: {})
}
}
const submission = rejected.get(item.itemId)
@@ -100,6 +105,14 @@ export function latestStructuredAgentSessionRequest(
return null
}
/** A user message steered into a turn the agent started makes it the user's turn too. */
function steeredInto(submissions: readonly AgentJournalSubmission[], turnItemId: string): boolean {
return submissions.some(
({ answeredInTurn }) =>
answeredInTurn?.via === 'steer' && answeredInTurn.turnItemId === turnItemId
)
}
/** Exclude sends that failed nobody and never became turns; include provider-journaled user rows, even from children/history/old hosts. */
export function hasStructuredAgentSessionRequest(
items: readonly AgentJournalRenderItem[],
+12 -2
View File
@@ -1,3 +1,4 @@
import { ORCA_ORCHESTRATION_WAIT_COMMAND } from './orca-cli-invocations'
import type {
AgentChildWorkKind,
AgentChildWorkMembership,
@@ -10,6 +11,8 @@ export type AgentChildWorkLiveness = 'waiting' | 'working' | 'monitoring' | null
export type AgentChildWorkLivenessCandidate = {
kind: AgentChildWorkKind
state?: AgentChildWorkState
/** What the child runs; a producer marks a worker wait with the canonical wait command, which is agent work. */
operation?: { input?: string } | null
}
export type AgentChildWorkLivenessEvidence = {
@@ -24,6 +27,12 @@ export function isAgentChildWorkKind(kind: AgentChildWorkKind): boolean {
return kind === 'agent'
}
function isAgentChildWork(child: AgentChildWorkLivenessCandidate): boolean {
return (
isAgentChildWorkKind(child.kind) || child.operation?.input === ORCA_ORCHESTRATION_WAIT_COMMAND
)
}
/** Only explicit settlement retires work: missing states, unknown kinds and lost contact stay active. Roster lifetime bounds this fail-active policy (session close; Claude provider ended); in-place blocked failures would remain working until then. */
export function isLiveChildWork(child: AgentChildWorkLivenessCandidate): boolean {
return child.state !== 'done' && child.state !== 'idle'
@@ -57,8 +66,9 @@ export function agentChildWorkLiveness(
continue
}
hasWaitingChildWork ||= isWaitingChildWork(child)
hasLiveAgentWork ||= isAgentChildWorkKind(child.kind)
hasLiveNonAgentWork ||= !isAgentChildWorkKind(child.kind)
const agentWork = isAgentChildWork(child)
hasLiveAgentWork ||= agentWork
hasLiveNonAgentWork ||= !agentWork
}
return agentChildWorkLivenessFromEvidence({
hasWaitingChildWork,
+17 -2
View File
@@ -1,5 +1,6 @@
import { classifyClaudeBackgroundTaskKind } from './claude-background-task-kind'
import { isAgentChildWorkKind } from './agent-status-child-work-liveness'
import { runsOrcaOrchestrationWait } from './orca-cli-invocations'
const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
'idle',
@@ -41,6 +42,8 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
hasRunningNonAgentTask: boolean
/** Ids of the running shell-like entries; one without an id is counted but not listed. */
runningNonAgentTaskIds: string[]
/** Running shells blocked on `orca orchestration check --wait`, which recovers them after a listener restart. */
runningAgentWaitIds: string[]
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
@@ -49,13 +52,15 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
tasks: [],
truncated: false,
hasRunningNonAgentTask: false,
runningNonAgentTaskIds: []
runningNonAgentTaskIds: [],
runningAgentWaitIds: []
}
}
const tasks: ClaudeBackgroundAgentTask[] = []
let truncated = false
let hasRunningNonAgentTask = false
const runningNonAgentTaskIds: string[] = []
const runningAgentWaitIds: string[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
truncated = true
@@ -79,6 +84,9 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
const id = typeof obj.id === 'string' ? obj.id.trim() : ''
if (id.length > 0) {
runningNonAgentTaskIds.push(id)
if (runsOrcaOrchestrationWait(obj.command)) {
runningAgentWaitIds.push(id)
}
}
}
if (!isAgentTask) {
@@ -96,5 +104,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
teammate: taskType === 'teammate'
})
}
return { present: true, tasks, truncated, hasRunningNonAgentTask, runningNonAgentTaskIds }
return {
present: true,
tasks,
truncated,
hasRunningNonAgentTask,
runningNonAgentTaskIds,
runningAgentWaitIds
}
}
@@ -190,7 +190,7 @@ describe('Claude background task status', () => {
expect(typeof monitoring?.turnCompletedAt).toBe('number')
})
it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => {
it('holds the finish of a lead that ends into a working subagent, unless the lead failed', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
@@ -199,10 +199,79 @@ describe('Claude background task status', () => {
hook_event_name: 'Stop',
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
})
// Child work keeps the spinner, but the ended lead still needs a notification stamp.
expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined })
expect(typeof childWorking?.turnCompletedAt).toBe('number')
expect(childWorking?.turnCompletedAt).toBeUndefined()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'again' })
const failed = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'StopFailure',
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
})
expect(failed).toMatchObject({ state: 'working', workingMode: undefined })
expect(typeof failed?.turnCompletedAt).toBe('number')
})
it('holds a coordinator waiting on workers in the background, even across a listener restart', () => {
const wait = {
id: 'wait-1',
type: 'shell',
status: 'running',
description: 'Wait for next worker message',
command:
'/usr/local/bin/orca orchestration check --wait --types worker_done --json 2>/dev/null'
}
const launched = createHookListenerState()
claudeEvent(launched, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' })
claudeEvent(launched, SOURCE_PANE, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: wait.command, run_in_background: true },
tool_response: { backgroundTaskId: 'wait-1' }
})
// Only the dev server is still listed; the ended wait's notification is still owed.
const owed = claudeEvent(launched, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})
expect(owed).toMatchObject({ state: 'working', workingMode: undefined })
expect(owed?.turnCompletedAt).toBeUndefined()
const restarted = createHookListenerState()
claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' })
const listed = claudeEvent(restarted, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [wait]
})
expect(listed).toMatchObject({ state: 'working', workingMode: undefined })
expect(listed?.turnCompletedAt).toBeUndefined()
// Only listed, it may be a subagent's wait, whose result never reaches this agent.
expect(
claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] })?.state
).toBe('done')
})
it('does not stamp a wake-up turn that ends with only a monitor that will wake it again', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'watch' })
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'sleep 15', run_in_background: true },
tool_response: { backgroundTaskId: RUNNING_SHELL.id }
})
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'UserPromptSubmit',
prompt: `<task-notification>\n<task-id>${RUNNING_SHELL.id}</task-id>\n<event>tick</event>`
})
const wakeEnd = claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL]
})
expect(wakeEnd).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(wakeEnd?.turnCompletedAt).toBeUndefined()
})
it('does not stamp a turn that is still running in the foreground', () => {
+57 -9
View File
@@ -1,3 +1,6 @@
import { isRecord } from './agent-status-child-work-value-guards'
import { runsOrcaOrchestrationWait } from './orca-cli-invocations'
/** Main-agent background tasks keyed by launch/notification task ID. Notifications can arrive a turn after task end and an empty Stop; owed work keeps the pane active (#23942). Captures: __fixtures__/claude-task-notification-hooks.jsonl. */
export type ClaudeLaunchedBackgroundTasks = Map<string, ClaudeLaunchedBackgroundTask>
@@ -11,6 +14,10 @@ type ClaudeLaunchedBackgroundTask = {
settled?: true
/** A delivered notification, distinct from a lease that merely gave up waiting. */
notificationDelivered?: true
/** A shell blocked on worker agents (`orca orchestration check --wait`): agent work, not monitoring. */
awaitsAgents?: true
/** Known only from the main agent's task list (e.g. after a listener restart): a subagent may own it, so it owes the main agent nothing. */
listedOnly?: true
}
/** An idle notification lease is expectation, not proof: Stop never promises an empty queue and posts can be lost/reordered. Captured deliveries took 40–130ms; Claude idle_prompt consistently fired after 60s. */
@@ -22,8 +29,13 @@ const CLAUDE_LAUNCHED_BACKGROUND_TASK_LIMIT = 256
/** The task id a main-agent PostToolUse reports for work it left running in the background. */
export function readClaudeBackgroundTaskLaunch(
toolName: string | undefined,
toolResponse: unknown
): { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } | null {
toolResponse: unknown,
toolInput?: unknown
):
| (Pick<ClaudeLaunchedBackgroundTask, 'kind' | 'awaitsAgents'> & {
id: string
})
| null {
if (typeof toolResponse !== 'object' || toolResponse === null) {
return null
}
@@ -37,22 +49,40 @@ export function readClaudeBackgroundTaskLaunch(
const rawShellId =
response.backgroundTaskId ?? (toolName === 'Monitor' ? response.taskId : undefined)
const shellId = typeof rawShellId === 'string' ? rawShellId.trim() : ''
return shellId.length > 0 ? { id: shellId, kind: 'shell' } : null
if (shellId.length === 0) {
return null
}
const command = isRecord(toolInput) ? toolInput.command : undefined
return {
id: shellId,
kind: 'shell',
...(runsOrcaOrchestrationWait(command) ? { awaitsAgents: true as const } : {})
}
}
export function recordClaudeBackgroundTaskLaunch(
tasks: ClaudeLaunchedBackgroundTasks,
launch: { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] }
launch: Pick<ClaudeLaunchedBackgroundTask, 'kind' | 'awaitsAgents' | 'listedOnly'> & {
id: string
}
): void {
const previous = tasks.get(launch.id)
if (previous) {
if (previous.kind === launch.kind) {
previous.launchUnconfirmed = undefined
previous.awaitsAgents ||= launch.awaitsAgents
if (!launch.listedOnly) {
previous.launchUnconfirmed = undefined
previous.listedOnly = undefined
}
}
return
}
if (makeClaudeTaskRecordRoom(tasks)) {
tasks.set(launch.id, { kind: launch.kind })
tasks.set(launch.id, {
kind: launch.kind,
...(launch.awaitsAgents ? { awaitsAgents: true as const } : {}),
...(launch.listedOnly ? { listedOnly: true as const } : {})
})
}
}
@@ -84,7 +114,11 @@ export function recordClaudeUnconfirmedAgentEnd(
now: number
): void {
if (!tasks.has(agentId) && makeClaudeTaskRecordRoom(tasks)) {
tasks.set(agentId, { kind: 'agent', launchUnconfirmed: true, notificationOwedAt: now })
tasks.set(agentId, {
kind: 'agent',
launchUnconfirmed: true,
notificationOwedAt: now
})
}
}
@@ -132,7 +166,11 @@ export function oweClaudeShellTaskNotifications(
): void {
for (const [id, task] of tasks) {
if (task.kind === 'shell' && !runningShellIds.has(id)) {
task.notificationOwedAt ??= now
if (task.listedOnly) {
tasks.delete(id)
} else {
task.notificationOwedAt ??= now
}
}
}
}
@@ -229,12 +267,22 @@ function claudeOwedTaskNotificationKinds(tasks: ClaudeLaunchedBackgroundTasks |
const owed = { agent: false, shell: false }
for (const task of tasks?.values() ?? []) {
if (task.launchUnconfirmed !== true && task.notificationOwedAt !== undefined) {
owed[task.kind] = true
owed[task.awaitsAgents ? 'agent' : task.kind] = true
}
}
return owed
}
/** A recorded worker wait lives from launch until its notification is delivered, stopped or given up on. */
export function claudeTasksAwaitAgents(tasks: ClaudeLaunchedBackgroundTasks | undefined): boolean {
for (const task of tasks?.values() ?? []) {
if (task.awaitsAgents) {
return true
}
}
return false
}
/** Return owed kinds after expiring idle debt; undefined mainAgentIdleSince means this event is the idle boundary. */
export function claudeLiveOwedTaskNotificationKinds(
tasks: ClaudeLaunchedBackgroundTasks | undefined,
+5 -47
View File
@@ -1,5 +1,4 @@
import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner'
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
import { orcaCliInvocations } from './orca-cli-invocations'
/** The browser CLI's command keys per handler group. Kept apart from the CLI's
* lazy handler imports so the renderer and mobile can read them too. */
@@ -103,34 +102,15 @@ const BROWSER_COMMAND_WORDS = new Set<string>(
)
)
// The published launcher names per platform, plus the dev build's.
const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
export type OrcaBrowserCommand = { pageId: string | null }
/** The first `orca <browser command>` a shell command line runs, with the page it
* targets, or null when it runs none. Quoted text is an argument, never a command. */
export function parseOrcaBrowserCommand(command: string): OrcaBrowserCommand | null {
const scanned = command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS)
for (const segment of splitUnquotedSegments(scanned)) {
const tokens = tokenizeCommandLine(segment)
let index = 0
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) {
index += 1
}
const binary = tokens[index]
const word = tokens[index + 1]
if (
binary !== undefined &&
word !== undefined &&
ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary)) &&
BROWSER_COMMAND_WORDS.has(word)
) {
return { pageId: pageFlagValue(tokens.slice(index + 2)) }
}
}
return null
const args = orcaCliInvocations(command).find((invocation) =>
BROWSER_COMMAND_WORDS.has(invocation[0] ?? '')
)
return args ? { pageId: pageFlagValue(args.slice(1)) } : null
}
function pageFlagValue(args: readonly string[]): string | null {
@@ -145,25 +125,3 @@ function pageFlagValue(args: readonly string[]): string | null {
}
return null
}
/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */
function splitUnquotedSegments(commandLine: string): string[] {
const segments: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (let index = 0; index < commandLine.length; index += 1) {
const char = commandLine[index]
if (quote === null && (char === '"' || char === "'")) {
quote = char
} else if (quote === char) {
quote = null
} else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) {
segments.push(current)
current = ''
continue
}
current += char
}
segments.push(current)
return segments
}
+59
View File
@@ -0,0 +1,59 @@
import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner'
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
// The published launcher names per platform, plus the dev build's.
const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
/** The arguments of each `orca ...` a shell command line runs, in order. Quoted text is an argument, never a command. */
export function orcaCliInvocations(command: string): string[][] {
const invocations: string[][] = []
for (const segment of splitUnquotedSegments(command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS))) {
const tokens = tokenizeCommandLine(segment)
let index = 0
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) {
index += 1
}
const binary = tokens[index]
if (binary !== undefined && ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary))) {
invocations.push(tokens.slice(index + 1))
}
}
return invocations
}
/** Canonical form a child row carries for a worker wait; raw commands outgrow the operation preview. */
export const ORCA_ORCHESTRATION_WAIT_COMMAND = 'orca orchestration check --wait'
/** A command that blocks on `orca orchestration check --wait` is waiting on worker agents, not monitoring. */
export function runsOrcaOrchestrationWait(command: unknown): boolean {
return (
typeof command === 'string' &&
command.includes('orchestration') &&
orcaCliInvocations(command).some(
(args) => args[0] === 'orchestration' && args[1] === 'check' && args.includes('--wait')
)
)
}
/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */
function splitUnquotedSegments(commandLine: string): string[] {
const segments: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (let index = 0; index < commandLine.length; index += 1) {
const char = commandLine[index]
if (quote === null && (char === '"' || char === "'")) {
quote = char
} else if (quote === char) {
quote = null
} else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) {
segments.push(current)
current = ''
continue
}
current += char
}
segments.push(current)
return segments
}
@@ -123,39 +123,47 @@ describe('createProgramStatusProjector', () => {
expect(cleared.subagents).toBeUndefined()
})
it("keeps a finished turn working while a child runs, stamping the turn's own end for both rows", () => {
it('keeps a finished turn working while a child runs, leaving it to the all-clear to announce', () => {
const { feed, tick } = projector()
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents'))
tick(10)
expect(feed(osc('state=done:app=pi'))).toMatchObject([
{
state: 'working',
turnCompletedAt: 1010,
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 },
subagents: [{ id: 'subagents/run1', state: 'working' }]
}
])
const [held] = feed(osc('state=done:app=pi'))
expect(held).toMatchObject({
state: 'working',
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 },
subagents: [{ id: 'subagents/run1', state: 'working' }]
})
expect(held.turnCompletedAt).toBeUndefined()
tick(10)
const [settled] = feed(osc('state=done:id=subagents/run1:app=pi-subagents'))
expect(settled).toMatchObject({
state: 'done',
turnCompletedAt: 1010,
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }
})
expect(settled.turnCompletedAt).toBeUndefined()
expect(settled.subagents).toBeUndefined()
})
it("lapses a hold no child refreshed to the lead's done, keeping its completion stamp", () => {
it("stamps a failed turn's own end while a child still runs, for both rows", () => {
const { feed, tick } = projector()
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents'))
tick(10)
expect(feed(osc('state=error:app=pi'))).toMatchObject([
{ state: 'working', turnCompletedAt: 1010, mainAgent: { state: 'done', outcome: 'failure' } }
])
tick(10)
expect(feed(osc('state=done:id=subagents/run1:app=pi-subagents'))).toMatchObject([
{ state: 'done', turnCompletedAt: 1010 }
])
})
it("lapses a hold no child refreshed to the lead's done, keeping its verdict", () => {
const { feed } = projector()
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1'))
const [held] = feed(osc('state=done:app=pi'))
expect(lapsedProgramStatusChildHold(held, 0, 999, 1000)).toBeNull()
const lapsed = lapsedProgramStatusChildHold(held, 0, 1000, 1000)
expect(lapsed).toMatchObject({
state: 'done',
turnCompletedAt: held.turnCompletedAt,
mainAgent: held.mainAgent
})
expect(lapsed).toMatchObject({ state: 'done', mainAgent: held.mainAgent })
expect(lapsed?.subagents).toBeUndefined()
const [settled] = feed(osc('state=done:id=subagents/run1'))
expect(lapsedProgramStatusChildHold(settled, 0, 1000, 1000)).toBeNull()
@@ -4,7 +4,10 @@ import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status-
import { AGENT_SUBAGENT_ID_MAX_LENGTH } from '../agent-status-subagent-snapshot'
import type { AgentTurnOutcome } from '../agent-turn-outcome'
import { foldMainAgentWithRowChildWork } from '../agent-hook-row-child-work-fold'
import { isAgentStatusHeldOpenByChildWork } from '../agent-lead-status-fold'
import {
heldTurnCountsAsFinished,
isAgentStatusHeldOpenByChildWork
} from '../agent-lead-status-fold'
import { createProgramStatusOscScanner } from './osc-scanner'
import {
programStatusDisplayText,
@@ -210,13 +213,15 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog
const msg = programStatusDisplayText(root?.msg)
const subagents = snapshotChildren()
// Why: async children outlive a finished turn (pi-subagents); failed/idle children and boundaries hold nothing.
const state =
const held =
projection.state === 'done' && projection.sessionBoundary !== true
? foldMainAgentWithRowChildWork('done', {
payload: { subagents: subagents.filter(isRowHoldingChild) }
}).stateName
: projection.state
rootDoneHeldOpen ||= projection.state === 'done' && state !== 'done'
})
: null
const state = held?.stateName ?? projection.state
rootDoneHeldOpen ||=
held !== null && state !== 'done' && heldTurnCountsAsFinished(held, projection.outcome, false)
const payload: ParsedAgentStatusPayload = {
state,
prompt: '',
@@ -228,7 +233,7 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog
: {}),
...(projection.sessionBoundary ? { sessionBoundary: true } : {}),
...(subagents.length > 0 ? { subagents } : {}),
// Hook rows announce a held turn's own done now; the all-clear restates it so it is not announced twice.
// A held turn that counts as finished is announced now; the all-clear restates it so it is not announced twice.
...(rootDoneHeldOpen ? { turnCompletedAt: rootStateStartedAt } : {}),
mainAgent: {
state: projection.state,
@@ -141,7 +141,8 @@ async function framesFor(
const feed = new host.StructuredAgentSessionTurnCompletionFeed({
sessions,
now: () => 10,
readStatusState: () => host.projectStructuredAgentSessionStatusState(items, [])
readStatusState: () => host.projectStructuredAgentSessionStatusState(items, []),
readChildWork: () => undefined
})
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
hostCalls.subscribeTurnCompletions = vi.fn((subscriber: unknown) => feed.subscribe(subscriber))
@@ -112,6 +112,7 @@ function attentionPath(directory: string) {
]
]),
readStatusState: () => projectStructuredAgentSessionStatusState(items),
readChildWork: () => undefined,
now: () => 42
})
feed.subscribe({
@@ -228,6 +228,7 @@ beforeEach(() => {
]
]),
readStatusState: () => projectStructuredAgentSessionStatusState(fixture.items),
readChildWork: () => undefined,
now: hostClock
})
fixture = {
@@ -179,6 +179,7 @@ describe('Explicit desktop read acknowledgements', () => {
]
]),
readStatusState: () => projectStructuredAgentSessionStatusState(otherItems),
readChildWork: () => undefined,
now: () => 42
})
const delivery = createStructuredAttentionMobileDelivery({