fix(native-chat): give a failed Claude background task a typed row instead of an opcode (#20519)

* fix(native-chat): give a failed Claude background task a typed row instead of an opcode

A failed backgrounded command printed red rows whose visible text was the wire
opcode, and printed one failure twice. All five task lifecycle kinds are
catalogued status-chrome, but the payload sniffer in classifyProviderFrame runs
first and promotes any frame reporting a failure to the generic unknown-frame
fallback, whose sentence lookup has no key for the field Claude puts its own
sentence in. Two frames for one task therefore produced two rows, both of them
the method name.

Suppressing those frames is not the fix: when the last background task settles
the tracker flushes it and the strip unmounts, local_bash is excluded from the
subagent roster, and the status feed publishes only live tasks, so for a lone
backgrounded command the transcript row is the only report of the failure that
exists anywhere.

So the catalogue now binds: kinds a dedicated typed translator owns are named
as covered, and the generic fallback refuses to emit for them in either
direction. A new row owner keeps one durable row per task id, opened by the
announcement, revised in place by the lifecycle frames and closed by the
notification, carrying the provider's summary, error, output path, usage and a
run state. The row is written on the same dual carrier the subagent roster
uses: a frozen text twin plus a typed block, so a client without the block type
reads the sentence rather than nothing.

hasProviderError keeps its authority everywhere else, unchanged.

* fix(native-chat): keep tool attribution across a background-task row

A background task's row is a system message landing mid-turn between the
assistant's tool calls, exactly where the spawn-group roster row lands. Without
the same exemption it ended the run the following tool messages fold into, so a
tool result arriving after one stopped folding into its own assistant turn.

Also syncs the catalog with the row's one new translate key.

* fix(native-chat): harden background task rows

* fix(native-chat): settle background rows on provider end

* fix(native-chat): scope malformed task fallback text

* test(native-chat): assert only eligibility at the disposition layer

The malformed-task-frame test asserted the generic fallback resolves Claude's
`summary` field itself, which was true only while that key sat in the shared
key list. Eligibility is what this layer decides; the sentence the row leads
with is Claude's, supplied through the display-text seam and proven in the
translation test.

* fix(native-chat): gate background-task admission and scope rows per run

Admission now matches the reference on all three gates. Type is the whole gate
and MONITORS ARE NOT ADMITTED: a monitor runs for the life of the session and
has no outcome a row could report, so it never reaches the timeline. On first
admission only, the task's tool_use_id must name a tool call this session
forwarded at the TOP level — a Task spawned inside a subagent's sidechain names
an id that never reached the transcript, and a top-level row for it would claim
an invocation the user never saw. And a task that already exists and has not
finished is not re-opened: a duplicate announcement is a redelivery, not a
second run.

Rows are now keyed per RUN. A provider may reuse a task id for a distinct later
invocation, and a row keyed by the id alone overwrote the first run's transcript
history instead of leaving it standing. Generation 1 keeps the bare key, so
every row already written is unaffected.

The spawning tool call is carried on the row as parentToolUseId. Orca's journal
has no structural parent link for an item — AgentJournalItemIdentity has four
arms and none carries one — so the relationship is data on the item rather than
nesting.

A terminal frame that names NO tool still opens a row. That is a named
deviation, recorded at its call site, and the measurement behind it is in the PR.

* fix(native-chat): read the aggregate roster by membership, not a phantom status

The background-tasks payload types every entry as exactly
{task_id, task_type, description, ambient?}. It has no per-entry status, so the
state this owner derived from one was always undefined and the reopen branch it
guarded was unreachable on every real payload — proven by deriving the state
from an SDK-shaped entry and getting null.

Membership is the only liveness the payload carries: it is the whole live set
after a change, so presence means live and absence means merely "no longer
listed", never an outcome. Presence does not revive a settled row either — the
level's ordering against the start/stop edges is unspecified and it carries no
evidence of a new run, so the task's own frames stay the only thing that opens
or settles one. Only the identity fields it really sends are read, and ambient
housekeeping entries are excluded as the payload asks.

The two helpers that served the dead branch are removed, along with the test
that exercised it through a synthetic status the CLI cannot send.

* fix(native-chat): mirror reference task admission and drop the synthesis path

The forwarded-parent gate is conditional on the field being PRESENT. An
announcement naming a tool this session never forwarded is a nested child and is
refused; one naming no tool at all is admitted, because absence of the field is
not evidence of an unforwarded parent. The previous rule required the field and
so refused every tool-less task.

Terminal frames now match on task_id alone. The forwarded-parent question is
settled once, at admission, and is never re-asked on a notification or a patch.
A frame for a task that was never admitted yields no row, and a patch is folded
into the row it names rather than opening one.

That removes the synthesized-row path entirely, and with it the named deviation
it carried: the captured tool-less failure lands on a row that already exists,
because its own tool-less announcement is admitted. The dead builders go with
it.

Left deliberately stricter than the reference, and flagged rather than changed:
a terminal frame still records its task id as terminal even for a task never
admitted, so a late announcement cannot open a row for work already reported
finished. Two existing tests pin that.

* fix(native-chat): preserve background task ownership across restarts

* chore: restore pnpm-lock.yaml to origin/main

A local pnpm run rewrote the lockfile and the merge commit swept it in. The
branch changes no dependencies, so it must carry no lockfile delta at all.

* fix(claude): harden background task lifecycle

* fix(claude): bound task generation history

* fix(claude): preserve task identity after history eviction

* fix(claude): resolve background task identity after rebind

* fix(claude): isolate queued task runs

* refactor(claude): give the background-task ledgers one bounded owner

The bounded collections behind a background-task row were read out of the
class with `Reflect.get` to prove they stay capped, which the anti-slop
gate rejects. Move them into `ClaudeBackgroundTaskLedgers`, which owns
the caps beside the eviction helpers and reports a typed readonly size
view the tests assert against.

Also replace a `Reflect.get` in the mobile recording proxy with typed
property access.

* fix(native-chat): report a background task failure the transcript never admitted

A terminal `task_notification` for a task no announcement ever admitted rendered
nothing at all. The typed row owner declined the row because its map held no
entry for the id, and reported the frame as handled — which is exactly what
tells the generic provider-frame fallback to stay quiet. Both surfaces declined
the same frame, so a real failed background task was dropped on the floor.

A terminal frame is self-sufficient: it states an outcome, and it carries the
summary, status, error, output path and usage that outcome needs. It now opens
its own row from those fields, with the summary as the label and `unknown` as
the kind when the frame names no task type. The row map enriches a terminal
frame; it never gates one. Every terminal status writes one, not failures alone,
so there is one rule here rather than a third behaviour for failures.

The deliberate hand-offs still win, because they are recorded rather than
implied: ambient, subagent and foreground tasks are claimed in the foreign-owner
ledger the notification path already checks first. A Task spawned inside a
subagent's sidechain now records its refusal there too, under `sidechain`,
instead of leaving no trace and reading as a task nothing ever decided about. A
capacity-refused task whose outcome the generic fallback already printed records
`fallback` the same way, so a redelivery neither prints twice nor mints the row
capacity refused.

The anti-resurrection guard stays and stays scoped to announcements: a late
`task_started` cannot reopen work already reported finished. The restart rule is
now stated once instead of twice — a different parent alias is the provider's
restart signal only when BOTH runs name their parent, which is what the terminal
ledger already required of an evicted row and what the live row now requires too.

* test(native-chat): pin that an orphan task row reopens the provider's turn

* fix(native-chat): stop an orphan task row drawing its sentence twice

An orphan row took its header label from the notification's summary, which
also renders as the row's sentence, so the same string appeared in both slots
of the same collapsed row. The label now stays empty and the header falls back
to the task kind, leaving the sentence to carry the provider's words.

* fix(native-chat): keep Claude task outcomes owned through capacity and redelivery

* fix(native-chat): keep settled overflow notifications from reopening a turn

* fix(native-chat): retain Claude task rows through journal pressure
This commit is contained in:
Brennan Benson
2026-09-17 10:10:27 -07:00
committed by GitHub
parent abc8386e14
commit 7a1f55c52a
60 changed files with 5759 additions and 260 deletions
@@ -7,6 +7,7 @@ import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskRunState
} from '../../shared/agent-session-wire'
import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row'
const MAX_TASK_ID_LENGTH = 512
const MAX_TASK_TEXT_LENGTH = 512
@@ -29,6 +30,10 @@ export function taskId(message: Record<string, unknown>): string | null {
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
}
export function taskAliasId(value: unknown): string | undefined {
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : undefined
}
function boundedTaskText(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
@@ -41,6 +46,66 @@ export function taskDescription(value: unknown): string | undefined {
return boundedTaskText(value)
}
/** Every other provider string a durable task row carries — its summary, its
* error, its output path — takes the description bound: the row is replayed on
* every reconnect, and each reader clips it again anyway. */
export function taskText(value: unknown): string | undefined {
return boundedTaskText(value)
}
/**
* The sentence a task frame wrote about itself.
*
* Only for a frame the row owner could not claim — malformed or capacity-refused.
* It reaches the generic fallback, which has no key for `summary` and would
* otherwise print the bare opcode. Passed to that fallback as Claude's own
* display text rather than taught to its shared key list, because that list is
* read for every provider and already resolves `summary` by hand for two Codex
* methods; widening it globally to reach one malformed Claude frame would
* re-rank the row text of every unmodelled frame on both providers.
*/
export function taskFrameSentence(frame: Record<string, unknown>): string | undefined {
const patch = record(frame.patch)
const sentence =
taskText(frame.summary) ??
taskText(frame.error) ??
taskText(patch?.summary) ??
taskText(patch?.error)
if (sentence) {
return sentence
}
// A terminal task update often carries only its status in the nested patch.
// Reuse the durable row's frozen sentence so capacity fallbacks never expose
// the provider opcode when no human-facing text was supplied.
if (
frame.subtype !== 'task_started' &&
frame.subtype !== 'task_updated' &&
frame.subtype !== 'task_progress' &&
frame.subtype !== 'task_notification'
) {
return undefined
}
const status = patch?.status ?? frame.status
const state = terminalClaudeTaskRunState(status)
if (state === null) {
return undefined
}
const kind = classifyClaudeBackgroundTaskKind(patch?.task_type ?? frame.task_type)
return backgroundTaskFallbackText({
type: 'background-task',
taskId: taskId(frame) ?? '',
kind,
label:
taskDescription(patch?.description) ??
taskDescription(frame.description) ??
(patch ? taskName(patch) : undefined) ??
taskName(frame) ??
'',
state
})
}
/** The provider-reported identity for a task. Subagent frames have carried the
* type under both `agent_type` and `subagent_type` across SDK versions. */
export function taskName(frame: Record<string, unknown>): string | undefined {
@@ -54,6 +119,7 @@ export function taskName(frame: Record<string, unknown>): string | undefined {
export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind {
switch (taskType) {
case 'local_agent':
case 'local_subagent':
return 'agent'
case 'local_workflow':
return 'workflow'
@@ -0,0 +1,184 @@
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle'
const MAX_GENERATION_ENTRIES = 512
const MAX_FOREIGN_TASK_ROWS = 512
const MAX_TERMINAL_TASK_IDS = 512
const MAX_FALLBACK_TASK_IDS = 512
/** Bounded run identity ledger. Once old ids fall out, a monotonic sequence
* keeps a reused id from colliding with a durable row already in the journal. */
class ClaudeBackgroundTaskGenerationLedger {
private readonly entries = new Map<string, number>()
private nextUniqueGeneration = 1
private evicted = false
next(id: string): number {
const previous = this.entries.get(id)
const generation =
previous === undefined ? (this.evicted ? this.nextUniqueGeneration++ : 1) : previous + 1
this.entries.delete(id)
this.entries.set(id, generation)
this.nextUniqueGeneration = Math.max(this.nextUniqueGeneration, generation + 1)
while (this.entries.size > MAX_GENERATION_ENTRIES) {
const oldest = this.entries.keys().next()
if (oldest.done || oldest.value === id) {
break
}
this.entries.delete(oldest.value)
this.evicted = true
}
return generation
}
get size(): number {
return this.entries.size
}
clear(): void {
this.entries.clear()
this.nextUniqueGeneration = 1
this.evicted = false
}
}
function rememberBoundedClaudeTaskSet(ids: Set<string>, id: string, maxSize: number): void {
ids.delete(id)
ids.add(id)
while (ids.size > maxSize) {
const oldest = ids.values().next()
if (oldest.done || oldest.value === id) {
break
}
ids.delete(oldest.value)
}
}
function rememberBoundedClaudeTaskMap<T>(
entries: Map<string, T>,
id: string,
value: T,
maxSize: number
): void {
entries.delete(id)
entries.set(id, value)
while (entries.size > maxSize) {
const oldest = entries.keys().next()
if (oldest.done || oldest.value === id) {
break
}
entries.delete(oldest.value)
}
}
export function ensureClaudeBackgroundTaskRowSlot(
rows: Map<string, ClaudeBackgroundTaskRow>,
maxSize: number
): boolean {
if (rows.size < maxSize) {
return true
}
for (const [id, row] of rows) {
if (isSettledBackgroundTaskState(row.block.state)) {
rows.delete(id)
return true
}
}
return false
}
function rememberClaudeBackgroundTaskTerminal(
terminalIds: Set<string>,
terminalToolUseIds: Map<string, string | undefined>,
rows: Map<string, ClaudeBackgroundTaskRow>,
id: string,
toolUseId: string | undefined,
maxSize: number
): void {
terminalIds.delete(id)
terminalIds.add(id)
terminalToolUseIds.set(id, toolUseId ?? rows.get(id)?.toolUseId ?? terminalToolUseIds.get(id))
while (terminalIds.size > maxSize) {
const oldest = terminalIds.values().next()
if (oldest.done || oldest.value === id) {
break
}
terminalToolUseIds.delete(oldest.value)
terminalIds.delete(oldest.value)
}
}
/** Who renders a task this owner deliberately declined.
*
* `sidechain` is a Task spawned inside a subagent's own run: its spawning tool
* never reached the top-level transcript, so no top-level row may claim it.
* `terminal` is a capacity-refused task whose settled typed row was already
* written without taking a live slot; its redeliveries must not print again. */
export type ForeignOwner = 'roster' | 'ambient' | 'foreground' | 'sidechain' | 'terminal'
/** How much each ledger is holding. Named and readonly so a caller can prove
* eviction still bounds them without reaching into the collections. */
export type ClaudeBackgroundTaskLedgerSizes = {
readonly generations: number
readonly foreign: number
readonly fallbackTaskIds: number
readonly terminalTaskIds: number
}
/** Every bounded ledger a session keeps beside its rows, with the caps that
* bound them. One owner, so a sweep clears them together and no cap is
* applied at only some of the call sites that write to a ledger. */
export class ClaudeBackgroundTaskLedgers {
/** Runs seen per task id, so a reused id opens a new row instead of
* overwriting the finished one. Survives the row being evicted. */
readonly generations = new ClaudeBackgroundTaskGenerationLedger()
readonly foreign = new Map<string, ForeignOwner>()
/** Tasks that were declined because every typed row slot was live. Their
* later frames must remain visible through the generic fallback. */
readonly fallbackTaskIds = new Set<string>()
readonly terminalTaskIds = new Set<string>()
/** The parent alias for the terminal run, when one was reported. Keeping it
* lets an evicted row distinguish a late duplicate start from a genuine
* restart under a fresh tool invocation. */
readonly terminalToolUseIds = new Map<string, string | undefined>()
rememberForeign(id: string, owner: ForeignOwner): void {
rememberBoundedClaudeTaskMap(this.foreign, id, owner, MAX_FOREIGN_TASK_ROWS)
}
rememberFallback(id: string): void {
rememberBoundedClaudeTaskSet(this.fallbackTaskIds, id, MAX_FALLBACK_TASK_IDS)
}
rememberTerminal(
rows: Map<string, ClaudeBackgroundTaskRow>,
id: string,
toolUseId: string | undefined
): void {
rememberClaudeBackgroundTaskTerminal(
this.terminalTaskIds,
this.terminalToolUseIds,
rows,
id,
toolUseId,
MAX_TERMINAL_TASK_IDS
)
}
get sizes(): ClaudeBackgroundTaskLedgerSizes {
return {
generations: this.generations.size,
foreign: this.foreign.size,
fallbackTaskIds: this.fallbackTaskIds.size,
terminalTaskIds: this.terminalTaskIds.size
}
}
clear(): void {
this.generations.clear()
this.foreign.clear()
this.fallbackTaskIds.clear()
this.terminalTaskIds.clear()
this.terminalToolUseIds.clear()
}
}
@@ -0,0 +1,41 @@
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import {
classifyClaudeBackgroundTaskKind,
record,
taskDescription,
taskId,
taskName
} from './claude-background-task-frames'
import type {
ClaudeBackgroundTaskChange,
ClaudeBackgroundTaskRow
} from './claude-background-task-row-lifecycle'
/** A roster carries membership and identity, not a task's terminal outcome. */
export function observeClaudeBackgroundTaskRoster(
tasks: unknown[],
rows: Map<string, ClaudeBackgroundTaskRow>,
revise: (id: string, change: ClaudeBackgroundTaskChange) => void
): void {
for (const entry of tasks) {
const task = record(entry)
const id = task === null ? null : taskId(task)
const row = id === null ? undefined : rows.get(id)
if (
task === null ||
id === null ||
task.ambient === true ||
!row ||
isSettledBackgroundTaskState(row.block.state)
) {
continue
}
// Presence must not revive a settled row; the task's own frames alone
// settle or restart it, while the roster enriches its identity fields.
revise(id, {
label: taskDescription(task.description) ?? taskName(task),
kind:
task.task_type === undefined ? undefined : classifyClaudeBackgroundTaskKind(task.task_type)
})
}
}
@@ -0,0 +1,294 @@
import { describe, expect, it, vi } from 'vitest'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionLifecycleJournal
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle'
import {
ClaudeBackgroundTaskIdentityResolver,
writeClaudeBackgroundTaskRow
} from './claude-background-task-row-journal'
import { ClaudeBackgroundTaskRows } from './claude-background-task-rows'
import { START_BASH } from './claude-background-task-row-test-support'
function journal(
epoch: () => string,
visitItems = vi.fn()
): StructuredAgentSessionLifecycleJournal {
return {
get epoch() {
return epoch()
},
visitItems
}
}
function row(): ClaudeBackgroundTaskRow {
return {
block: {
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'Build',
state: 'blocked',
error: 'exit 1'
},
lastSerialized: null,
toolUseId: 'tool-1',
terminalNotificationReceived: true,
generation: 1
}
}
describe('Claude background task row journal', () => {
it('resolves one immutable run once per journal epoch', () => {
let epoch = 'epoch-1'
const visits = vi.fn()
const firstJournal = journal(() => epoch, visits)
const resolver = new ClaudeBackgroundTaskIdentityResolver()
for (let revision = 0; revision < 100; revision += 1) {
resolver.resolve(firstJournal, 'task-1', 'tool-1')
}
expect(visits).toHaveBeenCalledOnce()
resolver.resolve(firstJournal, 'task-1', 'tool-2')
expect(visits).toHaveBeenCalledTimes(2)
epoch = 'epoch-2'
resolver.resolve(firstJournal, 'task-1', 'tool-1')
expect(visits).toHaveBeenCalledTimes(3)
resolver.resolve(
journal(() => 'epoch-2', visits),
'task-1',
'tool-1'
)
expect(visits).toHaveBeenCalledTimes(4)
})
it('bounds resolved run identities with LRU eviction', () => {
const visits = vi.fn()
const boundJournal = journal(() => 'epoch-1', visits)
const resolver = new ClaudeBackgroundTaskIdentityResolver()
for (let index = 0; index < 513; index += 1) {
resolver.resolve(boundJournal, `task-${index}`, `tool-${index}`)
}
resolver.resolve(boundJournal, 'task-0', 'tool-0')
expect(visits).toHaveBeenCalledTimes(514)
})
it('records a revision only after its append and publication are admitted', () => {
const task = row()
const appendAndPublish = vi
.fn()
.mockReturnValueOnce({ accepted: false, reason: 'backpressure' })
.mockReturnValueOnce({ accepted: true })
const sink = {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: appendAndPublish
}
const resolver = new ClaudeBackgroundTaskIdentityResolver()
expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({
accepted: false,
reason: 'backpressure'
})
expect(task.lastSerialized).toBeNull()
expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({
accepted: true
})
expect(task.lastSerialized).not.toBeNull()
expect(appendAndPublish).toHaveBeenCalledTimes(2)
})
it('keeps fallback append coalescing separate from its ordered publication', () => {
const calls: { operation: 'append' | 'publish'; coalescingKey?: string }[] = []
const task = row()
const sink: StructuredAgentSessionEventSink = {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItem: vi.fn((_identity, _body, _resolve, options) => {
calls.push({
operation: 'append',
...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {})
})
return { accepted: true as const }
}),
tryPublish: vi.fn((options) => {
calls.push({
operation: 'publish',
...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {})
})
return { accepted: true as const }
})
}
expect(
writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task)
).toEqual({ accepted: true })
expect(calls).toEqual([
{
operation: 'append',
coalescingKey: JSON.stringify(['claude-background-task', 'task-1', 'tool-1'])
},
{ operation: 'publish' }
])
expect(task.lastSerialized).not.toBeNull()
})
it('uses reserved lifecycle capacity when provider exit settles a live row', () => {
const appendAndPublish = vi.fn<
NonNullable<StructuredAgentSessionEventSink['tryAppendResolvedItemAndPublish']>
>(() => ({ accepted: true as const }))
const rows = new ClaudeBackgroundTaskRows({
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: appendAndPublish
},
isForwardedParentTool: () => true
})
rows.observe(START_BASH)
rows.settleSession()
expect(appendAndPublish).toHaveBeenCalledTimes(2)
expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true })
})
it('promotes a backpressured terminal row to lifecycle capacity on provider exit', () => {
const appendAndPublish = vi
.fn<NonNullable<StructuredAgentSessionEventSink['tryAppendResolvedItemAndPublish']>>()
.mockReturnValueOnce({ accepted: false, reason: 'backpressure' })
.mockReturnValueOnce({ accepted: true })
const rows = new ClaudeBackgroundTaskRows({
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: appendAndPublish
},
isForwardedParentTool: () => true
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'terminal-at-exit',
status: 'failed',
summary: 'failed'
})
rows.settleSession()
expect(appendAndPublish).toHaveBeenCalledTimes(2)
expect(appendAndPublish.mock.calls[1]?.[3]).toMatchObject({ lifecycle: true })
})
it('retries a refused row before later provider messages are read', () => {
const appendAndPublish = vi
.fn()
.mockReturnValueOnce({ accepted: false, reason: 'backpressure' })
.mockReturnValueOnce({ accepted: true })
const rows = new ClaudeBackgroundTaskRows({
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: appendAndPublish
},
isForwardedParentTool: () => true
})
expect(rows.observe(START_BASH)).toBe(true)
expect(rows.retryPendingWrites()).toEqual({ accepted: true })
expect(appendAndPublish).toHaveBeenCalledTimes(2)
expect(rows.retryPendingWrites()).toEqual({ accepted: true })
expect(appendAndPublish).toHaveBeenCalledTimes(2)
})
it('abandons a permanently failed retry and reports recovery instead of latching', () => {
const onPersistenceFailure = vi.fn()
const appendAndPublish = vi
.fn()
.mockReturnValueOnce({ accepted: false, reason: 'backpressure' })
.mockReturnValueOnce({ accepted: false, reason: 'failed' })
const rows = new ClaudeBackgroundTaskRows({
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: appendAndPublish
},
isForwardedParentTool: () => true,
onPersistenceFailure
})
rows.observe(START_BASH)
expect(rows.retryPendingWrites()).toEqual({ accepted: false, reason: 'failed' })
expect(onPersistenceFailure).toHaveBeenCalledOnce()
expect(rows.retryPendingWrites()).toEqual({ accepted: true })
})
it('hands retry-capacity exhaustion to session recovery without throwing', () => {
const onPersistenceFailure = vi.fn()
const rows = new ClaudeBackgroundTaskRows({
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: vi.fn(() => ({
accepted: false as const,
reason: 'backpressure' as const
}))
},
isForwardedParentTool: () => true,
onPersistenceFailure
})
for (let index = 0; index < 512; index += 1) {
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: `task-${index}`,
status: 'failed',
summary: 'failed'
})
}
expect(() => {
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'task-overflow',
status: 'failed',
summary: 'failed'
})
}).not.toThrow()
expect(onPersistenceFailure).toHaveBeenCalledWith(
expect.objectContaining({
message: 'claude background task journal retry capacity exhausted'
})
)
})
it.each(['closed', 'failed'] as const)('surfaces a %s sink refusal', (reason) => {
const task = row()
const sink = {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItemAndPublish: vi.fn(() => ({ accepted: false as const, reason }))
}
expect(
writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task)
).toEqual({ accepted: false, reason })
expect(task.lastSerialized).toBeNull()
})
})
@@ -0,0 +1,195 @@
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row'
import {
isBackgroundTaskBlock,
type NativeChatBackgroundTaskBlock
} from '../../shared/native-chat-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionLifecycleJournal,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle'
import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key'
const MAX_RESOLVED_TASK_IDENTITIES = 512
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
/** Durable identity for one RUN of a task.
*
* A provider may reuse a task id for a distinct later invocation, and a row
* keyed by the id alone would overwrite the first run's transcript history
* instead of leaving it standing beside the restart. The generation suffix
* separates them. Generation 1 carries no suffix, so every row written before
* generations existed keeps the key it already has. */
export function claudeBackgroundTaskIdentity(
taskId: string,
generation = 1
): AgentJournalItemIdentity {
const key =
generation > 1
? `claude-background-task:${taskId}#${generation}`
: `claude-background-task:${taskId}`
return { provider: 'orca', clientMessageId: key }
}
export function claudeBackgroundTaskBody(
block: NativeChatBackgroundTaskBlock
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }]
}
}
/** Reconcile one queued row against the durable run identity after a rebind. */
export function resolveClaudeBackgroundTaskIdentity(
journal: StructuredAgentSessionLifecycleJournal,
id: string,
toolUseId: string | undefined
): AgentJournalItemIdentity {
let maxGeneration = 0
let matchingGeneration: number | undefined
let parentlessGeneration: number | undefined
journal.visitItems((itemId, _sequence, body) => {
const identity = parseAgentJournalItemKey(itemId)
if (!identity || identity.provider !== 'orca') {
return
}
const taskBlock = body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined
if (!taskBlock || taskBlock.taskId !== id) {
return
}
const generation = persistedTaskGeneration(identity.clientMessageId, id)
if (generation === null) {
return
}
maxGeneration = Math.max(maxGeneration, generation)
if (taskBlock.parentToolUseId === toolUseId) {
matchingGeneration = Math.max(matchingGeneration ?? 0, generation)
} else if (taskBlock.parentToolUseId === undefined) {
parentlessGeneration = Math.max(parentlessGeneration ?? 0, generation)
}
})
// A prior parentless row cannot be proved distinct from the later aliased outcome.
return claudeBackgroundTaskIdentity(
id,
matchingGeneration ?? parentlessGeneration ?? (maxGeneration === 0 ? 1 : maxGeneration + 1)
)
}
/** Resolves each immutable provider run once per bound journal epoch. */
export class ClaudeBackgroundTaskIdentityResolver {
private journal: StructuredAgentSessionLifecycleJournal | null = null
private epoch: string | null = null
private readonly identities = new Map<string, AgentJournalItemIdentity>()
resolve = (
journal: StructuredAgentSessionLifecycleJournal,
id: string,
toolUseId: string | undefined
): AgentJournalItemIdentity => {
if (this.journal !== journal || this.epoch !== journal.epoch) {
this.journal = journal
this.epoch = journal.epoch
this.identities.clear()
}
const key = JSON.stringify([id, toolUseId ?? null])
const cached = this.identities.get(key)
if (cached) {
this.identities.delete(key)
this.identities.set(key, cached)
return cached
}
const identity = resolveClaudeBackgroundTaskIdentity(journal, id, toolUseId)
this.identities.set(key, identity)
if (this.identities.size > MAX_RESOLVED_TASK_IDENTITIES) {
const oldest = this.identities.keys().next()
if (!oldest.done) {
this.identities.delete(oldest.value)
}
}
return identity
}
clear(): void {
this.journal = null
this.epoch = null
this.identities.clear()
}
}
function persistedTaskGeneration(clientMessageId: string, taskId: string): number | null {
const base = `claude-background-task:${taskId}`
if (clientMessageId === base) {
return 1
}
const prefix = `${base}#`
if (!clientMessageId.startsWith(prefix)) {
return null
}
const generation = Number(clientMessageId.slice(prefix.length))
return Number.isSafeInteger(generation) && generation > 1 ? generation : null
}
export function writeClaudeBackgroundTaskRow(
sink: StructuredAgentSessionEventSink,
identities: ClaudeBackgroundTaskIdentityResolver,
id: string,
row: ClaudeBackgroundTaskRow,
/** Runs before admission to preserve turn-before-row ordering; duplicate
* delivery skips it, and a retry reuses the turn the first attempt opened. */
beforeAppend?: () => void,
lifecycle = false
): StructuredAgentSessionSinkAdmission {
const body = claudeBackgroundTaskBody(row.block)
const serialized = JSON.stringify(body)
if (serialized === row.lastSerialized) {
return ADMITTED
}
beforeAppend?.()
const identity = claudeBackgroundTaskIdentity(id, row.generation)
// Generation is translator-local and resets when a provider stream is
// recreated. Keep unresolved writes from distinct provider runs queued side
// by using the provider's parent tool identity as the coalescing discriminator.
const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null])
const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) }
const publishOptions = lifecycle ? { lifecycle: true } : {}
const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) =>
identities.resolve(journal, id, row.toolUseId)
const appendAndPublish = sink.tryAppendResolvedItemAndPublish
let admission: StructuredAgentSessionSinkAdmission
if (appendAndPublish) {
// Reserve enough space for any safe generation suffix; the actual identity
// is selected once the deferred sink is bound to the durable journal.
const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER)
admission = appendAndPublish(identitySizeBound, body, resolveIdentity, appendOptions)
} else if (sink.tryAppendResolvedItem) {
const identitySizeBound = claudeBackgroundTaskIdentity(id, Number.MAX_SAFE_INTEGER)
admission = sink.tryAppendResolvedItem(identitySizeBound, body, resolveIdentity, appendOptions)
if (admission.accepted) {
admission = sink.tryPublish
? sink.tryPublish(publishOptions)
: (sink.publish(publishOptions), ADMITTED)
}
} else if (sink.tryAppendItem) {
admission = sink.tryAppendItem(identity, body, appendOptions)
if (admission.accepted) {
admission = sink.tryPublish
? sink.tryPublish(publishOptions)
: (sink.publish(publishOptions), ADMITTED)
}
} else {
sink.appendItem(identity, body, appendOptions)
sink.publish(publishOptions)
admission = ADMITTED
}
if (admission.accepted) {
row.lastSerialized = serialized
}
return admission
}
@@ -0,0 +1,229 @@
import {
canReplaceBackgroundTaskState,
isSettledBackgroundTaskState
} from '../../shared/native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types'
import type { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import {
classifyClaudeBackgroundTaskKind,
liveClaudeTaskRunState,
record,
taskAliasId,
taskDescription,
taskId,
taskName,
taskText,
taskUsageTotalTokens,
terminalClaudeTaskRunState
} from './claude-background-task-frames'
export type ClaudeBackgroundTaskRow = {
block: NativeChatBackgroundTaskBlock
lastSerialized: string | null
/** Immutable alias for persistence/coalescing; the final frame may enrich the block's parent. */
toolUseId?: string
/** Whether the provider's terminal notification finalized this run. */
terminalNotificationReceived: boolean
/** Which RUN of this task id the row records. 1 for the first. */
generation: number
}
export type ClaudeBackgroundTaskChange = {
state?: NativeChatBackgroundTaskBlock['state'] | null
label?: string | undefined
kind?: NativeChatBackgroundTaskBlock['kind'] | undefined
summary?: string | undefined
error?: string | undefined
outputFile?: string | undefined
tokens?: number | undefined
}
export function canonicalClaudeBackgroundTaskId(
message: Record<string, unknown>,
ids: ClaudeSubagentIds
): string | null {
const declared = taskId(message)
const toolUseId = claudeBackgroundTaskToolUseId(message)
if (declared === null) {
const aliased = toolUseId === undefined ? null : ids.canonical(toolUseId)
return aliased !== null && aliased !== toolUseId ? aliased : null
}
if (toolUseId !== undefined) {
ids.alias(toolUseId, declared)
}
return declared
}
export function claudeBackgroundTaskToolUseId(
message: Record<string, unknown>
): string | undefined {
const patch = record(message.patch)
return taskAliasId(message.tool_use_id) ?? taskAliasId(patch?.tool_use_id)
}
export function claudeBackgroundTaskNotificationChange(
message: Record<string, unknown>
): ClaudeBackgroundTaskChange {
return {
state: terminalClaudeTaskRunState(message.status) ?? 'done',
summary: taskText(message.summary),
error: taskText(message.error),
outputFile: taskText(message.output_file),
tokens: taskUsageTotalTokens(message)
}
}
export function claudeBackgroundTaskPatchChange(
message: Record<string, unknown>
): ClaudeBackgroundTaskChange {
const patch = record(message.patch) ?? message
const status = patch.status ?? message.status
const terminal = terminalClaudeTaskRunState(status)
return {
state: terminal ?? liveClaudeTaskRunState(status),
label:
message.subtype === 'task_progress'
? undefined
: (taskDescription(patch.description) ?? taskName(patch)),
kind: 'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : undefined,
error: taskText(patch.error),
tokens: taskUsageTotalTokens(message)
}
}
export function newClaudeBackgroundTaskRow(
id: string,
message: Record<string, unknown>,
now: number,
generation: number
): ClaudeBackgroundTaskRow {
const totalTokens = taskUsageTotalTokens(message)
const toolUseId = claudeBackgroundTaskToolUseId(message)
return {
lastSerialized: null,
terminalNotificationReceived: false,
generation,
...(toolUseId === undefined ? {} : { toolUseId }),
block: {
type: 'background-task',
taskId: id,
kind: classifyClaudeBackgroundTaskKind(message.task_type),
label: taskDescription(message.description) ?? taskName(message) ?? '',
...(toolUseId === undefined ? {} : { parentToolUseId: toolUseId }),
state:
terminalClaudeTaskRunState(message.status) ??
liveClaudeTaskRunState(message.status) ??
'working',
startedAt: now,
...(totalTokens === undefined ? {} : { tokens: totalTokens })
}
}
}
/** The row a terminal notification opens on its own.
*
* A terminal frame is self-sufficient: it states an outcome the transcript owes
* the user whether or not an announcement ever admitted the task, so the row is
* built from the frame's own fields — its summary as the sentence, its status as
* the state, its error, output path and usage. */
export function newClaudeBackgroundTaskRowFromNotification(
id: string,
message: Record<string, unknown>,
now: number,
generation: number
): ClaudeBackgroundTaskRow {
const row = newClaudeBackgroundTaskRow(id, message, now, generation)
finalizeClaudeBackgroundTaskRow(row, message, now)
return row
}
/** A status patch is provisional; the notification supplies the final verdict. */
export function finalizeClaudeBackgroundTaskRow(
row: ClaudeBackgroundTaskRow,
message: Record<string, unknown>,
now: number
): void {
reviseClaudeBackgroundTaskRow(row, claudeBackgroundTaskNotificationChange(message), now)
const notificationToolUseId = claudeBackgroundTaskToolUseId(message)
row.block = {
...row.block,
...(row.block.parentToolUseId === undefined && notificationToolUseId !== undefined
? { parentToolUseId: notificationToolUseId }
: {}),
state: terminalClaudeTaskRunState(message.status) ?? 'done',
settledAt: now
}
row.terminalNotificationReceived = true
}
export function shouldRestartClaudeBackgroundTaskRow(
row: ClaudeBackgroundTaskRow,
message: Record<string, unknown>
): boolean {
if (!isSettledBackgroundTaskState(row.block.state) || row.block.startedAt === undefined) {
return false
}
const toolUseId = claudeBackgroundTaskToolUseId(message)
// One rule for a restart, the same one the terminal ledger applies to a row
// that has already been evicted: only when BOTH runs name their parent is a
// different alias the provider's restart signal. A finished run that named no
// parent cannot be proved distinct from this announcement, so it stands.
const parentToolUseId = row.block.parentToolUseId
return parentToolUseId !== undefined && toolUseId !== undefined && toolUseId !== parentToolUseId
}
/** Task types the transcript materializes as a row.
*
* Type is the whole gate. A MONITOR is never admitted: it is Claude's own
* ambient housekeeping, runs for the life of the session, and has no outcome a
* transcript row could report. A type this build does not recognise is not
* evidence of anything a row could truthfully say either. Agents are
* materialized too, by the subagent roster, which claims them upstream of this
* owner — so the set left here is the backgrounded shell command and the
* workflow. */
const MATERIALIZED_TASK_KINDS: ReadonlySet<NativeChatBackgroundTaskBlock['kind']> = new Set([
'command',
'workflow'
])
export function isClaudeBackgroundTranscriptTask(
message: Record<string, unknown>,
kind: NativeChatBackgroundTaskBlock['kind']
): boolean {
// A task the provider explicitly calls foreground is the turn's own work and
// already has the tool row that invoked it.
return MATERIALIZED_TASK_KINDS.has(kind) && message.is_backgrounded !== false
}
export function reviseClaudeBackgroundTaskRow(
row: ClaudeBackgroundTaskRow,
change: ClaudeBackgroundTaskChange,
now: number
): void {
const next: NativeChatBackgroundTaskBlock = { ...row.block }
if (change.label && !next.label) {
next.label = change.label
}
if (change.kind !== undefined && change.kind !== 'unknown') {
next.kind = change.kind
}
if (change.summary !== undefined) {
next.summary = change.summary
}
if (change.error !== undefined) {
next.error = change.error
}
if (change.outputFile !== undefined) {
next.outputFile = change.outputFile
}
if (change.tokens !== undefined) {
next.tokens = change.tokens
}
if (change.state && canReplaceBackgroundTaskState(next.state, change.state)) {
next.state = change.state
if (isSettledBackgroundTaskState(change.state)) {
next.settledAt = now
}
}
row.block = next
}
@@ -0,0 +1,113 @@
// Shared fixtures and the sink harness the background-task row suites drive.
//
// Moved out of claude-background-task-rows.test.ts verbatim when that suite
// reached its line budget, so the terminal-frame suite reads the same captured
// payloads and the same forwarded-tool admission set rather than a second copy.
import { vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeBackgroundTaskRows } from './claude-background-task-rows'
/** The exact payloads the user's journal carried for the reported failure. */
export const FAILED_UPDATE = {
type: 'system',
subtype: 'task_updated',
task_id: 'byjnee2no',
patch: { status: 'failed', end_time: 1_789_332_035_695 }
}
export const FAILED_NOTIFICATION = {
type: 'system',
subtype: 'task_notification',
task_id: 'byjnee2no',
tool_use_id: 'toolu_01CqPd7y',
status: 'failed',
output_file: '/private/tmp/claude-501/tasks/byjnee2no.output',
summary: 'Background command "Wait for the verification verdict" failed with exit code 1'
}
/** A terminal frame captured from a user session whose task was never admitted.
* Nothing rendered for it: the typed path declined the row and told the generic
* fallback the frame was covered. */
export const ORPHAN_FAILED_NOTIFICATION = {
type: 'system',
subtype: 'task_notification',
task_id: 'bjzenpq13',
tool_use_id: 'toolu_01ASNfnDBEzt4w3ejLE12bGu',
status: 'failed',
output_file: '',
summary: 'Locate the exact screenshot session',
uuid: '1d748563-5741-4aa8-9c21-7023b90bc737',
session_id: 'f4579b9c-b4bb-4551-81b2-2acca35e4a7b'
}
export function blockOf(
body: AgentJournalItemBody | undefined
): NativeChatBackgroundTaskBlock | null {
if (!body || body.kind !== 'message') {
return null
}
const block = body.blocks.find(
(candidate): candidate is NativeChatBackgroundTaskBlock => candidate.type === 'background-task'
)
return block ?? null
}
export function twinOf(body: AgentJournalItemBody | undefined): string | null {
if (!body || body.kind !== 'message') {
return null
}
const block = body.blocks.find((candidate) => candidate.type === 'text')
return block?.type === 'text' ? block.text : null
}
/** The spawn call the harness treats as forwarded to the top-level transcript.
* Admission consults this, so a test that wants a row must name it. */
export const FORWARDED_TOOL = 'toolu_01CqPd7y'
export function harness(
forwarded: readonly string[] = [FORWARDED_TOOL, 'toolu_first', 'toolu_second']
) {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const turnOpens: number[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: vi.fn(),
publish: vi.fn()
}
let clock = 1_000
const forwardedTools = new Set(forwarded)
const rows = new ClaudeBackgroundTaskRows({
sink,
isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId),
openOutputTurn: () => turnOpens.push(1),
now: () => (clock += 10)
})
const keys = (): string[] =>
items.map((item) =>
item.identity.provider === 'orca' ? item.identity.clientMessageId : item.identity.provider
)
return {
rows,
items,
keys,
forwardedTools,
latest: () => blockOf(items.at(-1)?.body),
latestTwin: () => twinOf(items.at(-1)?.body),
turnOpens
}
}
export const START_BASH = {
type: 'system',
subtype: 'task_started',
task_id: 'byjnee2no',
tool_use_id: 'toolu_01CqPd7y',
task_type: 'local_bash',
description: 'Wait for the verification verdict',
is_backgrounded: true
}
@@ -0,0 +1,92 @@
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle'
import {
ClaudeBackgroundTaskIdentityResolver,
writeClaudeBackgroundTaskRow
} from './claude-background-task-row-journal'
const MAX_PENDING_TASK_WRITES = 512
export class ClaudeBackgroundTaskRowWriter {
private readonly pending = new Map<
string,
{ id: string; row: ClaudeBackgroundTaskRow; lifecycle: boolean }
>()
private readonly identities = new ClaudeBackgroundTaskIdentityResolver()
constructor(
private readonly sink: StructuredAgentSessionEventSink,
private readonly onPersistenceFailure?: (error: Error) => void
) {}
write(
id: string,
row: ClaudeBackgroundTaskRow,
beforeAppend?: () => void,
lifecycle = false
): void {
const admission = writeClaudeBackgroundTaskRow(
this.sink,
this.identities,
id,
row,
beforeAppend,
lifecycle
)
const key = JSON.stringify([id, row.toolUseId ?? null, row.generation])
if (admission.accepted) {
this.pending.delete(key)
} else if (admission.reason === 'backpressure') {
if (!this.pending.has(key) && this.pending.size >= MAX_PENDING_TASK_WRITES) {
this.pending.clear()
this.onPersistenceFailure?.(
new Error('claude background task journal retry capacity exhausted')
)
return
}
this.pending.set(key, { id, row, lifecycle })
} else if (admission.reason === 'failed') {
this.onPersistenceFailure?.(new Error('claude background task journal sink failed'))
}
}
/** Replays bounded row obligations before provider reading resumes. */
retryPendingWrites(): StructuredAgentSessionSinkAdmission {
for (const [key, pending] of this.pending) {
const admission = writeClaudeBackgroundTaskRow(
this.sink,
this.identities,
pending.id,
pending.row,
undefined,
pending.lifecycle
)
if (!admission.accepted) {
if (admission.reason !== 'backpressure') {
this.pending.clear()
if (admission.reason === 'failed') {
this.onPersistenceFailure?.(new Error('claude background task journal sink failed'))
}
}
return admission
}
this.pending.delete(key)
}
return { accepted: true }
}
settlePendingWrites(): void {
for (const pending of this.pending.values()) {
pending.lifecycle = true
}
this.retryPendingWrites()
}
dispose(): void {
this.pending.clear()
this.identities.clear()
}
}
@@ -0,0 +1,705 @@
import { describe, expect, it } from 'vitest'
import {
FAILED_NOTIFICATION,
FAILED_UPDATE,
FORWARDED_TOOL,
harness,
START_BASH
} from './claude-background-task-row-test-support'
describe('claude background task rows', () => {
it('reports one failed backgrounded command as ONE row carrying the provider sentence', () => {
const { rows, items, latest, latestTwin } = harness()
rows.observe(START_BASH)
rows.observe(FAILED_UPDATE)
rows.observe(FAILED_NOTIFICATION)
// One durable identity, not one row per frame: the same failure arrived on
// two frames and printed twice before this owner existed.
const identities = new Set(
items.map((item) =>
item.identity.provider === 'orca' ? item.identity.clientMessageId : item.identity.provider
)
)
expect([...identities]).toEqual(['claude-background-task:byjnee2no'])
expect(latest()).toMatchObject({
type: 'background-task',
taskId: 'byjnee2no',
kind: 'command',
label: 'Wait for the verification verdict',
state: 'blocked',
summary: FAILED_NOTIFICATION.summary,
outputFile: FAILED_NOTIFICATION.output_file
})
// The visible text is the provider's own sentence, never the wire opcode.
expect(latestTwin()).toBe(FAILED_NOTIFICATION.summary)
expect(latestTwin()).not.toContain('task_notification')
})
it('lands the captured failure on its row when the announcement carried no tool id', () => {
// An announcement naming NO tool is admitted — absence of the field is not
// evidence of an unforwarded parent — so by the time this REAL captured
// frame arrives its row already exists and simply takes the sentence.
const { rows, latestTwin } = harness()
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: 'bo2vuy8qb',
task_type: 'local_bash',
description: 'verify',
is_backgrounded: true
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'bo2vuy8qb',
status: 'failed',
output_file: '',
summary: "Check the verifier's state"
})
expect(latestTwin()).toBe("Check the verifier's state")
})
it('keeps terminal ownership after a tracked task reports foregrounded', () => {
const { rows, latest } = harness()
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: START_BASH.task_id,
patch: { is_backgrounded: false, status: 'running' }
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: START_BASH.task_id,
status: 'failed',
summary: 'foreground transition failed'
})
expect(latest()).toMatchObject({ state: 'blocked', summary: 'foreground transition failed' })
})
it('revises in place rather than opening a row from a patch', () => {
const { rows, items, latest } = harness()
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'never-announced',
patch: { status: 'running' }
})
expect(items).toEqual([])
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_progress',
task_id: 'byjnee2no',
description: 'Running Bash',
usage: { total_tokens: 1_200 }
})
// Progress `description` is the CURRENT ACTIVITY, not the task's name.
expect(latest()).toMatchObject({ label: 'Wait for the verification verdict', tokens: 1_200 })
})
it('takes no row from a terminal patch for an untracked task', () => {
const { rows, items } = harness()
expect(
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'pre-journal',
patch: { status: 'failed', description: 'Check logs', error: 'boom' }
})
).toBe(true)
expect(items).toEqual([])
})
it('latches a reported outcome against a later live tick', () => {
const { rows, latest } = harness()
rows.observe(START_BASH)
rows.observe(FAILED_NOTIFICATION)
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [{ task_id: 'byjnee2no', task_type: 'local_bash', description: 'still listed' }]
})
expect(latest()).toMatchObject({ state: 'blocked' })
})
it('ignores late revisions after a task has reported its outcome', () => {
const { rows, items, latest, turnOpens } = harness()
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: START_BASH.task_id,
status: 'failed',
summary: 'first run failed'
})
const writes = items.length
const opens = turnOpens.length
rows.observe({
type: 'system',
subtype: 'task_progress',
task_id: START_BASH.task_id,
usage: { total_tokens: 99 }
})
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: START_BASH.task_id,
patch: { error: 'late update' }
})
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [{ task_id: START_BASH.task_id, task_type: 'local_bash', description: 'late roster' }]
})
rows.observe({ ...START_BASH, description: 'duplicate start' })
expect(items).toHaveLength(writes)
expect(turnOpens).toHaveLength(opens)
expect(latest()).toMatchObject({ state: 'blocked', summary: 'first run failed' })
})
it('does not reopen a turn when a settled session receives a late outcome', () => {
const { rows, latest, turnOpens } = harness()
rows.observe(START_BASH)
rows.settleSession()
const opens = turnOpens.length
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: START_BASH.task_id,
status: 'failed',
summary: 'late outcome'
})
expect(turnOpens).toHaveLength(opens)
expect(latest()).toMatchObject({ state: 'blocked', summary: 'late outcome' })
})
it('reopens a settled row when Claude re-announces the same task id with a new tool id', () => {
const { rows, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'resume-1', tool_use_id: 'toolu_first' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'resume-1',
tool_use_id: 'toolu_first',
status: 'completed',
summary: 'first run finished'
})
expect(latest()).toMatchObject({ state: 'done', summary: 'first run finished' })
rows.observe({
...START_BASH,
task_id: 'resume-1',
tool_use_id: 'toolu_second',
status: 'running',
description: 'Second run'
})
expect(latest()).toMatchObject({ state: 'working', label: 'Second run' })
expect(latest()).not.toHaveProperty('summary')
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'resume-1',
tool_use_id: 'toolu_second',
status: 'failed',
summary: 'second run failed'
})
expect(latest()).toMatchObject({ state: 'blocked', summary: 'second run failed' })
})
// Entries carry `{task_id, task_type, description, ambient?}` and NOTHING
// else — no per-entry status — so these use the real payload shape.
it('takes identity from the aggregate roster', () => {
const { rows, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'aggregate-1', description: undefined })
expect(latest()).toMatchObject({ label: '', state: 'working' })
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [{ task_id: 'aggregate-1', task_type: 'local_bash', description: 'Named by roster' }]
})
expect(latest()).toMatchObject({ label: 'Named by roster', state: 'working' })
})
// NOT an ablation of the status-read removal: that removal is behaviour-neutral
// on every real payload, which is exactly why the branch it fed was dead. This
// pins the standing latch rule instead — presence is a level signal whose
// ordering against the start/stop edges is unspecified and which carries no
// evidence of a new run, so a row that reported its own outcome keeps it.
it('does not let mere presence in the live set revive a settled row', () => {
const { rows, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'aggregate-2' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'aggregate-2',
status: 'completed'
})
expect(latest()).toMatchObject({ state: 'done' })
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [{ task_id: 'aggregate-2', task_type: 'local_bash', description: 'still listed' }]
})
expect(latest()).toMatchObject({ state: 'done' })
})
it('excludes ambient housekeeping from the aggregate roster', () => {
const { rows, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'aggregate-3', description: undefined })
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [
{
task_id: 'aggregate-3',
task_type: 'local_bash',
description: 'housekeeping name',
ambient: true
}
]
})
expect(latest()).toMatchObject({ label: '' })
})
it('never burns a revision on a duplicate delivery', () => {
const { rows, items } = harness()
rows.observe(START_BASH)
const afterStart = items.length
rows.observe(START_BASH)
expect(items.length).toBe(afterStart)
})
it('claims a resumed task announced under a new tool id', () => {
const { rows, items, latest } = harness()
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_notification',
tool_use_id: 'toolu_01CqPd7y',
status: 'failed',
summary: 'it failed'
})
expect(items.at(-1)?.identity).toMatchObject({
clientMessageId: 'claude-background-task:byjnee2no'
})
expect(latest()).toMatchObject({ taskId: 'byjnee2no', state: 'blocked' })
})
it('rejects overlong tool-use aliases instead of clipping them into collisions', () => {
// The row is opened under a usable alias; a later frame carrying an
// oversized one must resolve to NO task rather than being clipped into this
// one and attaching another task's failure to it.
const { rows, items, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'task-a' })
const afterStart = items.length
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
tool_use_id: `${'x'.repeat(512)}A`,
status: 'failed',
summary: 'misattributed failure'
})
).toBe(false)
expect(items).toHaveLength(afterStart)
expect(latest()).toMatchObject({ taskId: 'task-a', state: 'working' })
})
it('evicts settled rows so the lifetime cap cannot drop a later failure', () => {
const { rows, latest } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `settled-${index}` })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: `settled-${index}`,
status: 'completed'
})
}
rows.observe({ ...START_BASH, task_id: 'overflow' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow',
status: 'failed',
summary: 'overflow failed'
})
expect(latest()).toMatchObject({
taskId: 'overflow',
state: 'blocked',
summary: 'overflow failed'
})
})
it('reopens a settled task after its row was evicted when the parent alias changes', () => {
const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second'])
rows.observe({ ...START_BASH, task_id: 'evicted-restart' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'evicted-restart',
tool_use_id: FORWARDED_TOOL,
status: 'completed'
})
for (let index = 0; index < 63; index += 1) {
rows.observe({ ...START_BASH, task_id: `settled-${index}` })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: `settled-${index}`,
status: 'completed'
})
}
// The first settled row is evicted to make room for this one.
rows.observe({ ...START_BASH, task_id: 'evictor' })
rows.observe({
...START_BASH,
task_id: 'evicted-restart',
tool_use_id: 'toolu_second',
description: 'second invocation'
})
expect([...new Set(keys())]).toContain('claude-background-task:evicted-restart#2')
expect(latest()).toMatchObject({
taskId: 'evicted-restart',
state: 'working',
label: 'second invocation'
})
})
it('bounds generation history without reusing an evicted durable identity', () => {
const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second'])
rows.observe({ ...START_BASH, task_id: 'generation-reused' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'generation-reused',
status: 'completed'
})
rows.observe({ ...START_BASH, task_id: 'generation-reused', tool_use_id: 'toolu_second' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'generation-reused',
tool_use_id: 'toolu_second',
status: 'completed'
})
for (let index = 0; index < 513; index += 1) {
const taskId = `generation-${index}`
rows.observe({ ...START_BASH, task_id: taskId })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: taskId,
status: 'completed'
})
}
rows.observe({ ...START_BASH, task_id: 'generation-reused' })
rows.observe({
...START_BASH,
task_id: 'generation-0',
tool_use_id: 'toolu_second',
description: 'reused after ledger eviction'
})
const identities = new Set(keys())
expect(identities).toContain('claude-background-task:generation-reused#2')
expect(identities).toContain('claude-background-task:generation-reused#4')
expect(identities).toContain('claude-background-task:generation-0')
expect(identities).toContain('claude-background-task:generation-0#5')
expect(latest()).toMatchObject({
taskId: 'generation-0',
state: 'working',
label: 'reused after ledger eviction'
})
expect(rows.ledgerSizes.generations).toBeLessThanOrEqual(512)
})
it('declines coverage so the fallback still reports when every row slot is live', () => {
// The row map is bounded. A task that cannot be admitted for lack of a slot
// is not silently swallowed: coverage is declined so the generic fallback
// reports it instead.
const { rows } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
expect(rows.observe({ ...START_BASH, task_id: 'overflow-live' })).toBe(false)
})
it('keeps one overflow terminal row across its update and notification', () => {
const { rows, keys, latest } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
expect(rows.observe({ ...START_BASH, task_id: 'overflow-fallback' })).toBe(false)
expect(
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'overflow-fallback',
patch: { status: 'failed' }
})
).toBe(true)
expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(1)
expect(latest()).toMatchObject({ state: 'blocked' })
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-fallback',
status: 'failed',
summary: 'overflow failed'
})
).toBe(true)
expect(
rows.observe({
type: 'system',
subtype: 'task_progress',
task_id: 'overflow-fallback',
usage: { total_tokens: 3 }
})
).toBe(true)
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-fallback',
status: 'failed',
summary: 'duplicate overflow failed'
})
).toBe(true)
expect(keys().filter((id) => id === 'claude-background-task:overflow-fallback')).toHaveLength(2)
expect(latest()).toMatchObject({ state: 'blocked', summary: 'overflow failed' })
})
it('lets the final notification correct a provisional failed update', () => {
const { rows, latest, latestTwin } = harness()
rows.observe(START_BASH)
rows.observe({ ...FAILED_UPDATE, task_id: START_BASH.task_id })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: START_BASH.task_id,
status: 'stopped',
summary: 'No completion record was found'
})
expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' })
expect(latestTwin()).toBe('No completion record was found')
})
it('corrects a capacity-refused failed update with the final stopped verdict', () => {
const { rows, latest, keys } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
rows.observe({ ...START_BASH, task_id: 'overflow-stopped' })
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'overflow-stopped',
patch: { status: 'failed' }
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-stopped',
status: 'stopped',
summary: 'No completion record was found'
})
expect(latest()).toMatchObject({ state: 'idle', summary: 'No completion record was found' })
expect(keys().filter((id) => id === 'claude-background-task:overflow-stopped')).toHaveLength(2)
})
it('preserves a fallback run alias across a duplicate terminal without one', () => {
const { rows, latest } = harness([FORWARDED_TOOL, 'toolu_second'])
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
expect(rows.observe({ ...START_BASH, task_id: 'overflow-restart' })).toBe(false)
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-restart',
tool_use_id: FORWARDED_TOOL,
status: 'completed'
})
).toBe(true)
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-restart',
status: 'completed'
})
).toBe(true)
// Make a typed slot available for the new invocation. The alias from the
// first terminal edge is still needed to distinguish this restart from a
// redelivery of the completed fallback run.
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'live-0',
status: 'completed'
})
expect(
rows.observe({
...START_BASH,
task_id: 'overflow-restart',
tool_use_id: 'toolu_second',
description: 'second overflow run'
})
).toBe(true)
expect(latest()).toMatchObject({
taskId: 'overflow-restart',
state: 'working',
label: 'second overflow run'
})
})
it('bounds foreign-owner memory for tasks rendered elsewhere', () => {
const { rows } = harness()
for (let index = 0; index < 600; index += 1) {
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: `agent-${index}`,
task_type: 'local_agent',
subagent_type: 'explorer'
})
}
expect(rows.ledgerSizes.foreign).toBeLessThanOrEqual(512)
})
it('bounds fallback ownership memory for capacity-refused tasks', () => {
const { rows } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
for (let index = 0; index < 600; index += 1) {
rows.observe({ ...START_BASH, task_id: `overflow-${index}` })
}
expect(rows.ledgerSizes.fallbackTaskIds).toBeLessThanOrEqual(512)
})
it('bounds settled overflow rows while preserving the evicted outcome', () => {
const { rows, keys } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
for (let index = 0; index < 513; index += 1) {
const id = `overflow-${index}`
rows.observe({ ...START_BASH, task_id: id })
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: id,
patch: { status: 'failed' }
})
}
const beforeRedelivery = keys().length
expect(rows.ledgerSizes.overflowTerminalRows).toBeLessThanOrEqual(512)
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-0',
status: 'failed',
summary: 'late outcome'
})
expect(keys()).toHaveLength(beforeRedelivery)
expect(keys()).toContain('claude-background-task:overflow-0')
})
it('loses contact rather than claiming an outcome when the provider goes away', () => {
const { rows, latest, latestTwin } = harness()
rows.observe(START_BASH)
rows.dispose()
expect(latest()).toMatchObject({ state: 'unverifiable' })
// The frozen sentence a client without the block type reads must not assert
// a liveness only the dead process could have observed.
expect(latestTwin()).toBe(
'Background command "Wait for the verification verdict" stopped reporting'
)
})
it('states only that a live task was started, never that it is still running', () => {
const { rows, latestTwin } = harness()
rows.observe(START_BASH)
expect(latestTwin()).toBe('Started background command "Wait for the verification verdict"')
})
it('declines frames it does not own', () => {
const { rows } = harness()
expect(rows.observe({ type: 'system', subtype: 'init' })).toBe(false)
expect(rows.observe({ type: 'assistant' })).toBe(false)
expect(rows.observe({ type: 'system', subtype: 'task_started', task_id: 'x' })).toBe(true)
})
it('admits a task type it does not recognise as no task at all', () => {
const { rows, items } = harness()
rows.observe({ ...START_BASH, task_id: 'weird-1', task_type: 'local_teleport' })
expect(items).toEqual([])
})
it('gives a reused task id a fresh row instead of overwriting the finished run', () => {
const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second_run'])
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'byjnee2no',
status: 'failed',
summary: 'first run failed'
})
rows.observe({ ...START_BASH, tool_use_id: 'toolu_second_run' })
const written = [...new Set(keys())]
expect(written).toEqual([
'claude-background-task:byjnee2no',
'claude-background-task:byjnee2no#2'
])
expect(latest()).toMatchObject({ state: 'working', parentToolUseId: 'toolu_second_run' })
})
it('carries the spawning tool call on the row', () => {
const { rows, latest } = harness()
rows.observe(START_BASH)
expect(latest()).toMatchObject({ parentToolUseId: FORWARDED_TOOL })
})
it('does not re-open a task that is already running', () => {
// A redelivered announcement is not a second run. The row it would revise
// is one the user is already reading, so it yields no deltas at all — even
// when the redelivery carries metadata the first announcement lacked.
const { rows, items, latest } = harness()
rows.observe({ ...START_BASH, description: undefined })
const afterStart = items.length
expect(latest()).toMatchObject({ label: '', state: 'working' })
rows.observe({ ...START_BASH, description: 'Named on redelivery' })
expect(items.length).toBe(afterStart)
expect(latest()).toMatchObject({ label: '' })
})
})
@@ -0,0 +1,279 @@
// One durable row per Claude background `task_id`, revised in place from the
// lifecycle frames so a failed command prints once with the provider sentence.
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { record, taskAliasId } from './claude-background-task-frames'
import {
claudeBackgroundTaskPatchChange,
claudeBackgroundTaskToolUseId,
canonicalClaudeBackgroundTaskId,
finalizeClaudeBackgroundTaskRow,
newClaudeBackgroundTaskRow,
newClaudeBackgroundTaskRowFromNotification,
reviseClaudeBackgroundTaskRow,
type ClaudeBackgroundTaskChange,
type ClaudeBackgroundTaskRow
} from './claude-background-task-row-lifecycle'
import {
ClaudeBackgroundTaskLedgers,
ensureClaudeBackgroundTaskRowSlot,
type ClaudeBackgroundTaskLedgerSizes
} from './claude-background-task-memory'
import { ClaudeBackgroundTaskRowWriter } from './claude-background-task-row-writer'
import { observeClaudeBackgroundTaskStart } from './claude-background-task-start'
import { observeClaudeBackgroundTaskRoster } from './claude-background-task-roster'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import { ClaudeOverflowTerminalRows } from './claude-overflow-terminal-rows'
const MAX_TASK_ROWS = 64
const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_started',
'task_updated',
'task_progress',
'task_notification'
])
export type ClaudeBackgroundTaskRowsDeps = {
sink: StructuredAgentSessionEventSink
/** Whether a tool id names a tool call this session forwarded at the TOP
* level. Consulted on first admission only: a task whose spawning tool never
* reached the transcript is a nested child, and a top-level row minted for it
* would claim an invocation the user never saw. */
isForwardedParentTool: (toolUseId: string) => boolean
/** Opens a turn for the frame being journaled. A typed row is provider
* output, so writing one must reopen a turn the provider resumed itself —
* otherwise the session renders the row while reporting idle. */
openOutputTurn?: (frame: Record<string, unknown>, observedAt: number) => void
onPersistenceFailure?: (error: Error) => void
now?: () => number
}
export class ClaudeBackgroundTaskRows {
private readonly rows = new Map<string, ClaudeBackgroundTaskRow>()
private readonly writer: ClaudeBackgroundTaskRowWriter
private readonly overflowTerminalRows: ClaudeOverflowTerminalRows
private readonly ledgers = new ClaudeBackgroundTaskLedgers()
private readonly ids = new ClaudeSubagentIds()
private readonly now: () => number
constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) {
this.now = deps.now ?? (() => Date.now())
this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure)
this.overflowTerminalRows = new ClaudeOverflowTerminalRows(
this.ledgers,
this.now,
(id, row, openOutputTurn) => this.writeRow(id, row, openOutputTurn)
)
}
/** @internal - exposed for tests only: what the bounded ledgers are holding,
* so eviction can be proved without reaching into the collections. */
get ledgerSizes(): ClaudeBackgroundTaskLedgerSizes & { readonly overflowTerminalRows: number } {
return { ...this.ledgers.sizes, overflowTerminalRows: this.overflowTerminalRows.size }
}
/** The frame being journaled right now, so a write can open its turn. Null
* outside `observe`: a teardown sweep must never open one. */
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 }
try {
return this.observeFrame(message)
} finally {
this.journaling = null
}
}
/** A Monitor result can identify its task before any lifecycle announcement.
* Its later notification belongs to that tool call, not a transcript row. */
observeMonitorToolResult(taskId: unknown): void {
const id = taskAliasId(taskId)
if (id !== undefined) {
this.ledgers.rememberForeign(id, 'ambient')
}
}
private observeFrame(message: Record<string, unknown>): boolean {
if (message.type !== 'system') {
return false
}
if (message.subtype === 'background_tasks_changed') {
if (!Array.isArray(message.tasks)) {
return false
}
observeClaudeBackgroundTaskRoster(message.tasks, this.rows, (id, change) =>
this.revise(id, change)
)
return true
}
if (typeof message.subtype !== 'string' || !TASK_SUBTYPES.has(message.subtype)) {
return false
}
const id = canonicalClaudeBackgroundTaskId(message, this.ids)
if (id === null) {
return false
}
if (message.subtype === 'task_started') {
return observeClaudeBackgroundTaskStart({
id,
message,
rows: this.rows,
ledgers: this.ledgers,
isForwardedParentTool: this.deps.isForwardedParentTool,
openRow: (taskId, frame) => this.openRow(taskId, frame),
maxRows: MAX_TASK_ROWS
})
}
if (this.ledgers.foreign.has(id)) {
return true
}
if (message.subtype === 'task_notification') {
return this.observeNotification(id, message)
}
return this.observePatch(id, message)
}
settleSession(): void {
for (const [id, row] of this.rows) {
if (!isSettledBackgroundTaskState(row.block.state)) {
this.revise(id, { state: 'unverifiable' }, true)
}
}
this.writer.settlePendingWrites()
}
dispose(): void {
this.settleSession()
this.rows.clear()
this.writer.dispose()
this.overflowTerminalRows.clear()
this.ledgers.clear()
this.ids.clear()
}
retryPendingWrites() {
return this.writer.retryPendingWrites()
}
private openRow(id: string, message: Record<string, unknown>): void {
const generation = this.ledgers.generations.next(id)
this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now(), generation))
this.write(id)
}
private observeNotification(id: string, message: Record<string, unknown>): boolean {
if (this.ledgers.fallbackTaskIds.has(id)) {
this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message))
this.overflowTerminalRows.observeNotification(id, message)
return true
}
const row = this.rows.get(id)
if (row && row.terminalNotificationReceived) {
return true
}
// Remembered even for a task never admitted. It scopes the anti-resurrection
// guard to ANNOUNCEMENTS: a late `task_started` cannot reopen work already
// reported finished, which is a different thing from this frame stating the
// outcome itself.
this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message))
if (!row) {
return this.openTerminalRow(id, message)
}
const wasLive = !isSettledBackgroundTaskState(row.block.state)
finalizeClaudeBackgroundTaskRow(row, message, this.now())
this.write(id, wasLive)
return true
}
/** The row a terminal frame opens for itself. A task the transcript never
* admitted still owes the user its outcome, and the frame carries everything
* that outcome needs, so the row map only ever ENRICHES one — a missing entry
* is not a reason to render nothing. */
private openTerminalRow(id: string, message: Record<string, unknown>): boolean {
if (!ensureClaudeBackgroundTaskRowSlot(this.rows, MAX_TASK_ROWS)) {
this.overflowTerminalRows.observeNotificationWithoutSlot(id, message)
return true
}
const generation = this.ledgers.generations.next(id)
this.rows.set(
id,
newClaudeBackgroundTaskRowFromNotification(id, message, this.now(), generation)
)
this.write(id)
return true
}
private observePatch(id: string, message: Record<string, unknown>): boolean {
if (this.ledgers.fallbackTaskIds.has(id)) {
const change = claudeBackgroundTaskPatchChange(message)
if (change.state && isSettledBackgroundTaskState(change.state)) {
this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message))
this.overflowTerminalRows.observePatch(id, message, change)
return true
}
return false
}
const row = this.rows.get(id)
if (row && isSettledBackgroundTaskState(row.block.state)) {
return true
}
const patch = record(message.patch)
// A tracked row remains this owner's responsibility even if a later patch
// reports foreground execution; its terminal notification still revises
// the durable row. Only an untracked task belongs to the foreground owner.
if (patch?.is_backgrounded === false && !this.rows.has(id)) {
this.ledgers.rememberForeign(id, 'foreground')
return true
}
const change = claudeBackgroundTaskPatchChange(message)
if (change.state && isSettledBackgroundTaskState(change.state)) {
this.ledgers.rememberTerminal(this.rows, id, claudeBackgroundTaskToolUseId(message))
}
// A patch is folded into the row it names and is never a row of its own, so
// an untracked task takes no row from it.
if (row) {
this.revise(id, change)
}
return true
}
private revise(id: string, change: ClaudeBackgroundTaskChange, lifecycle = false): void {
const row = this.rows.get(id)
if (!row) {
return
}
const wasLive = !isSettledBackgroundTaskState(row.block.state)
reviseClaudeBackgroundTaskRow(row, change, this.now())
this.write(id, wasLive, lifecycle)
}
private write(id: string, openOutputTurn = true, lifecycle = false): void {
const row = this.rows.get(id)
if (!row) {
return
}
this.writeRow(id, row, openOutputTurn, lifecycle)
}
private writeRow(
id: string,
row: ClaudeBackgroundTaskRow,
openOutputTurn = true,
lifecycle = false
): void {
const journaling = this.journaling
this.writer.write(
id,
row,
() => {
if (journaling && openOutputTurn) {
this.deps.openOutputTurn?.(journaling.frame, journaling.observedAt)
}
},
lifecycle
)
}
}
@@ -0,0 +1,98 @@
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import { classifyClaudeBackgroundTaskKind } from './claude-background-task-frames'
import {
claudeBackgroundTaskToolUseId,
isClaudeBackgroundTranscriptTask,
shouldRestartClaudeBackgroundTaskRow,
type ClaudeBackgroundTaskRow
} from './claude-background-task-row-lifecycle'
import {
ensureClaudeBackgroundTaskRowSlot,
type ClaudeBackgroundTaskLedgers
} from './claude-background-task-memory'
import { isClaudeSubagentTask } from './claude-subagent-task-frames'
export function observeClaudeBackgroundTaskStart(input: {
id: string
message: Record<string, unknown>
rows: Map<string, ClaudeBackgroundTaskRow>
ledgers: ClaudeBackgroundTaskLedgers
isForwardedParentTool: (toolUseId: string) => boolean
openRow: (id: string, message: Record<string, unknown>) => void
maxRows: number
}): boolean {
const { id, message, rows, ledgers } = input
if (ledgers.fallbackTaskIds.has(id)) {
if (ledgers.terminalTaskIds.has(id)) {
const previousToolUseId = ledgers.terminalToolUseIds.get(id)
const currentToolUseId = claudeBackgroundTaskToolUseId(message)
if (
previousToolUseId !== undefined &&
currentToolUseId !== undefined &&
previousToolUseId !== currentToolUseId
) {
ledgers.fallbackTaskIds.delete(id)
} else {
return false
}
} else {
return false
}
}
if (message.ambient === true || message.skip_transcript === true) {
ledgers.rememberForeign(id, 'ambient')
return true
}
if (isClaudeSubagentTask(message)) {
ledgers.rememberForeign(id, 'roster')
return true
}
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
if (!isClaudeBackgroundTranscriptTask(message, kind)) {
ledgers.rememberForeign(id, 'foreground')
return true
}
const existing = rows.get(id)
if (existing) {
ledgers.foreign.delete(id)
// A live task's duplicate announcement is redelivery, not a new run.
if (!isSettledBackgroundTaskState(existing.block.state)) {
return true
}
if (shouldRestartClaudeBackgroundTaskRow(existing, message)) {
input.openRow(id, message)
}
return true
}
let restartedTerminal = false
if (ledgers.terminalTaskIds.has(id)) {
const previousToolUseId = ledgers.terminalToolUseIds.get(id)
const currentToolUseId = claudeBackgroundTaskToolUseId(message)
// A terminal edge without a usable parent cannot prove a later start is a new run.
if (
previousToolUseId === undefined ||
currentToolUseId === undefined ||
previousToolUseId === currentToolUseId
) {
return true
}
restartedTerminal = true
}
ledgers.foreign.delete(id)
const toolUseId = claudeBackgroundTaskToolUseId(message)
// Absence of a parent is not evidence of an unforwarded parent.
if (toolUseId !== undefined && !input.isForwardedParentTool(toolUseId)) {
ledgers.rememberForeign(id, 'sidechain')
return true
}
if (!ensureClaudeBackgroundTaskRowSlot(rows, input.maxRows)) {
ledgers.rememberFallback(id)
return false
}
if (restartedTerminal) {
ledgers.terminalTaskIds.delete(id)
ledgers.terminalToolUseIds.delete(id)
}
input.openRow(id, message)
return true
}
@@ -0,0 +1,238 @@
// What a TERMINAL background-task frame renders on its own, and what it must
// not. A terminal frame states an outcome, so it is self-sufficient: the row map
// enriches one, it never gates one. The deliberate hand-offs still win, and a
// late announcement still cannot reopen work already reported finished.
import { describe, expect, it } from 'vitest'
import {
FORWARDED_TOOL,
harness,
ORPHAN_FAILED_NOTIFICATION,
START_BASH
} from './claude-background-task-row-test-support'
describe('claude background task terminal frames', () => {
it('reports a failure for a task it never saw admitted', () => {
// The row map ENRICHES a terminal frame; it never gates one. This exact
// frame reached a user session whose task was never admitted, and both the
// typed path and the generic fallback stayed silent, so the failure was
// dropped on the floor.
const { rows, items, latest, latestTwin, turnOpens } = harness()
expect(rows.observe(ORPHAN_FAILED_NOTIFICATION)).toBe(true)
expect(items).toHaveLength(1)
// The row is provider output like any other, so writing it reopens the turn
// the provider resumed itself rather than printing beside an idle session.
expect(turnOpens).toHaveLength(1)
expect(latest()).toMatchObject({
type: 'background-task',
taskId: 'bjzenpq13',
kind: 'unknown',
state: 'blocked',
summary: 'Locate the exact screenshot session',
parentToolUseId: 'toolu_01ASNfnDBEzt4w3ejLE12bGu'
})
// The sentence carries the provider's words; the header falls back to the
// kind label, so one field is never drawn in two slots of the same row.
expect(latest()?.label).toBe('')
expect(latestTwin()).toBe('Locate the exact screenshot session')
})
it('carries the error, output path and usage a terminal frame supplies itself', () => {
const { rows, latest } = harness()
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'orphan-detailed',
status: 'failed',
error: 'exit code 2',
output_file: '/tmp/orphan-detailed.output',
usage: { total_tokens: 41 }
})
expect(latest()).toMatchObject({
state: 'blocked',
error: 'exit code 2',
outputFile: '/tmp/orphan-detailed.output',
tokens: 41
})
})
it('reports every terminal outcome it never saw start, not failures alone', () => {
const { rows, latest } = harness()
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'quiet-1',
status: 'completed'
})
expect(latest()).toMatchObject({ taskId: 'quiet-1', kind: 'unknown', state: 'done' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'quiet-2',
status: 'stopped',
summary: 'the user stopped it'
})
expect(latest()).toMatchObject({ taskId: 'quiet-2', state: 'idle' })
})
it('leaves agent tasks to the subagent roster', () => {
const { rows, items } = harness()
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: 'task-agent',
task_type: 'local_agent',
subagent_type: 'explorer',
description: 'Map the lane'
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'task-agent',
status: 'failed',
summary: 'the child failed'
})
expect(items).toEqual([])
})
it('leaves legacy local_subagent tasks to the subagent roster', () => {
const { rows, items } = harness()
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: 'task-legacy-agent',
task_type: 'local_subagent',
subagent_type: 'explorer'
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'task-legacy-agent',
status: 'failed',
summary: 'the child failed'
})
expect(items).toEqual([])
})
it('writes nothing for ambient housekeeping the user never asked for', () => {
const { rows, items } = harness()
rows.observe({ ...START_BASH, task_id: 'ambient-1', ambient: true })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'ambient-1',
status: 'failed'
})
expect(items).toEqual([])
})
it('leaves foreground commands to the ordinary transcript path', () => {
const { rows, items } = harness()
expect(
rows.observe({
...START_BASH,
task_id: 'foreground-1',
is_backgrounded: false
})
).toBe(true)
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'foreground-1',
status: 'failed',
summary: 'foreground command failed'
})
).toBe(true)
expect(items).toEqual([])
})
it('refuses a nested child whose spawning tool was never forwarded', () => {
// A Task spawned inside a subagent's sidechain names a tool id that only
// exists in that sidechain. A top-level row for it would claim an
// invocation the user never saw.
const { rows, items } = harness([])
rows.observe({ ...START_BASH, task_id: 'nested-1', tool_use_id: 'toolu_sidechain' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'nested-1',
tool_use_id: 'toolu_sidechain',
status: 'failed',
summary: 'the nested child failed'
})
expect(items).toEqual([])
})
it('never lets a monitor reach the timeline', () => {
// A monitor is Claude's own housekeeping: it runs for the life of the
// session and has no outcome a transcript row could report.
const { rows, items } = harness()
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: 'monitor-1',
tool_use_id: FORWARDED_TOOL,
task_type: 'monitor',
description: 'Watch the build',
is_backgrounded: true
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'monitor-1',
status: 'failed',
summary: 'monitor stopped'
})
expect(items).toEqual([])
})
it('does not resurrect a task whose terminal edge arrived before its start', () => {
// The guard is scoped to ANNOUNCEMENTS: the terminal frame states an
// outcome and gets its row, and the late `task_started` that follows may
// not reopen work already reported finished.
const { rows, items, keys, latest } = harness()
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'done-before-start',
status: 'completed'
})
).toBe(true)
expect(items).toHaveLength(1)
expect(rows.observe({ ...START_BASH, task_id: 'done-before-start' })).toBe(true)
expect(items).toHaveLength(1)
expect([...new Set(keys())]).toEqual(['claude-background-task:done-before-start'])
expect(latest()).toMatchObject({ state: 'done' })
})
it('does not resurrect an orphan outcome that named its own parent tool', () => {
const { rows, items, latest } = harness([
FORWARDED_TOOL,
ORPHAN_FAILED_NOTIFICATION.tool_use_id
])
rows.observe(ORPHAN_FAILED_NOTIFICATION)
rows.observe({
...START_BASH,
task_id: ORPHAN_FAILED_NOTIFICATION.task_id,
tool_use_id: ORPHAN_FAILED_NOTIFICATION.tool_use_id
})
expect(items).toHaveLength(1)
expect(latest()).toMatchObject({ state: 'blocked' })
})
it('does not resurrect after a terminal update that arrived before start', () => {
const { rows, items } = harness()
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'updated-before-start',
patch: { status: 'completed' }
})
rows.observe({ ...START_BASH, task_id: 'updated-before-start' })
expect(items).toEqual([])
})
})
@@ -0,0 +1,214 @@
// A typed background-task row is provider output. Journaling one must reopen a
// turn the provider resumed on its own, or the session renders the row while
// the projector — and so the sidebar row and the chat indicator — read idle.
//
// This is the same failure shape as the reported incident: a `result` settles
// the turn, then a background task reports in and wakes the agent.
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalRenderItem
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import {
hasUnansweredStructuredAgentSessionDispatch,
projectStructuredAgentSessionStatus
} from '../../shared/structured-agent-session-projection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
const SESSION = 'claude-session'
const TOOL = 'toolu_fwd'
function harness() {
const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => appended.push({ identity, body }),
appendTombstone: () => {},
publish: vi.fn()
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
const items = (): AgentJournalRenderItem[] => {
const byKey = new Map<string, AgentJournalRenderItem>()
appended.forEach(({ identity, body }, index) => {
const key = agentJournalItemKey(identity)
const existing = byKey.get(key)
byKey.set(key, {
itemId: key,
revision: (existing?.revision ?? 0) + 1,
body,
sequence: existing?.sequence ?? index,
observedAt: index
})
})
return [...byKey.values()].sort((a, b) => a.sequence - b.sequence)
}
return { translator, items, appended }
}
function projected(items: readonly AgentJournalRenderItem[]): string {
expect(hasUnansweredStructuredAgentSessionDispatch([], null)).toBe(false)
return projectStructuredAgentSessionStatus(items, [], null)
}
function systemFrame(uuid: string, fields: Record<string, unknown>) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: { type: 'system', uuid, session_id: SESSION, ...fields }
}
}
/** The assistant turn that invokes the spawn tool, which admission requires. */
function spawnToolCall(translator: ReturnType<typeof harness>['translator']) {
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'a1',
session_id: SESSION,
parent_tool_use_id: null,
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: TOOL, name: 'Bash', input: { command: 'wait' } }]
}
}
})
}
function settleTurn(translator: ReturnType<typeof harness>['translator']) {
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'result',
subtype: 'success',
uuid: 'r1',
session_id: SESSION,
parent_tool_use_id: null,
duration_ms: 1000
}
})
}
describe('a typed background-task row opens the turn it resumes', () => {
it('reports working, not idle, when a task notification wakes the agent', () => {
const { translator, items } = harness()
spawnToolCall(translator)
translator.handle(
systemFrame('s1', {
subtype: 'task_started',
task_id: 'byjnee2no',
tool_use_id: TOOL,
task_type: 'local_bash',
description: 'Wait for the verification verdict',
is_backgrounded: true
})
)
settleTurn(translator)
// The turn is settled: this is the state the reported session was in.
expect(projected(items())).toBe('idle')
translator.handle(
systemFrame('s2', {
subtype: 'task_notification',
task_id: 'byjnee2no',
tool_use_id: TOOL,
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
})
)
// The typed row is journaled...
// `agentJournalItemKey` percent-encodes the ':', so the durable row reads
// `orca:claude-background-task%3Abyjnee2no`.
const taskRow = items().find((item) => item.itemId.includes('byjnee2no'))
expect(taskRow).toBeDefined()
// ...and it opened a turn, so the session does not read idle beside it.
const running = items().filter((item) => readAgentJournalTurn(item.body)?.state === 'running')
expect(running).toHaveLength(1)
expect(projected(items())).toBe('working')
})
it('does not reopen a completed turn for a late task revision', () => {
const { translator, items } = harness()
spawnToolCall(translator)
translator.handle(
systemFrame('s1', {
subtype: 'task_started',
task_id: 'late-revision',
tool_use_id: TOOL,
task_type: 'local_bash',
description: 'Wait for the verification verdict',
is_backgrounded: true
})
)
translator.handle(
systemFrame('s2', {
subtype: 'task_notification',
task_id: 'late-revision',
tool_use_id: TOOL,
status: 'completed',
summary: 'first run finished'
})
)
settleTurn(translator)
expect(projected(items())).toBe('idle')
translator.handle(
systemFrame('s3', {
subtype: 'task_progress',
task_id: 'late-revision',
usage: { total_tokens: 99 }
})
)
expect(projected(items())).toBe('idle')
})
it('does not reopen a completed turn when an overflow terminal row is enriched', () => {
const { translator, items } = harness()
for (let index = 0; index < 64; index += 1) {
translator.handle(
systemFrame(`start-${index}`, {
subtype: 'task_started',
task_id: `live-${index}`,
task_type: 'local_bash',
is_backgrounded: true
})
)
}
translator.handle(
systemFrame('overflow-start', {
subtype: 'task_started',
task_id: 'overflow-turn',
task_type: 'local_bash',
is_backgrounded: true
})
)
translator.handle(
systemFrame('overflow-update', {
subtype: 'task_updated',
task_id: 'overflow-turn',
patch: { status: 'failed' }
})
)
settleTurn(translator)
expect(projected(items())).toBe('idle')
translator.handle(
systemFrame('overflow-notification', {
subtype: 'task_notification',
task_id: 'overflow-turn',
status: 'stopped',
summary: 'No completion record was found'
})
)
expect(projected(items())).toBe('idle')
})
})
@@ -0,0 +1,43 @@
// Which Claude tool calls this session actually forwarded to the top-level
// transcript.
//
// A task announces the tool call that spawned it. That tool call is only
// evidence the user can act on when it was forwarded at the TOP level: a nested
// Task spawned from inside a subagent's sidechain names a tool id that exists
// only in that sidechain, and a row minted for it would claim a top-level
// invocation that never appeared. So admission asks this registry, and a task
// whose parent was never forwarded yields no row at all.
/** Event-accumulated and pruned by nothing, so bounded. Eviction is oldest
* first: a tool id old enough to fall out can no longer be the parent of a
* task announcement still in flight. */
const MAX_FORWARDED_TOOL_IDS = 512
export class ClaudeForwardedToolRegistry {
private readonly ids = new Set<string>()
/** Record a tool call forwarded at the top level. Nested traffic must not
* reach here — its caller checks `parent_tool_use_id` first. */
record(toolUseId: string): void {
if (toolUseId.length === 0) {
return
}
this.ids.delete(toolUseId)
this.ids.add(toolUseId)
while (this.ids.size > MAX_FORWARDED_TOOL_IDS) {
const oldest = this.ids.values().next()
if (oldest.done || oldest.value === toolUseId) {
break
}
this.ids.delete(oldest.value)
}
}
has(toolUseId: string): boolean {
return this.ids.has(toolUseId)
}
clear(): void {
this.ids.clear()
}
}
@@ -0,0 +1,156 @@
// Journaling ONE Claude message envelope: its body, tool calls, tool results,
// reasoning, and the unmodeled content that falls back to a generic row.
//
// Split out of the translator when that file reached its line budget. The body
// moved unchanged; the only edit is that what were closure variables are now
// read off an explicit context, so the open turn and the collaborators it
// writes through stay owned by the translator.
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeBackgroundTaskRows } from './claude-background-task-rows'
import type { ClaudeForwardedToolRegistry } from './claude-forwarded-tool-registry'
import {
claudeRecord,
claudeMessageBody,
claudeMessageIdentity,
claudeOutputEnvelope,
claudeThinkingIdentity,
claudeThinkingText,
claudeToolBody,
claudeToolIdentity,
claudeToolResults,
claudeToolUses,
readClaudeMessageEnvelope,
type ClaudeToolUse
} from './claude-structured-item-translation'
import {
appendUnmodeledContent,
type ClaudeProviderFrameFallback
} from './claude-structured-provider-fallback'
import type { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
import type { ClaudeSubagentRoster } from './claude-subagent-roster'
import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening'
import type { ClaudeOpenTurn } from './claude-open-turn'
export type ClaudeMessageJournalContext = {
sink: StructuredAgentSessionEventSink
tools: Map<string, ClaudeToolUse>
streamedBlocks: ReturnType<typeof createClaudeStreamedBlockRegistry>
streamedText: ReturnType<typeof createClaudeStreamedTextCheckpoints>
subagents: ClaudeSubagentRoster
forwardedTools: ClaudeForwardedToolRegistry
backgroundTasks: ClaudeBackgroundTaskRows
providerFallback: ClaudeProviderFrameFallback
/** The session's open turn. Sole owner of turn identity and of the reopen
* latch; this module asks it rather than tracking a copy. */
turn: ClaudeOpenTurn
}
export function journalClaudeMessage(
ctx: ClaudeMessageJournalContext,
message: Record<string, unknown>,
startsTurn: boolean,
observedAt: number,
/** Host clock on the submission that produced this send, when known. */
requestedAt?: number
): boolean {
const envelope = readClaudeMessageEnvelope(message)
if (!envelope) {
return false
}
let changed = false
if (envelope.parentToolUseId) {
ctx.subagents.observeChildActivity(envelope.parentToolUseId)
}
const outputEnvelope = claudeOutputEnvelope(envelope)
const body = claudeMessageBody(outputEnvelope)
const identity =
(body && envelope.role === 'assistant' ? ctx.streamedBlocks.reconcile(envelope) : null) ??
claudeMessageIdentity(envelope)
ctx.streamedText.forget(agentJournalItemKey(identity))
const thinking = claudeThinkingText(outputEnvelope)
const source: ClaudeTurnSource = {
sessionId: envelope.sessionId,
uuid: envelope.uuid,
assistant: envelope.role === 'assistant'
}
const openOutputTurn = (): void => ctx.turn.ensureOpen(message, source, observedAt)
if (body) {
// Opening before the append is what brackets a turn around its own first
// output; a reader that scans back to the turn record and stops would
// otherwise look straight past the row that opened it.
ctx.turn.ensureOpen(message, source, observedAt)
ctx.sink.appendItem(identity, body)
changed = true
}
for (const tool of claudeToolUses(outputEnvelope)) {
ctx.turn.ensureOpen(message, source, observedAt)
ctx.tools.set(tool.id, tool)
// Only a TOP-LEVEL call can be the parent of a top-level task row; a
// sidechain's own tool ids never reach the transcript.
if (!envelope.parentToolUseId) {
ctx.forwardedTools.record(tool.id)
}
ctx.sink.appendItem(claudeToolIdentity(envelope.sessionId, tool.id), claudeToolBody({ tool }))
changed = true
}
const results = claudeToolResults(envelope)
for (const result of results) {
const tool = ctx.tools.get(result.toolUseId) ?? {
id: result.toolUseId,
name: 'tool',
input: null
}
ctx.sink.appendItem(
claudeToolIdentity(envelope.sessionId, result.toolUseId),
claudeToolBody({ tool, result })
)
ctx.subagents.observeToolResult(result.toolUseId, result.failed)
if (
results.length === 1 &&
envelope.parentToolUseId === null &&
tool.name === 'Monitor' &&
ctx.forwardedTools.has(result.toolUseId)
) {
ctx.backgroundTasks.observeMonitorToolResult(claudeRecord(message.tool_use_result)?.taskId)
}
ctx.tools.delete(result.toolUseId)
changed = true
}
if (thinking) {
ctx.turn.ensureOpen(message, source, observedAt)
ctx.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'message',
role: 'reasoning',
blocks: [
{ type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
]
})
changed = true
}
changed =
appendUnmodeledContent(ctx.providerFallback, outputEnvelope, message, openOutputTurn) || changed
// The send's turn is anchored to the user row journaled just above it.
const sendEchoTurn = claudeTurnOpenedBySendEcho({
envelope,
frame: message,
startsTurn,
observedAt,
...(requestedAt === undefined ? {} : { requestedAt }),
userItemId: agentJournalItemKey(identity)
})
if (sendEchoTurn) {
ctx.turn.allowReopen()
ctx.turn.open(sendEchoTurn, observedAt)
}
if (changed) {
ctx.sink.publish()
}
return true
}
@@ -0,0 +1,92 @@
import {
finalizeClaudeBackgroundTaskRow,
newClaudeBackgroundTaskRow,
newClaudeBackgroundTaskRowFromNotification,
reviseClaudeBackgroundTaskRow,
type ClaudeBackgroundTaskChange,
type ClaudeBackgroundTaskRow
} from './claude-background-task-row-lifecycle'
import type { ClaudeBackgroundTaskLedgers } from './claude-background-task-memory'
const MAX_OVERFLOW_TERMINAL_ROWS = 512
/** Settled rows waiting for their final notification never consume a live task slot. */
export class ClaudeOverflowTerminalRows {
private readonly rows = new Map<string, ClaudeBackgroundTaskRow>()
constructor(
private readonly ledgers: ClaudeBackgroundTaskLedgers,
private readonly now: () => number,
private readonly write: (
id: string,
row: ClaudeBackgroundTaskRow,
openOutputTurn?: boolean
) => void
) {}
get size(): number {
return this.rows.size
}
observePatch(
id: string,
message: Record<string, unknown>,
change: ClaudeBackgroundTaskChange
): void {
if (this.rows.has(id)) {
return
}
const row = newClaudeBackgroundTaskRow(
id,
message,
this.now(),
this.ledgers.generations.next(id)
)
reviseClaudeBackgroundTaskRow(row, change, this.now())
this.rows.set(id, row)
this.write(id, row)
while (this.rows.size > MAX_OVERFLOW_TERMINAL_ROWS) {
const oldest = this.rows.keys().next()
if (oldest.done) {
break
}
this.rows.delete(oldest.value)
this.ledgers.fallbackTaskIds.delete(oldest.value)
this.ledgers.rememberForeign(oldest.value, 'terminal')
}
}
observeNotification(id: string, message: Record<string, unknown>): void {
const row = this.rows.get(id)
this.rows.delete(id)
this.ledgers.fallbackTaskIds.delete(id)
if (row) {
finalizeClaudeBackgroundTaskRow(row, message, this.now())
this.write(id, row, false)
} else {
this.writeNotification(id, message)
}
this.ledgers.rememberForeign(id, 'terminal')
}
observeNotificationWithoutSlot(id: string, message: Record<string, unknown>): void {
this.writeNotification(id, message)
this.ledgers.rememberForeign(id, 'terminal')
}
clear(): void {
this.rows.clear()
}
private writeNotification(id: string, message: Record<string, unknown>): void {
this.write(
id,
newClaudeBackgroundTaskRowFromNotification(
id,
message,
this.now(),
this.ledgers.generations.next(id)
)
)
}
}
@@ -37,6 +37,131 @@ function fakeChild(): ChildProcessWithoutNullStreams {
}
describe('Claude stream-json close ordering', () => {
it('stops pulling SDK messages until reading resumes', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValue(true)
const child = fakeChild()
const first = Promise.withResolvers<Record<string, unknown>>()
const next = vi
.fn<() => Promise<IteratorResult<Record<string, unknown>>>>()
.mockImplementationOnce(async () => ({ value: await first.promise, done: false }))
.mockResolvedValueOnce({ value: { type: 'second' }, done: false })
.mockResolvedValue({ value: undefined, done: true })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const seen: string[] = []
let connection: Awaited<ReturnType<typeof openClaudeStreamJsonConnection>>
connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{
onMessage: (message) => {
seen.push(String(message.type))
if (message.type === 'first') {
connection.pauseReading?.()
}
}
},
() => child,
queryImpl
)
first.resolve({ type: 'first' })
await vi.waitFor(() => expect(seen).toEqual(['first']))
await new Promise((resolve) => setImmediate(resolve))
expect(next).toHaveBeenCalledOnce()
connection.resumeReading?.()
await vi.waitFor(() => expect(seen).toEqual(['first', 'second']))
expect(next).toHaveBeenCalledTimes(3)
await expect(connection.close()).resolves.toBe(true)
})
it('releases a pulled frame when provider exit is reported', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValue(true)
const child = fakeChild()
const first = Promise.withResolvers<Record<string, unknown>>()
const next = vi
.fn<() => Promise<IteratorResult<Record<string, unknown>>>>()
.mockImplementationOnce(async () => ({ value: await first.promise, done: false }))
.mockResolvedValue({ value: undefined, done: true })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const events: string[] = []
const connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{
onMessage: (message) => events.push(`message:${String(message.type)}`),
onExit: () => events.push('exit')
},
() => child,
queryImpl
)
connection.pauseReading?.()
first.resolve({ type: 'task_notification' })
await vi.waitFor(() => expect(next).toHaveBeenCalledOnce())
expect(events).toEqual([])
child.emit('exit', 1, null)
await vi.waitFor(() => expect(events).toEqual(['exit', 'message:task_notification']))
await expect(connection.close()).resolves.toBe(true)
})
it('returns an unproven close without waiting on a live output reader', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValue(false)
const child = fakeChild()
const next = vi.fn(() => new Promise<IteratorResult<Record<string, unknown>>>(() => {}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{},
() => child,
queryImpl
)
await expect(connection.close()).resolves.toBe(false)
expect(next).toHaveBeenCalledOnce()
})
it('waits for the live tree refresh before ending stdin', async () => {
const refreshDone = Promise.withResolvers<void>()
mocks.refresh.mockReturnValueOnce(refreshDone.promise)
@@ -38,6 +38,10 @@ function loadClaudeAgentSdk(): Promise<typeof ClaudeAgentSdk> {
return claudeAgentSdk
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export type ClaudeStreamJsonLaunch = {
/** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */
pathToClaudeCodeExecutable: string
@@ -78,6 +82,8 @@ export type ClaudeStreamJsonConnection = ClaudeControlSurface & {
readonly closed: boolean
/** What the ladder has observed so far; read after a `close()` that returned false. */
readonly exitVerdict: ClaudeChildExitVerdict
pauseReading?: () => void
resumeReading?: () => void
send: (message: Record<string, unknown>) => Promise<void>
/** Resolves true after processless settlement, or root exit plus observed tree exit. */
close: () => Promise<boolean>
@@ -141,6 +147,23 @@ export async function openClaudeStreamJsonConnection(
let faultReported = false
let exitReported = false
let closePromise: Promise<boolean> | null = null
let readingBarrier: Promise<void> | null = null
let releaseReadingBarrier: (() => void) | null = null
const pauseReading = (): void => {
if (closing || exited || terminalError || readingBarrier) {
return
}
readingBarrier = new Promise<void>((resolve) => {
releaseReadingBarrier = resolve
})
}
const resumeReading = (): void => {
const release = releaseReadingBarrier
readingBarrier = null
releaseReadingBarrier = null
release?.()
}
const waitUntilReadable = (): Promise<void> => readingBarrier ?? Promise.resolve()
// One reaper per child: every close attempt and error-path reap shares its proof.
const rootSettled = (): boolean => exited || processless
const tree = createClaudeChildTreeReaper(child, { exited: rootSettled })
@@ -180,6 +203,7 @@ export async function openClaudeStreamJsonConnection(
})
const handleUnexpectedEnd = (cause?: Error): void => {
resumeReading()
terminalError ??= exitError(spawner.stderrTail, exitStatus, cause)
inbox.fail(terminalError)
if (!closing && !faultReported) {
@@ -192,18 +216,38 @@ export async function openClaudeStreamJsonConnection(
}
}
void (async () => {
for await (const message of session) {
handlers.onMessage?.(message as unknown as Record<string, unknown>)
const readerDone = (async () => {
try {
const iterator = session[Symbol.asyncIterator]()
let completed = false
try {
for (;;) {
await waitUntilReadable()
const next = await iterator.next()
if (next.done) {
completed = true
break
}
await waitUntilReadable()
if (!isRecord(next.value)) {
throw new Error('claude stream-json yielded a non-object message')
}
handlers.onMessage?.(next.value)
}
} finally {
if (!completed) {
await iterator.return?.()
}
}
} catch (error: unknown) {
// The SDK ends its generator in error when the child dies or the transport
// fails; a transport failure with a live child still has to reap the tree.
if (!closing && !exited) {
void tree.reap()
}
handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error)))
}
})().catch((error: unknown) => {
// The SDK ends its generator in error when the child dies or the transport
// fails; a transport failure with a live child still has to reap the tree.
if (!closing && !exited) {
void tree.reap()
}
handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error)))
})
})()
child.on('error', (error) => {
if (spawner.pid === undefined) {
@@ -251,6 +295,7 @@ export async function openClaudeStreamJsonConnection(
const close = (): Promise<boolean> => {
closePromise ??= (async () => {
closing = true
resumeReading()
// Arm the descendant proof before ending stdin. The SDK may exit the root
// immediately; a post-exit walk cannot recover descendants that reparented.
await (tree.refresh?.() ?? tree.capture())
@@ -264,8 +309,10 @@ export async function openClaudeStreamJsonConnection(
inbox.fail(new Error('claude stream-json connection closed'))
if (!proven) {
closePromise = null
return false
}
return proven
await readerDone
return true
})()
return closePromise
}
@@ -284,6 +331,8 @@ export async function openClaudeStreamJsonConnection(
tree: tree.treeVerdict
} as const
},
pauseReading,
resumeReading,
send,
close
}
@@ -0,0 +1,867 @@
import { describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink,
type StructuredAgentSessionEventTarget,
type StructuredAgentSessionLifecycleJournal
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { blockOf } from './claude-background-task-row-test-support'
// The frames below are the ones the reported session actually carried: two real
// failures that printed THREE red rows whose visible text was the wire opcode,
// two of them for the same task.
const TASK_ID = 'byjnee2no'
const SUMMARY = 'Background command "Wait for the verification verdict" failed with exit code 1'
function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null {
return identity.provider === 'orca' ? identity.clientMessageId : null
}
function harness() {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: vi.fn(),
publish: vi.fn()
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
const rowsWithPrefix = (prefix: string): AgentJournalItemBody[] =>
items
.filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith(prefix))
.map((item) => item.body)
const textOf = (body: AgentJournalItemBody): string => {
if (body.kind === 'status') {
return body.text
}
return body.kind === 'message'
? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join(' ')
: ''
}
return {
translator,
items,
/** Generic unknown-frame rows — the ones that printed `claude · <opcode>`. */
fallbackRows: () => rowsWithPrefix('provider-frame:').map(textOf),
taskRowIds: () =>
items
.map((item) => orcaClientMessageId(item.identity) ?? '')
.filter((id) => id.startsWith('claude-background-task:')),
taskRowTexts: () => rowsWithPrefix('claude-background-task:').map(textOf)
}
}
function systemFrame(fields: Record<string, unknown>) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: { type: 'system', session_id: 'claude-session', ...fields }
}
}
/** The assistant turn that invokes the spawn tool. Admission consults it: a task
* whose spawning tool never reached the transcript is a nested child, so every
* realistic sequence forwards this first. */
function spawnToolCall(
translator: ReturnType<typeof harness>['translator'],
toolUseId = 'toolu_01CqPd7y'
): void {
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: `assistant-${toolUseId}`,
session_id: 'claude-session',
parent_tool_use_id: null,
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'wait' } }]
}
}
})
}
function playFailedBackgroundCommand(translator: ReturnType<typeof harness>['translator']): void {
spawnToolCall(translator)
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: TASK_ID,
tool_use_id: 'toolu_01CqPd7y',
task_type: 'local_bash',
description: 'Wait for the verification verdict',
is_backgrounded: true
})
)
translator.handle(
systemFrame({
subtype: 'task_updated',
task_id: TASK_ID,
patch: { status: 'failed', end_time: 1_789_332_035_695 }
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: TASK_ID,
tool_use_id: 'toolu_01CqPd7y',
status: 'failed',
output_file: '/private/tmp/claude-501/tasks/byjnee2no.output',
summary: SUMMARY
})
)
}
function fillLiveTaskRows(translator: ReturnType<typeof harness>['translator']): void {
for (let index = 0; index < 64; index += 1) {
const toolUseId = `toolu-live-${index}`
spawnToolCall(translator, toolUseId)
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: `live-${index}`,
tool_use_id: toolUseId,
task_type: 'local_bash',
description: `live ${index}`,
is_backgrounded: true
})
)
}
}
function persistedTarget(
persisted: Map<string, AgentJournalItemBody>
): StructuredAgentSessionEventTarget {
const journal =
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test double implements the journal methods exercised by the deferred sink.
{
appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
persisted.set(agentJournalItemKey(identity), body)
return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 }
},
appendTombstone: vi.fn(),
visitItems: (
visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void
) => {
for (const [itemId, body] of persisted) {
visit(itemId, 0, body)
}
},
epoch: 'test'
} as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
}
describe('claude journal translation — background task rows', () => {
it('persists one terminal row at the hard watermark without an opcode fallback', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const appendEntered = Promise.withResolvers<void>()
const appendGate = Promise.withResolvers<void>()
const target = persistedTarget(persisted)
const appendItem = target.journal.appendItem.bind(target.journal)
vi.spyOn(target.journal, 'appendItem').mockImplementationOnce(async (...args) => {
appendEntered.resolve()
await appendGate.promise
return appendItem(...args)
})
const deferred = createDeferredStructuredAgentSessionEventSink({
watermarks: {
pauseQueuedOperations: 1,
maxQueuedOperations: 4,
lowQueuedOperations: 0,
maxQueuedBytes: 1_000_000
}
})
const translator = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'hard-watermark'
})
const providerResume = vi.fn()
let sinkPaused = false
deferred.sink.bindReadingControl?.({
pauseReading: () => {
sinkPaused = true
},
resumeReading: () => {
sinkPaused = false
const admission = translator.retryPendingTaskRows?.() ?? { accepted: true }
if (!sinkPaused && (admission.accepted || admission.reason !== 'backpressure')) {
providerResume()
}
}
})
deferred.bind(target)
deferred.sink.appendItem(
{ provider: 'orca', clientMessageId: 'blocked-prefill' },
{ kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }
)
await appendEntered.promise
const notification = systemFrame({
subtype: 'task_notification',
task_id: 'hard-watermark-task',
tool_use_id: 'toolu-hard-watermark',
status: 'failed',
summary: 'The real provider task failed',
uuid: 'hard-watermark-notification'
})
translator.handle(notification)
translator.handle(notification)
expect(deferred.state().queuedOperations).toBe(4)
expect(translator.retryPendingTaskRows?.()).toEqual({
accepted: false,
reason: 'backpressure'
})
expect(
[...persisted.values()].filter(
(body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task'
)
).toEqual([])
appendGate.resolve()
await vi.waitFor(() => expect(providerResume).toHaveBeenCalledOnce())
await expect(deferred.drained()).resolves.toEqual({ ok: true })
const taskRows = [...persisted.values()].filter(
(body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task'
)
expect(taskRows).toHaveLength(1)
expect(blockOf(taskRows[0])?.error).toBeUndefined()
expect(blockOf(taskRows[0])?.summary).toBe('The real provider task failed')
expect(
[...persisted.values()].some(
(body) =>
body.kind === 'status' && body.providerFrame?.kind.includes('task_notification') === true
)
).toBe(false)
})
it('coalesces an unbound overflow patch and aliased final notification', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const translator = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'test'
})
fillLiveTaskRows(translator)
translator.handle(
systemFrame({ subtype: 'task_started', task_id: 'queued-overflow', task_type: 'local_bash' })
)
translator.handle(
systemFrame({
subtype: 'task_updated',
task_id: 'queued-overflow',
patch: { status: 'failed' }
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'queued-overflow',
tool_use_id: 'toolu-final',
status: 'stopped',
summary: 'No completion record was found'
})
)
deferred.bind(persistedTarget(persisted))
await deferred.drained()
expect([...persisted.keys()].filter((key) => key.includes('queued-overflow'))).toEqual([
'orca:claude-background-task%3Aqueued-overflow'
])
expect(blockOf(persisted.get('orca:claude-background-task%3Aqueued-overflow'))).toMatchObject({
state: 'idle',
parentToolUseId: 'toolu-final',
summary: 'No completion record was found'
})
})
it('reconciles an aliased notification after a parentless overflow patch was persisted', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind(persistedTarget(persisted))
const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
fillLiveTaskRows(first)
first.handle(
systemFrame({ subtype: 'task_started', task_id: 'bound-overflow', task_type: 'local_bash' })
)
first.handle(
systemFrame({
subtype: 'task_updated',
task_id: 'bound-overflow',
patch: { status: 'failed' }
})
)
await deferred.drained()
const resumed = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'resumed'
})
resumed.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'bound-overflow',
tool_use_id: 'toolu-first',
status: 'stopped',
summary: 'No completion record was found'
})
)
await deferred.drained()
expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([
'orca:claude-background-task%3Abound-overflow'
])
expect(blockOf(persisted.get('orca:claude-background-task%3Abound-overflow'))).toMatchObject({
state: 'idle',
parentToolUseId: 'toolu-first'
})
const restarted = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'next'
})
spawnToolCall(restarted, 'toolu-second')
restarted.handle(
systemFrame({
subtype: 'task_started',
task_id: 'bound-overflow',
tool_use_id: 'toolu-second',
task_type: 'local_bash',
is_backgrounded: true
})
)
await deferred.drained()
expect([...persisted.keys()].filter((key) => key.includes('bound-overflow'))).toEqual([
'orca:claude-background-task%3Abound-overflow',
'orca:claude-background-task%3Abound-overflow%232'
])
})
it('resolves a queued restart identity after the sink rebinds', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind(persistedTarget(persisted))
const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
spawnToolCall(first, 'toolu-first')
first.handle(
systemFrame({
subtype: 'task_started',
task_id: 'queued-restart',
tool_use_id: 'toolu-first',
task_type: 'local_bash',
is_backgrounded: true
})
)
await deferred.drained()
first.dispose()
await deferred.drained()
const restarted = createDeferredStructuredAgentSessionEventSink()
const second = createClaudeJournalTranslator({
sink: restarted.sink,
fallbackIdPrefix: 'second'
})
spawnToolCall(second, 'toolu-second')
second.handle(
systemFrame({
subtype: 'task_started',
task_id: 'queued-restart',
tool_use_id: 'toolu-second',
task_type: 'local_bash',
is_backgrounded: true
})
)
restarted.bind(persistedTarget(persisted))
await restarted.drained()
expect([...persisted.keys()].filter((key) => key.includes('queued-restart'))).toEqual([
'orca:claude-background-task%3Aqueued-restart',
'orca:claude-background-task%3Aqueued-restart%232'
])
})
it('keeps pending writes from distinct runs when a translator is recreated', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const deferred = createDeferredStructuredAgentSessionEventSink()
const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: 'first' })
spawnToolCall(first, 'toolu-first')
first.handle(
systemFrame({
subtype: 'task_started',
task_id: 'queued-overlap',
tool_use_id: 'toolu-first',
task_type: 'local_bash',
is_backgrounded: true
})
)
first.dispose()
const second = createClaudeJournalTranslator({
sink: deferred.sink,
fallbackIdPrefix: 'second'
})
spawnToolCall(second, 'toolu-second')
second.handle(
systemFrame({
subtype: 'task_started',
task_id: 'queued-overlap',
tool_use_id: 'toolu-second',
task_type: 'local_bash',
is_backgrounded: true
})
)
deferred.bind(persistedTarget(persisted))
await deferred.drained()
expect([...persisted.keys()].filter((key) => key.includes('queued-overlap'))).toEqual([
'orca:claude-background-task%3Aqueued-overlap',
'orca:claude-background-task%3Aqueued-overlap%232'
])
})
it('does not overwrite a prior run when a new translator sees a reused task id', () => {
const persisted = new Map<string, AgentJournalItemBody>()
const journal: StructuredAgentSessionLifecycleJournal = {
epoch: '',
visitItems: (visit) => {
for (const [itemId, body] of persisted) {
visit(itemId, 0, body)
}
}
}
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => persisted.set(agentJournalItemKey(identity), body),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItem: (_identitySizeBound, body, resolveIdentity) => {
const identity = resolveIdentity(journal)
if (identity) {
persisted.set(agentJournalItemKey(identity), body)
}
return { accepted: true }
}
}
const first = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'first' })
spawnToolCall(first, 'toolu-first')
first.handle(
systemFrame({
subtype: 'task_started',
task_id: 'reused-after-reconnect',
tool_use_id: 'toolu-first',
task_type: 'local_bash',
is_backgrounded: true
})
)
first.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'reused-after-reconnect',
tool_use_id: 'toolu-first',
status: 'failed',
summary: 'first run failed'
})
)
first.dispose()
const resumed = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'resumed' })
spawnToolCall(resumed, 'toolu-first')
resumed.handle(
systemFrame({
subtype: 'task_started',
task_id: 'reused-after-reconnect',
tool_use_id: 'toolu-first',
task_type: 'local_bash',
is_backgrounded: true
})
)
expect([...persisted.keys()].filter((key) => key.includes('reused-after-reconnect'))).toEqual([
'orca:claude-background-task%3Areused-after-reconnect'
])
resumed.dispose()
const second = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'second' })
spawnToolCall(second, 'toolu-second')
second.handle(
systemFrame({
subtype: 'task_started',
task_id: 'reused-after-reconnect',
tool_use_id: 'toolu-second',
task_type: 'local_bash',
is_backgrounded: true
})
)
const rows = [...persisted.entries()].filter(([key]) => key.includes('reused-after-reconnect'))
expect(rows.map(([key]) => key)).toEqual([
'orca:claude-background-task%3Areused-after-reconnect',
'orca:claude-background-task%3Areused-after-reconnect%232'
])
})
it('prints the provider sentence once instead of the opcode twice', () => {
const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness()
playFailedBackgroundCommand(translator)
// ABLATION: drop `message:system:task_*` from CLAUDE_TYPED_TRANSLATOR_KINDS
// and this is `['claude · message:system:task_updated', 'claude ·
// message:system:task_notification']` — the reported bug exactly.
expect(fallbackRows()).toEqual([])
// One durable row for one task, however many frames reported it.
expect(new Set(taskRowIds())).toEqual(new Set([`claude-background-task:${TASK_ID}`]))
expect(taskRowTexts().at(-1)).toBe(SUMMARY)
})
it('reports a failure for a task nothing in this transcript ever admitted', () => {
// The captured frame from a session where NOTHING rendered: the typed path
// declined the row and told the fallback the frame was covered, so the
// failure reached neither surface.
const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness()
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'bjzenpq13',
tool_use_id: 'toolu_01ASNfnDBEzt4w3ejLE12bGu',
status: 'failed',
output_file: '',
summary: 'Locate the exact screenshot session',
uuid: '1d748563-5741-4aa8-9c21-7023b90bc737'
})
)
expect(taskRowIds()).toEqual(['claude-background-task:bjzenpq13'])
expect(taskRowTexts().at(-1)).toBe('Locate the exact screenshot session')
// One row, not two: the typed row is real coverage, so the generic fallback
// stays quiet beside it rather than printing the opcode.
expect(fallbackRows()).toEqual([])
})
it('keeps the aggregate roster frame off the transcript even when it carries a failure', () => {
const { translator, fallbackRows, taskRowIds } = harness()
translator.handle(
systemFrame({
subtype: 'background_tasks_changed',
tasks: [{ task_id: TASK_ID, status: 'failed', task_type: 'local_bash' }]
})
)
// It promotes through the payload sniffer exactly as the per-task frames do,
// and it creates no row of its own: the task's own frames own that.
expect(fallbackRows()).toEqual([])
expect(taskRowIds()).toEqual([])
})
it('still surfaces an unmodelled failed frame through the generic fallback', () => {
const { translator, fallbackRows } = harness()
translator.handle(systemFrame({ subtype: 'future_event', status: 'failed' }))
// The coverage contract is per-kind, so nothing about it weakens the payload
// sniffer for the kinds nobody has modelled.
expect(fallbackRows()).toEqual(['claude · message:system:future_event'])
})
it('falls back visibly when a malformed task frame reports a failure', () => {
const { translator, fallbackRows, taskRowIds } = harness()
translator.handle(
systemFrame({
subtype: 'task_notification',
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
})
)
expect(taskRowIds()).toEqual([])
expect(fallbackRows()).toEqual(['Background command "Wait" failed with exit code 1'])
})
it('keeps a refused live task failure on one typed terminal row', () => {
const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness()
fillLiveTaskRows(translator)
spawnToolCall(translator, 'toolu-overflow')
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: 'overflow-fallback',
tool_use_id: 'toolu-overflow',
task_type: 'local_bash',
description: 'overflow task',
is_backgrounded: true
})
)
translator.handle(
systemFrame({
subtype: 'task_updated',
task_id: 'overflow-fallback',
patch: { status: 'failed' }
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'overflow-fallback',
tool_use_id: 'toolu-overflow',
status: 'failed',
summary: 'overflow failed'
})
)
translator.handle(
systemFrame({
subtype: 'task_progress',
task_id: 'overflow-fallback',
usage: { total_tokens: 3 }
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'overflow-fallback',
status: 'failed',
summary: 'duplicate overflow failed'
})
)
expect(fallbackRows()).toEqual([])
expect(
taskRowIds().filter((id) => id === 'claude-background-task:overflow-fallback')
).toHaveLength(2)
expect(taskRowTexts().at(-1)).toBe('overflow failed')
})
it('prints a notification-first failure only once when every typed slot is live', () => {
const { translator, fallbackRows, taskRowIds } = harness()
fillLiveTaskRows(translator)
const notification = systemFrame({
subtype: 'task_notification',
task_id: 'orphan-overflow',
status: 'failed',
summary: 'orphan overflow failed'
})
translator.handle(notification)
translator.handle(notification)
expect(fallbackRows()).toEqual([])
expect(
taskRowIds().filter((id) => id === 'claude-background-task:orphan-overflow')
).toHaveLength(1)
})
it('keeps fallback ownership when a finished overflow task redelivers its start', () => {
const { translator, fallbackRows, taskRowIds } = harness()
fillLiveTaskRows(translator)
spawnToolCall(translator, 'toolu-overflow')
const start = systemFrame({
subtype: 'task_started',
task_id: 'overflow-redelivery',
tool_use_id: 'toolu-overflow',
task_type: 'local_bash',
is_backgrounded: true
})
const notification = systemFrame({
subtype: 'task_notification',
task_id: 'overflow-redelivery',
tool_use_id: 'toolu-overflow',
status: 'failed',
summary: 'overflow redelivery failed'
})
translator.handle(start)
translator.handle(notification)
translator.handle(start)
translator.handle(
systemFrame({ subtype: 'task_notification', task_id: 'live-0', status: 'completed' })
)
translator.handle(notification)
expect(fallbackRows()).toEqual([])
expect(
taskRowIds().filter((id) => id === 'claude-background-task:overflow-redelivery')
).toHaveLength(1)
})
it.each(['completed', 'stopped'])(
'reports a capacity-refused %s task without a generic frame row',
(status) => {
const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness()
fillLiveTaskRows(translator)
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: 'overflow-success',
task_type: 'local_bash',
is_backgrounded: true
})
)
translator.handle(
systemFrame({
subtype: 'task_updated',
task_id: 'overflow-success',
patch: { status }
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'overflow-success',
status,
summary: `task ${status}`
})
)
expect(fallbackRows()).toEqual([])
expect(
taskRowIds().filter((id) => id === 'claude-background-task:overflow-success')
).toHaveLength(2)
expect(taskRowTexts().at(-1)).toBe(`task ${status}`)
}
)
it('settles live background rows when the provider ends before disposal', () => {
const { translator, taskRowTexts } = harness()
spawnToolCall(translator)
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: TASK_ID,
tool_use_id: 'toolu_01CqPd7y',
task_type: 'local_bash',
description: 'Wait for the verification verdict',
is_backgrounded: true
})
)
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(taskRowTexts().at(-1)).toBe(
'Background command "Wait for the verification verdict" stopped reporting'
)
})
it('keeps a nested child spawned inside a sidechain off the top-level transcript', () => {
const { translator, taskRowIds, fallbackRows } = harness()
// The spawn tool call is emitted by a SUBAGENT, so it carries a parent tool
// id and is never a top-level invocation.
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'nested-assistant',
session_id: 'claude-session',
parent_tool_use_id: 'toolu_parent_agent',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 'toolu_sidechain', name: 'Bash', input: { command: 'wait' } }
]
}
}
})
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: 'nested-1',
tool_use_id: 'toolu_sidechain',
task_type: 'local_bash',
description: 'nested work',
is_backgrounded: true
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'nested-1',
tool_use_id: 'toolu_sidechain',
status: 'failed',
summary: 'nested child failed'
})
)
expect(taskRowIds()).toEqual([])
expect(fallbackRows()).toEqual([])
})
it('keeps a monitor off the timeline entirely', () => {
const { translator, taskRowIds, fallbackRows } = harness()
spawnToolCall(translator)
translator.handle(
systemFrame({
subtype: 'task_started',
task_id: 'monitor-1',
tool_use_id: 'toolu_01CqPd7y',
task_type: 'monitor',
description: 'Watch the build',
is_backgrounded: true
})
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: 'monitor-1',
tool_use_id: 'toolu_01CqPd7y',
status: 'failed',
summary: 'monitor stopped'
})
)
expect(taskRowIds()).toEqual([])
expect(fallbackRows()).toEqual([])
})
it('keeps a monitor without a start frame owned by its top-level tool result', () => {
const { translator, taskRowIds, fallbackRows } = harness()
const toolUseId = 'toolu-monitor'
const taskId = 'bm5w1s2mv'
translator.handle({
type: 'message',
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'monitor-call',
session_id: 'claude-session',
parent_tool_use_id: null,
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: toolUseId, name: 'Monitor', input: { persistent: true } }
]
}
}
})
translator.handle({
type: 'message',
sessionId: 'orca-session',
message: {
type: 'user',
uuid: 'monitor-result',
session_id: 'claude-session',
parent_tool_use_id: null,
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'Monitor started' }]
},
tool_use_result: { taskId, timeoutMs: 0, persistent: true }
}
})
translator.handle(
systemFrame({ subtype: 'task_updated', task_id: taskId, patch: { status: 'completed' } })
)
translator.handle(
systemFrame({
subtype: 'task_notification',
task_id: taskId,
tool_use_id: '',
status: 'completed',
summary: 'Monitor event: workflow journal results'
})
)
expect(taskRowIds()).toEqual([])
expect(fallbackRows()).toEqual([])
})
})
@@ -1,47 +1,36 @@
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import {
claudeMessageBody,
claudeMessageIdentity,
claudeOutputEnvelope,
claudeStreamingMessageBody,
claudeThinkingIdentity,
claudeThinkingText,
claudeToolBody,
claudeToolIdentity,
claudeToolResults,
claudeToolUses,
readClaudeMessageEnvelope,
type ClaudeToolUse
} from './claude-structured-item-translation'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
appendUnmodeledContent,
claudeProviderFrameKind,
claudeResultFailure,
createClaudeProviderFrameFallback,
isSettledClaudeResultKind
} from './claude-structured-provider-fallback'
import { taskFrameSentence } from './claude-background-task-frames'
import { ClaudeBackgroundTaskRows } from './claude-background-task-rows'
import { ClaudeForwardedToolRegistry } from './claude-forwarded-tool-registry'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
import {
claudeStreamTurnStartSource,
claudeStreamTurnSource,
claudeTurnOpenedBySendEcho,
isRootClaudeFrame,
type ClaudeTurnSource
isRootClaudeFrame
} from './claude-turn-opening'
import { claudeTurnEndForResult } from './claude-turn-lifecycle-item'
import { ClaudeOpenTurn } from './claude-open-turn'
import { ClaudeJournalPrompts } from './claude-structured-journal-prompts'
import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling'
export type ClaudeJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
@@ -49,6 +38,7 @@ export type ClaudeJournalTranslatorDeps = {
coalesceMs?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
fallbackIdPrefix?: string
onBackgroundTaskJournalFailure?: (error: Error) => void
}
export type ClaudeJournalTranslator = {
@@ -58,6 +48,7 @@ export type ClaudeJournalTranslator = {
* a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */
readonly currentTurnId: string | null
flush: () => void
retryPendingTaskRows?: () => StructuredAgentSessionSinkAdmission
/** Streamed blocks still awaiting a final frame. A settled turn leaves none. */
readonly pendingStreamedBlocks: number
dispose: () => void
@@ -66,12 +57,14 @@ export type ClaudeJournalTranslator = {
export function createClaudeSessionJournalTranslator(
sink: StructuredAgentSessionEventSink | undefined,
prompts: ClaudePromptRegistry,
fallbackIdPrefix: string
fallbackIdPrefix: string,
onBackgroundTaskJournalFailure?: (error: Error) => void
): ClaudeJournalTranslator | null {
return sink
? createClaudeJournalTranslator({
sink,
fallbackIdPrefix,
...(onBackgroundTaskJournalFailure ? { onBackgroundTaskJournalFailure } : {}),
bindPromptItemId: (itemId, promptKey, questionId) =>
prompts.bindJournalItemId(itemId, promptKey, questionId)
})
@@ -96,6 +89,18 @@ export function createClaudeJournalTranslator(
sink: deps.sink,
currentGroupKey: () => turn.groupKey
})
const forwardedTools = new ClaudeForwardedToolRegistry()
const backgroundTasks = new ClaudeBackgroundTaskRows({
sink: deps.sink,
isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId),
// A typed task row is provider output: journaling one must open a resumed
// turn, or the session shows the row while reading idle.
openOutputTurn: (frame, observedAt) =>
turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt),
...(deps.onBackgroundTaskJournalFailure
? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure }
: {})
})
const streamedText = createClaudeStreamedTextCheckpoints({
...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
...(deps.schedule ? { schedule: deps.schedule } : {}),
@@ -129,106 +134,32 @@ export function createClaudeJournalTranslator(
return true
}
const messageContext: ClaudeMessageJournalContext = {
sink: deps.sink,
tools,
streamedBlocks,
streamedText,
subagents,
forwardedTools,
backgroundTasks,
providerFallback,
turn
}
const handleMessage = (
message: Record<string, unknown>,
startsTurn: boolean,
observedAt: number,
requestedAt?: number
): boolean => {
const envelope = readClaudeMessageEnvelope(message)
if (!envelope) {
return false
}
let changed = false
if (envelope.parentToolUseId) {
subagents.observeChildActivity(envelope.parentToolUseId)
}
const outputEnvelope = claudeOutputEnvelope(envelope)
const body = claudeMessageBody(outputEnvelope)
// The final frame of a streamed block lands on the block's identity, not its own uuid.
const identity =
(body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
claudeMessageIdentity(envelope)
streamedText.forget(agentJournalItemKey(identity))
const thinking = claudeThinkingText(outputEnvelope)
const source: ClaudeTurnSource = {
sessionId: envelope.sessionId,
uuid: envelope.uuid,
assistant: envelope.role === 'assistant'
}
const openOutputTurn = (): void => turn.ensureOpen(message, source, observedAt)
if (body) {
// Opening before the append is what brackets a turn around its own first
// output; a reader that scans back to the turn record and stops would
// otherwise look straight past the row that opened it.
turn.ensureOpen(message, source, observedAt)
deps.sink.appendItem(identity, body)
changed = true
}
for (const tool of claudeToolUses(outputEnvelope)) {
turn.ensureOpen(message, source, observedAt)
tools.set(tool.id, tool)
deps.sink.appendItem(
claudeToolIdentity(envelope.sessionId, tool.id),
claudeToolBody({ tool })
)
changed = true
}
for (const result of claudeToolResults(envelope)) {
const tool = tools.get(result.toolUseId) ?? {
id: result.toolUseId,
name: 'tool',
input: null
}
deps.sink.appendItem(
claudeToolIdentity(envelope.sessionId, result.toolUseId),
claudeToolBody({ tool, result })
)
// A spawn call's result is the parent turn's evidence its child finished.
subagents.observeToolResult(result.toolUseId, result.failed)
// Tool inputs are only needed until their matching result arrives.
tools.delete(result.toolUseId)
changed = true
}
if (thinking) {
turn.ensureOpen(message, source, observedAt)
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'message',
role: 'reasoning',
blocks: [
{ type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
]
})
changed = true
}
changed =
appendUnmodeledContent(providerFallback, outputEnvelope, message, openOutputTurn) || changed
// The send's turn is anchored to the user row journaled just above it.
const sendEchoTurn = claudeTurnOpenedBySendEcho({
envelope,
frame: message,
startsTurn,
observedAt,
...(requestedAt === undefined ? {} : { requestedAt }),
userItemId: agentJournalItemKey(identity)
})
if (sendEchoTurn) {
turn.allowReopen()
turn.open(sendEchoTurn, observedAt)
}
if (changed) {
deps.sink.publish()
}
return true
}
): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt)
return {
handle: (event) => {
if (event.type === 'ended') {
prompts.retryPendingCancellations()
streamedText.flush()
// No event will ever settle a child once the provider is gone.
subagents.settleSession()
backgroundTasks.settleSession()
// The host saw the child end, so the turn's end is observed, not lost.
turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() })
// A frame that arrives after the child is gone must not open a turn no
@@ -265,15 +196,16 @@ export function createClaudeJournalTranslator(
streamedText.settle()
}
const kind = claudeProviderFrameKind(event.message)
// Ordinary turn bookkeeping stays suppressed; a reported failure never does.
const failure = claudeResultFailure(event.message)
if (failure || !isSettledClaudeResultKind(kind)) {
providerFallback.append(kind, event.message, failure?.text)
}
} else if (event.type === 'message') {
// These frames stay `status-chrome`: the roster reads them here, and the
// fallback below still drops the raw frame instead of printing an opcode.
subagents.observeSystemFrame(event.message)
const backgroundTaskCovered = backgroundTasks.observe(
event.message,
event.observedAt ?? Date.now()
)
const kind = claudeProviderFrameKind(event.message)
if (
!handleMessage(
@@ -283,7 +215,13 @@ export function createClaudeJournalTranslator(
event.requestedAt
)
) {
providerFallback.append(kind, event.message)
providerFallback.append(
kind,
event.message,
taskFrameSentence(event.message),
undefined,
{ coveredByTypedTranslator: backgroundTaskCovered }
)
}
publishActivity(kind, event.message)
} else if (event.type === 'provider-frame') {
@@ -296,6 +234,7 @@ export function createClaudeJournalTranslator(
return turn.id
},
flush: streamedText.flush,
retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(),
get pendingStreamedBlocks() {
return streamedText.pending
},
@@ -305,6 +244,8 @@ export function createClaudeJournalTranslator(
prompts.clear()
streamedBlocks.clear()
subagents.dispose()
backgroundTasks.dispose()
forwardedTools.clear()
}
}
}
@@ -5,6 +5,7 @@ import {
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
import {
type UnhandledProviderFrameJournalItemOptions,
readableProviderFrameText,
unhandledProviderFrameJournalItem
} from '../native-chat/agent-session-wire/unhandled-provider-frame'
@@ -110,14 +111,23 @@ export function createClaudeProviderFrameFallback(
kind: string,
payload: unknown,
displayText?: string | null,
beforeAppend?: () => void
/** Runs only when a row is actually going to be written, so a frame that
* translates to nothing never opens a turn. */
beforeAppend?: () => void,
options?: UnhandledProviderFrameJournalItemOptions
) => boolean
} {
let sequence = 0
return {
append: (kind, payload, displayText, beforeAppend) => {
append: (kind, payload, displayText, beforeAppend, options) => {
sequence += 1
const translated = unhandledProviderFrameJournalItem('claude', kind, payload)
const translated = unhandledProviderFrameJournalItem(
'claude',
kind,
payload,
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
options
)
if (!translated) {
return false
}
@@ -47,6 +47,10 @@ import { resolveClaudeAcquisitionError } from './claude-structured-session-close
import { readClaudeTranscriptEntryUuid } from './claude-tui-exit'
import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation'
import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch'
import {
bindClaudeJournalReadingControl,
createClaudeJournalFailureHandler
} from './claude-structured-session-journal-control'
export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000
@@ -72,12 +76,8 @@ export async function acquireClaudeSession({
}
const sessionId = input.identity.sessionId
const prompts = new ClaudePromptRegistry()
const translator = createClaudeSessionJournalTranslator(
input.events,
prompts,
String(input.fence)
)
const { previous, attempt } = acquisitions.start(sessionId, prompts)
let unbindReadingControl: (() => void) | undefined
let liveSession: ClaudeSession | null = null
let observedLeafUuid: string | null = null,
expectedProviderSessionId: string | null = null
@@ -85,6 +85,12 @@ export async function acquireClaudeSession({
// this acquisition owns. Keep the check ahead of every stateful consumer.
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
const translator = createClaudeSessionJournalTranslator(
input.events,
prompts,
String(input.fence),
createClaudeJournalFailureHandler({ attempt, initDeadline, callbacks, sessionId })
)
const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved)
const onMessage = (message: Record<string, unknown>): void => {
@@ -198,6 +204,7 @@ export async function acquireClaudeSession({
)
)
attempt.connection = connection
unbindReadingControl = bindClaudeJournalReadingControl(input.events, connection, translator)
acquisitions.assertCurrent(sessionId, attempt)
initDeadline.start()
const [initialization, init] = await withAgentSessionCreatePhase(
@@ -259,6 +266,7 @@ export async function acquireClaudeSession({
prompts,
translator,
events: input.events,
...(unbindReadingControl ? { unbindReadingControl } : {}),
process,
acquisitionGeneration: mintClaudeAcquisitionGeneration(deps),
options: acquisitionOptions.options,
@@ -284,6 +292,7 @@ export async function acquireClaudeSession({
return acquired
} catch (error) {
initDeadline.clear()
unbindReadingControl?.()
const acquisitionError = await resolveClaudeAcquisitionError({
error,
sessionId,
@@ -26,7 +26,10 @@ import {
closeClaudeSession,
settleClaudeExitedSession
} from './claude-structured-session-close'
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
import {
drainClaudeObservedExits,
persistClaudeSessionHandle
} from './claude-structured-session-exit-lifecycle'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window'
import {
@@ -80,7 +83,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
attempt.buffered.push(event)
return
}
if (this.sessions.get(sessionId)?.connection === attempt.connection) {
if (
this.sessions.get(sessionId)?.connection === attempt.connection ||
this.exits.get(sessionId)?.connection === attempt.connection
) {
event()
}
}
@@ -103,7 +109,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
this.exits.set(sessionId, exit)
exit.publication = closePromise
.then((proven) => (proven ? this.settleUnexpectedExit(sessionId, exit) : undefined))
.then((proven) => {
if (!proven) {
return undefined
}
return this.settleUnexpectedExit(sessionId, exit)
})
.catch(() => undefined)
}
@@ -112,37 +123,19 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
* retry. Publication trails observation by the close ladder and the
* transcript cursor write, so nothing outside can otherwise tell the two
* apart without guessing at wall-clock. */
drainObservedExits = async (): Promise<void> => {
const awaited = new Set<Promise<void>>()
for (;;) {
const pending = [...this.exits.values()]
.map((exit) => exit.publication)
.filter(
(publication): publication is Promise<void> =>
publication !== undefined && !awaited.has(publication)
)
if (pending.length === 0) {
return
}
for (const publication of pending) {
awaited.add(publication)
}
// A publication can settle an exit that itself observes another; only the
// ones this pass has not already awaited keep the loop going.
await Promise.all(pending)
}
}
drainObservedExits = (): Promise<void> => drainClaudeObservedExits(this.exits)
/** Lifecycle recovery is published only after the child tree proof is true. */
private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise<void> {
exit.settlementPromise ??= (async () => {
exit.session.unbindReadingControl?.()
if (this.exits.get(sessionId) !== exit) {
settleClaudeExitedSession(exit.session)
return
}
// Persist the transcript-derived cursor before publishing the lifecycle
// event that lets the host release and reacquire this exact child.
await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined)
await persistClaudeSessionHandle(sessionId, exit.session, this.deps).catch(() => undefined)
if (this.exits.get(sessionId) !== exit) {
settleClaudeExitedSession(exit.session)
return
@@ -177,30 +170,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId)
})
private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise<void> {
try {
const transcriptLeaf = this.deps.readTranscriptLeaf
? await readClaudeTranscriptLeafWithReproof({
readTranscriptLeaf: this.deps.readTranscriptLeaf,
providerSessionId: session.providerSessionId,
previousLeafUuid: session.leafUuid,
claudeConfigDir: session.claudeConfigDir
})
: null
if (transcriptLeaf) {
session.leafUuid = transcriptLeaf
}
} catch {
// A stale or unavailable tail must not overwrite the last observed leaf.
}
await this.deps.persistHandle?.({
sessionId,
providerSessionId: session.providerSessionId,
leafUuid: session.leafUuid,
fence: session.fence
})
}
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
const backgroundTasksChanged =
event.type === 'ended'
@@ -97,7 +97,9 @@ async function finalizeClaudePublishedSession(
for (const prompt of session.prompts.clear()) {
prompt.settle(null)
}
if ((await session.connection.close()) !== true) {
const connectionClosed = await session.connection.close()
session.unbindReadingControl?.()
if (connectionClosed !== true) {
const cleanupError = claudeAcquisitionCleanupError(
session.connection,
new Error('provider close unproven')
@@ -0,0 +1,56 @@
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
import type {
ClaudeSession,
ClaudeSessionExit,
ClaudeStructuredSessionAdapterDeps
} from './claude-structured-session-state'
/** Wait for each first-hand exit's publication, including exits observed while waiting. */
export async function drainClaudeObservedExits(
exits: Map<string, ClaudeSessionExit>
): Promise<void> {
const awaited = new Set<Promise<void>>()
for (;;) {
const pending = [...exits.values()]
.map((exit) => exit.publication)
.filter(
(publication): publication is Promise<void> =>
publication !== undefined && !awaited.has(publication)
)
if (pending.length === 0) {
return
}
for (const publication of pending) {
awaited.add(publication)
}
await Promise.all(pending)
}
}
export async function persistClaudeSessionHandle(
sessionId: string,
session: ClaudeSession,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf' | 'persistHandle'>
): Promise<void> {
try {
const transcriptLeaf = deps.readTranscriptLeaf
? await readClaudeTranscriptLeafWithReproof({
readTranscriptLeaf: deps.readTranscriptLeaf,
providerSessionId: session.providerSessionId,
previousLeafUuid: session.leafUuid,
claudeConfigDir: session.claudeConfigDir
})
: null
if (transcriptLeaf) {
session.leafUuid = transcriptLeaf
}
} catch {
// An unavailable tail must not overwrite the last observed leaf.
}
await deps.persistHandle?.({
sessionId,
providerSessionId: session.providerSessionId,
leafUuid: session.leafUuid,
fence: session.fence
})
}
@@ -0,0 +1,53 @@
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
import type { createClaudeInitDeadline } from './claude-structured-init-deadline'
import type {
ClaudeAcquisitionAttempt,
ClaudeAcquireCallbacks
} from './claude-structured-session-state'
export function createClaudeJournalFailureHandler(input: {
attempt: ClaudeAcquisitionAttempt
initDeadline: ReturnType<typeof createClaudeInitDeadline>
callbacks: ClaudeAcquireCallbacks
sessionId: string
}): (error: Error) => void {
return (error) => {
if (!input.attempt.published) {
input.initDeadline.reject(error)
return
}
const connection = input.attempt.connection
if (connection) {
void connection
.close()
.catch(() => false)
.finally(() => input.callbacks.handleExit(input.sessionId, input.attempt, error))
}
}
}
export function bindClaudeJournalReadingControl(
sink: StructuredAgentSessionEventSink | undefined,
connection: ClaudeStreamJsonConnection,
translator: ClaudeJournalTranslator | null
): (() => void) | undefined {
if (!connection.pauseReading || !connection.resumeReading) {
return undefined
}
let sinkPaused = false
return sink?.bindReadingControl?.({
pauseReading: () => {
sinkPaused = true
connection.pauseReading?.()
},
resumeReading: () => {
sinkPaused = false
const retried = translator?.retryPendingTaskRows?.() ?? { accepted: true }
if (!sinkPaused && (retried.accepted || retried.reason !== 'backpressure')) {
connection.resumeReading?.()
}
}
})
}
@@ -19,6 +19,7 @@ export function createClaudeSessionPublication(input: {
prompts: ClaudePromptRegistry
translator: ClaudeJournalTranslator | null
events: ClaudeSession['events']
unbindReadingControl?: () => void
process: AgentSessionAcquisition['process']
linkId?: string
observedAt: number
@@ -83,7 +84,8 @@ export function createClaudeSessionPublication(input: {
]),
restoreSkippedOptions: new Set(),
translator: input.translator,
events: input.events
events: input.events,
...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {})
}
}
}
@@ -0,0 +1,263 @@
import { describe, expect, it, vi } from 'vitest'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventTarget,
type StructuredAgentSessionEventSink,
type StructuredAgentSessionReadingControl
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { blockOf } from './claude-background-task-row-test-support'
import {
adapterFor,
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
function controlledSink(): {
sink: StructuredAgentSessionEventSink
control: () => StructuredAgentSessionReadingControl | undefined
unbind: ReturnType<typeof vi.fn>
} {
let control: StructuredAgentSessionReadingControl | undefined
const unbind = vi.fn()
return {
sink: {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
bindReadingControl: (next) => {
control = next
return unbind
}
},
control: () => control,
unbind
}
}
function persistedTarget(
persisted: Map<string, AgentJournalItemBody>
): StructuredAgentSessionEventTarget {
const journal =
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test double implements the journal methods exercised by the deferred sink.
{
appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
persisted.set(agentJournalItemKey(identity), body)
return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 }
},
appendTombstone: vi.fn(),
visitItems: (
visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void
) => {
for (const [itemId, body] of persisted) {
visit(itemId, 0, body)
}
},
epoch: 'test'
} as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
}
describe('Claude structured reading control', () => {
it('binds sink pressure to SDK reading and unbinds on requested close', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const events = controlledSink()
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: events.sink
})
const pauseReading = vi.spyOn(claude.connections[0], 'pauseReading')
const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading')
events.control()?.pauseReading()
expect(pauseReading).toHaveBeenCalledOnce()
events.control()?.resumeReading()
expect(resumeReading).toHaveBeenCalledOnce()
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
expect(events.unbind).toHaveBeenCalledOnce()
})
it('unbinds when acquisition fails after the connection opens', async () => {
const claude = fakeClaude({ initProof: 'none' })
const adapter = adapterFor(claude, {}, [], [], 1)
const events = controlledSink()
await expect(
adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: events.sink
})
).rejects.toThrow('did not finish starting')
expect(events.unbind).toHaveBeenCalledOnce()
})
it('unbinds when the published provider exits unexpectedly', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const events = controlledSink()
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: events.sink
})
claude.connections[0].handlers.onExit?.(new Error('provider exited'))
await adapter.drainObservedExits()
expect(events.unbind).toHaveBeenCalledOnce()
})
it('keeps delivery ownership until a reported provider exit finishes closing', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const events = controlledSink()
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: events.sink
})
const connection = claude.connections[0]
connection.handlers.onExit?.(new Error('provider exited'))
connection.handlers.onMessage?.({
type: 'system',
subtype: 'task_notification',
session_id: PROVIDER_SESSION_ID,
task_id: 'held-terminal-frame',
status: 'failed',
summary: 'The final task outcome'
})
expect(events.sink.appendItem).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
kind: 'message',
role: 'system',
blocks: expect.arrayContaining([
expect.objectContaining({
type: 'background-task',
taskId: 'held-terminal-frame',
summary: 'The final task outcome'
})
])
}),
expect.anything()
)
await adapter.drainObservedExits()
expect(events.unbind).toHaveBeenCalledOnce()
})
it('releases SDK reading when a pending row becomes permanently refused', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const events = controlledSink()
events.sink.tryAppendResolvedItemAndPublish = vi
.fn()
.mockReturnValueOnce({ accepted: false, reason: 'backpressure' })
.mockReturnValueOnce({ accepted: false, reason: 'failed' })
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: events.sink
})
const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading')
claude.connections[0].handlers.onMessage?.({
type: 'system',
subtype: 'task_notification',
session_id: PROVIDER_SESSION_ID,
task_id: 'failed-journal-row',
status: 'failed',
summary: 'failed'
})
events.control()?.pauseReading()
events.control()?.resumeReading()
expect(resumeReading).toHaveBeenCalledOnce()
await adapter.drainObservedExits()
})
it('automatically retries a hard-watermark row before resuming SDK reads', async () => {
const persisted = new Map<string, AgentJournalItemBody>()
const target = persistedTarget(persisted)
const deferred = createDeferredStructuredAgentSessionEventSink({
watermarks: {
pauseQueuedOperations: 1,
maxQueuedOperations: 4,
lowQueuedOperations: 0,
maxQueuedBytes: 1_000_000
}
})
deferred.bind(target)
const claude = fakeClaude()
const adapter = adapterFor(claude)
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
events: deferred.sink
})
await deferred.drained()
persisted.clear()
const appendEntered = Promise.withResolvers<void>()
const appendGate = Promise.withResolvers<void>()
const appendItem = target.journal.appendItem.bind(target.journal)
vi.spyOn(target.journal, 'appendItem').mockImplementationOnce(async (...args) => {
appendEntered.resolve()
await appendGate.promise
return appendItem(...args)
})
const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading')
deferred.sink.appendItem(
{ provider: 'orca', clientMessageId: 'blocked-prefill' },
{ kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }
)
await appendEntered.promise
const notification = {
type: 'system',
subtype: 'task_notification',
session_id: PROVIDER_SESSION_ID,
task_id: 'hard-watermark-task',
tool_use_id: 'toolu-hard-watermark',
status: 'failed',
summary: 'The real provider task failed',
uuid: 'hard-watermark-notification'
}
claude.connections[0].handlers.onMessage?.(notification)
claude.connections[0].handlers.onMessage?.(notification)
expect(deferred.state().queuedOperations).toBe(4)
appendGate.resolve()
await vi.waitFor(() => expect(resumeReading).toHaveBeenCalledOnce())
await expect(deferred.drained()).resolves.toEqual({ ok: true })
const taskRows = [...persisted.values()].filter(
(body) => body.kind === 'message' && blockOf(body)?.taskId === 'hard-watermark-task'
)
expect(taskRows).toHaveLength(1)
expect(blockOf(taskRows[0])?.summary).toBe('The real provider task failed')
expect(
[...persisted.values()].some(
(body) =>
body.kind === 'status' && body.providerFrame?.kind.includes('task_notification') === true
)
).toBe(false)
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
})
})
@@ -170,6 +170,7 @@ export type ClaudeSession = {
closeEnded?: boolean
translator: ClaudeJournalTranslator | null
events: StructuredAgentSessionEventSink | undefined
unbindReadingControl?: () => void
}
export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string {
@@ -74,6 +74,7 @@ export function fakeClaude(
const route = routes[subtype]
return route ? route(params) : undefined
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fake implements the complete connection contract below.
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeConnection = {
launch,
@@ -83,6 +84,8 @@ export function fakeClaude(
closeCount: 0,
pid: 4321,
closed: false,
pauseReading: () => {},
resumeReading: () => {},
initializationResult: async () => {
connection.calls.push({ subtype: 'initialize' })
if (options.exitBeforeInit) {
@@ -33,6 +33,20 @@ describe('readClaudeSubagentTaskFrame', () => {
})
})
it('announces the legacy local_subagent task type', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-legacy-subagent',
tool_use_id: 'toolu_legacy',
task_type: 'local_subagent',
subagent_type: 'code-reviewer',
description: 'Review the diff'
})
)
).toMatchObject({ announcesSubagent: true, excluded: false })
})
it('excludes a backgrounded shell command even though it carries a tool_use_id', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {
@@ -164,9 +164,11 @@ export class AgentSessionJournal {
snapshot = (): AgentJournalSnapshot => renderJournalState(this.state)
/** Visits reduced items without allocating and sorting a full snapshot. */
visitItems = (visit: (itemId: string, sequence: number) => void): void => {
visitItems = (
visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void
): void => {
for (const item of this.state.items.values()) {
visit(item.itemId, item.sequence)
visit(item.itemId, item.sequence, item.body)
}
}
@@ -7,8 +7,12 @@ import type {
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { isSubagentGroupBlock } from '../../../shared/native-chat-types'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row'
import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../../shared/native-chat-types'
import type {
NativeChatBackgroundTaskBlock,
NativeChatSubagentEntry
} from '../../../shared/native-chat-types'
import {
codexSubagentGroupBody,
codexSubagentGroupIdentity
@@ -55,6 +59,34 @@ function rosterRow(agents: NativeChatSubagentEntry[]) {
}
}
function backgroundTaskBlock(
overrides: Partial<NativeChatBackgroundTaskBlock> = {}
): NativeChatBackgroundTaskBlock {
return {
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'sleep 20',
state: 'working',
startedAt: 10,
...overrides
}
}
function backgroundTaskRow(block = backgroundTaskBlock()) {
return {
identity: {
provider: 'orca' as const,
clientMessageId: `claude-background-task:${block.taskId}`
},
body: {
kind: 'message' as const,
role: 'system' as const,
blocks: [{ type: 'text' as const, text: backgroundTaskFallbackText(block) }, block]
}
}
}
function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem {
const row = rosterRow(agents)
return {
@@ -70,6 +102,10 @@ function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry
return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : []
}
function taskOf(body: AgentJournalRenderItem['body']): NativeChatBackgroundTaskBlock | undefined {
return body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined
}
function twinOf(body: AgentJournalRenderItem['body']): string | undefined {
return body.kind === 'message'
? body.blocks.find((block) => block.type === 'text')?.text
@@ -104,6 +140,23 @@ describe('staleSubagentRosterRevisions', () => {
expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)')
})
it('settles a background task the previous host left live, and moves the twin with it', () => {
const row = backgroundTaskRow()
const revisions = staleSubagentRosterRevisions([
{
itemId: agentJournalItemKey(row.identity),
revision: 1,
body: row.body,
sequence: 2,
observedAt: 1
}
])
expect(revisions).toHaveLength(1)
expect(taskOf(revisions[0]!.body)).toMatchObject({ taskId: 'task-1', state: 'unverifiable' })
expect(twinOf(revisions[0]!.body)).toBe('Background command "sleep 20" stopped reporting')
})
// The child stopped being observable at an unknown moment. A stamp taken now
// would report the time the app was down as how long the child ran.
it('records no terminal timestamp for a child whose run length is unknown', () => {
@@ -114,6 +167,21 @@ describe('staleSubagentRosterRevisions', () => {
expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt')
})
it('records no terminal timestamp for a background task whose run length is unknown', () => {
const row = backgroundTaskRow(backgroundTaskBlock({ settledAt: 20 }))
const revisions = staleSubagentRosterRevisions([
{
itemId: agentJournalItemKey(row.identity),
revision: 1,
body: row.body,
sequence: 2,
observedAt: 1
}
])
expect(taskOf(revisions[0]!.body)).not.toHaveProperty('settledAt')
})
it('owes nothing for a roster whose children all settled', () => {
expect(
staleSubagentRosterRevisions([
@@ -186,6 +254,22 @@ describe('journal reopen after the writing host is gone', () => {
expect(reopened.snapshot().items.at(-1)?.revision).toBe(2)
})
it('settles a persisted working background task to unverifiable', async () => {
const live = await open()
const row = backgroundTaskRow()
await live.appendItem(row.identity, row.body, { fence: 0 })
const beforeRestart = live.snapshot().items.at(-1)!
expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' })
expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"')
await live.close()
const reopened = await open()
const afterRestart = reopened.snapshot().items.at(-1)!
expect(afterRestart.itemId).toBe(beforeRestart.itemId)
expect(taskOf(afterRestart.body)).toMatchObject({ state: 'unverifiable' })
expect(twinOf(afterRestart.body)).toBe('Background command "sleep 20" stopped reporting')
})
it('writes nothing on a second reopen once every child is settled', async () => {
const live = await open()
const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
@@ -27,8 +27,15 @@ import {
subagentGroupFallbackText
} from '../../../shared/native-chat-subagent-summary'
import {
backgroundTaskFallbackText,
isSettledBackgroundTaskState,
normalizeBackgroundTaskState
} from '../../../shared/native-chat-background-task-row'
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatBackgroundTaskBlock,
type NativeChatSubagentGroupBlock
} from '../../../shared/native-chat-types'
@@ -45,7 +52,7 @@ export function staleSubagentRosterRevisions(
const revisions: JournalSubagentLivenessRevision[] = []
for (const item of items) {
const body = item.body
if (body.kind !== 'message' || !body.blocks.some(hasWorkingChild)) {
if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) {
continue
}
// A key that will not parse cannot be re-addressed, and appending under a
@@ -59,30 +66,58 @@ export function staleSubagentRosterRevisions(
return revisions
}
function hasWorkingChild(block: NativeChatBlock): boolean {
function hasStaleLiveWork(block: NativeChatBlock): boolean {
return hasWorkingChild(block) || hasLiveBackgroundTask(block)
}
function hasWorkingChild(block: NativeChatBlock): block is NativeChatSubagentGroupBlock {
return (
isSubagentGroupBlock(block) &&
block.agents.some((agent) => normalizeSubagentState(agent.state) === 'working')
)
}
function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackgroundTaskBlock {
return (
isBackgroundTaskBlock(block) &&
!isSettledBackgroundTaskState(normalizeBackgroundTaskState(block.state))
)
}
/** No `settledAt`: the child stopped being observable at an unknown moment, and
* stamping the reopen would report the time the app was down as how long it
* ran. Readers already draw an unverifiable child with no stamp as having no
* known run length. */
function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
const settled = blocks.map((block) =>
hasWorkingChild(block) ? settleGroup(block as NativeChatSubagentGroupBlock) : block
const backgroundTaskTwinText = new Map<string, string>()
const settled = blocks.map((block) => {
if (hasWorkingChild(block)) {
return settleGroup(block)
}
if (hasLiveBackgroundTask(block)) {
const next = settleBackgroundTask(block)
backgroundTaskTwinText.set(
backgroundTaskFallbackText(block),
backgroundTaskFallbackText(next)
)
return next
}
return block
})
const withBackgroundTaskTwins = settled.map((block) =>
block.type === 'text'
? { ...block, text: backgroundTaskTwinText.get(block.text) ?? block.text }
: block
)
const rosters = settled.filter(isSubagentGroupBlock)
const rosters = withBackgroundTaskTwins.filter(isSubagentGroupBlock)
const only = rosters.length === 1 ? rosters[0] : undefined
if (!only) {
return settled
return withBackgroundTaskTwins
}
// The plain-text twin is all a client without the block type ever shows, so it
// has to move with the block or the two would disagree about the same row.
const twin = subagentGroupFallbackText(only.agents)
return settled.map((block) =>
return withBackgroundTaskTwins.map((block) =>
block.type === 'text' && isSubagentGroupFallbackText(block.text)
? { ...block, text: twin }
: block
@@ -99,3 +134,8 @@ function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGro
)
}
}
function settleBackgroundTask(block: NativeChatBackgroundTaskBlock): NativeChatBackgroundTaskBlock {
const { settledAt: _settledAt, ...withoutSettledAt } = block
return { ...withoutSettledAt, state: 'unverifiable' }
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../agent-session-journal/journal-payload-bounds'
import { CODEX_APP_SERVER_NOTIFICATION_METHODS } from '../../codex/codex-app-server-notification-schema'
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from './claude-stream-json-frame-schema'
import {
@@ -166,6 +167,68 @@ describe('provider frame classification catalog', () => {
})
})
describe('typed translator coverage', () => {
it('emits no generic row for a covered kind, whatever the payload reports', () => {
// The catalogue calls these `status-chrome`, but `hasProviderError` promotes
// any of them that reports a failure — which is how a failed background task
// reached users as `claude · message:system:task_notification`. Coverage is
// the contract that stops it: the typed translator writes the row instead.
for (const kind of [
'message:system:task_started',
'message:system:task_updated',
'message:system:task_progress',
'message:system:task_notification',
'message:system:background_tasks_changed'
]) {
expect(
unhandledProviderFrameJournalItem(
'claude',
kind,
{
task_id: 'byjnee2no',
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
},
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
{ coveredByTypedTranslator: true }
),
kind
).toBeNull()
}
})
it('keeps malformed covered-kind failures eligible for the generic fallback', () => {
// Eligibility is all this layer decides. A frame naming no task is not
// claimed by the row owner, which withholds the coverage flag so the
// failure still reaches the user. The SENTENCE it leads with is Claude's to
// supply, through the fallback's display-text seam — proven in
// `claude-structured-journal-translation-background-tasks.test.ts`. Teaching
// `summary` to the shared key list here would re-rank the row text of every
// unmodelled frame on both providers to reach this one case.
expect(
unhandledProviderFrameJournalItem('claude', 'message:system:task_notification', {
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
})
).toMatchObject({ classification: 'error-surface' })
})
it('covers Claude only — the same method name on another provider still falls back', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'message:system:task_notification', {
status: 'failed'
})
).not.toBeNull()
})
it('leaves an unmodelled Claude failure on the visible fallback', () => {
const item = unhandledProviderFrameJournalItem('claude', 'message:system:future_task', {
status: 'failed'
})
expect(item?.classification).toBe('error-surface')
})
})
describe('notice disposition boundaries', () => {
it.each(['warning', 'guardianWarning', 'deprecationNotice', 'configWarning'])(
'retains the error-surface cap exemption for %s',
@@ -149,6 +149,38 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = {
}
} as const satisfies ProviderFrameClassificationTable
/**
* Frame kinds a DEDICATED typed translator owns end to end.
*
* Coverage is a contract, not a label. The catalogue classification alone is
* only a hint about where a frame belongs, and `hasProviderError` deliberately
* outranks it so an unmodelled failure still reaches the user — which is how a
* failed background task ended up rendered by the generic fallback, whose row
* text is the wire opcode when the payload carries no key the fallback knows.
* A kind listed here is guaranteed no fallback row instead, so its translator
* may legitimately emit zero rows for a frame and nothing appears beside it.
*
* Listing a kind before its translator exists deletes the only report of a
* failure, so nothing may be added here except together with the code that
* renders it.
*
* A frame of a listed kind that names no task writes nothing. The row it
* replaces named no task either — it printed the opcode and a raw payload —
* and every frame this protocol sends carries the id its own tracker and
* roster have always required.
*/
const CLAUDE_TYPED_TRANSLATOR_KINDS: ReadonlySet<string> = new Set([
'message:system:task_started',
'message:system:task_updated',
'message:system:task_progress',
'message:system:task_notification',
'message:system:background_tasks_changed'
] satisfies ClaudeStreamJsonFrameKind[])
export function hasTypedProviderFrameTranslator(provider: string, kind: string): boolean {
return provider === 'claude' && CLAUDE_TYPED_TRANSLATOR_KINDS.has(kind)
}
const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome'])
const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason'])
@@ -224,6 +224,35 @@ describe('deferred structured agent-session event sink', () => {
expect(log).toHaveLength(2)
})
it('admits a resolved append and publication as one bounded operation', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink({
watermarks: {
pauseQueuedOperations: 1,
maxQueuedOperations: 2,
lowQueuedOperations: 0,
maxQueuedBytes: 1_000_000
}
})
expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true })
expect(
deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1))
).toEqual({ accepted: true })
expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({
accepted: false,
reason: 'backpressure'
})
deferred.bind(target(5, log))
await deferred.drained()
expect(log).toEqual([
{ call: 'appendItem', fence: 5, ordinal: 0 },
{ call: 'appendItem', fence: 5, ordinal: 1 },
{ call: 'publish', fence: 5 }
])
})
it('pauses provider reading at the soft byte watermark before rejecting writes', async () => {
const log: Recorded[] = []
const changes: boolean[] = []
@@ -8,6 +8,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate'
import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue'
import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append'
export type StructuredAgentSessionSinkAdmission =
| { accepted: true }
@@ -36,10 +37,13 @@ export type StructuredAgentSessionLifecycleJournal = Pick<
'epoch' | 'visitItems'
>
export type StructuredAgentSessionLifecycleIdentityResolver = (
export type StructuredAgentSessionIdentityResolver = (
journal: StructuredAgentSessionLifecycleJournal
) => AgentJournalItemIdentity | null
/** Compatibility alias for lifecycle callers that already use this resolver. */
export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver
export type StructuredAgentSessionEventSink = {
appendItem(
identity: AgentJournalItemIdentity,
@@ -61,11 +65,25 @@ export type StructuredAgentSessionEventSink = {
body: AgentJournalItemBody,
options?: StructuredAgentSessionAppendOptions
): StructuredAgentSessionSinkAdmission
/** Queues an ordinary append whose identity is resolved after journal bind. */
tryAppendResolvedItem?(
identitySizeBound: AgentJournalItemIdentity,
body: AgentJournalItemBody,
resolveIdentity: StructuredAgentSessionIdentityResolver,
options?: StructuredAgentSessionAppendOptions
): StructuredAgentSessionSinkAdmission
/** Queues one resolved append and its publication as a single admitted operation. */
tryAppendResolvedItemAndPublish?(
identitySizeBound: AgentJournalItemIdentity,
body: AgentJournalItemBody,
resolveIdentity: StructuredAgentSessionIdentityResolver,
options?: StructuredAgentSessionAppendOptions
): StructuredAgentSessionSinkAdmission
/** Queues one journal-derived lifecycle append; a null resolution is a no-op. */
tryAppendLifecycleTransition?(
identitySizeBound: AgentJournalItemIdentity,
body: AgentJournalItemBody,
resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver
resolveIdentity: StructuredAgentSessionIdentityResolver
): StructuredAgentSessionSinkAdmission
/** Current durable epoch, when this deferred sink is bound to its journal. */
journalEpoch?(): string | null
@@ -142,6 +160,7 @@ export function createDeferredStructuredAgentSessionEventSink(
...(deps.readingControl ? { readingControl: deps.readingControl } : {}),
...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {})
})
const resolvedAppend = createStructuredAgentSessionResolvedAppend(queue)
const appendLifecycleBatch = (
settlementId: string,
@@ -203,6 +222,7 @@ export function createDeferredStructuredAgentSessionEventSink(
},
options
),
...resolvedAppend,
tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity) => {
const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body)
return queue.submit(
@@ -0,0 +1,61 @@
import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue'
/** Resolve a queued item's run identity against the journal bound at execution. */
export function createStructuredAgentSessionResolvedAppend(
queue: StructuredAgentSessionSinkQueue
): {
tryAppendResolvedItem: NonNullable<StructuredAgentSessionEventSink['tryAppendResolvedItem']>
tryAppendResolvedItemAndPublish: NonNullable<
StructuredAgentSessionEventSink['tryAppendResolvedItemAndPublish']
>
} {
return {
tryAppendResolvedItem: (identitySizeBound, body, resolveIdentity, options = {}) => {
const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body)
return queue.submit(
{
bytes,
run: async (bound) => {
const identity = resolveIdentity(bound.journal)
if (identity === null) {
return
}
if (estimateStructuredAgentSessionItemBytes(identity, body) > bytes) {
throw new Error('structured agent-session item identity exceeded its reserved size')
}
await bound.journal.appendItem(identity, body, {
fence: bound.fence,
...(options.observedAt === undefined ? {} : { observedAt: options.observedAt })
})
}
},
options
)
},
tryAppendResolvedItemAndPublish: (identitySizeBound, body, resolveIdentity, options = {}) => {
const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + 1
return queue.submit(
{
bytes,
run: async (bound) => {
const identity = resolveIdentity(bound.journal)
if (identity === null) {
return
}
if (estimateStructuredAgentSessionItemBytes(identity, body) + 1 > bytes) {
throw new Error('structured agent-session item identity exceeded its reserved size')
}
await bound.journal.appendItem(identity, body, {
fence: bound.fence,
...(options.observedAt === undefined ? {} : { observedAt: options.observedAt })
})
bound.publish()
}
},
options
)
}
}
}
@@ -16,6 +16,26 @@ describe('rewind recovery of newer durable records', () => {
blocks: [{ type: 'text', text: '{"type":"future-block"}' }]
})
})
it('preserves background-task blocks across rewind recovery', () => {
const body = {
kind: 'message' as const,
role: 'system',
blocks: [
{ type: 'text' as const, text: 'Started background command "sleep 20"' },
{
type: 'background-task' as const,
taskId: 'task-1',
kind: 'command',
label: 'sleep 20',
state: 'working'
}
]
}
expect(restoreRewindJournalBody(body)).toEqual(body)
})
it('preserves unknown state as evidence rather than inventing success or pending work', () => {
const body = {
kind: 'tool-call' as const,
@@ -21,7 +21,12 @@ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody
(block.type === 'text' && 'text' in block) ||
(block.type === 'tool-call' && 'name' in block && !('state' in block)) ||
(block.type === 'tool-result' && 'output' in block) ||
block.type === 'image-ref'
block.type === 'image-ref' ||
(block.type === 'background-task' &&
'taskId' in block &&
'kind' in block &&
'label' in block &&
'state' in block)
) {
return block
}
@@ -6,7 +6,10 @@ import {
type JournalPayloadLimits
} from '../agent-session-journal/journal-payload-bounds'
import { codexGoalRowText } from '../../codex/codex-goal-journal-rows'
import { classifyProviderFrame } from './provider-frame-disposition'
import {
classifyProviderFrame,
hasTypedProviderFrameTranslator
} from './provider-frame-disposition'
export type UnhandledProviderFrameJournalItem = {
body: AgentJournalStatusItem
@@ -14,6 +17,11 @@ export type UnhandledProviderFrameJournalItem = {
classification: 'timeline-substantive' | 'error-surface'
}
export type UnhandledProviderFrameJournalItemOptions = {
/** A typed translator accepted this exact frame, not merely this frame kind. */
coveredByTypedTranslator?: boolean
}
function serializeProviderPayload(payload: unknown): string {
try {
const serialized = JSON.stringify(payload)
@@ -76,8 +84,20 @@ export function unhandledProviderFrameJournalItem(
provider: string,
kind: string,
payload: unknown,
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS,
options: UnhandledProviderFrameJournalItemOptions = {}
): UnhandledProviderFrameJournalItem | null {
// A kind a typed translator owns never degrades to its opcode here, in either
// direction: "no row" is that translator's decision, not a gap this fallback
// has to cover. Checked before classification, because the payload sniffer
// inside it promotes a covered frame that reports a failure and would
// otherwise print `${provider} · ${kind}` beside the typed row.
if (
options.coveredByTypedTranslator === true &&
hasTypedProviderFrameTranslator(provider, kind)
) {
return null
}
const classification = classifyProviderFrame(provider, kind, payload)
if (
classification === 'stream-into-item' ||
@@ -0,0 +1,63 @@
import {
normalizeBackgroundTaskKind,
normalizeBackgroundTaskState
} from '../../../shared/native-chat-background-task-row'
import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary'
import type { NativeChatBlock, NativeChatSubagentState } from '../../../shared/native-chat-types'
type WorkerTranscriptActivityBlock = Extract<
NativeChatBlock,
{ type: 'subagent-group' | 'background-task' }
>
export type WorkerTranscriptActivityBlockBounders = {
clipMetadata: (value: string) => string
clipText: (value: string) => string
boundEntryId: (value: string) => string
markClipped: (warning: string) => void
}
const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64
export function boundWorkerTranscriptActivityBlock(
block: WorkerTranscriptActivityBlock,
bounders: WorkerTranscriptActivityBlockBounders
): NativeChatBlock {
if (block.type === 'subagent-group') {
const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS)
if (agents.length < block.agents.length) {
bounders.markClipped('Some subagents were omitted from oversized spawn groups.')
}
return {
...block,
groupId: bounders.clipMetadata(block.groupId),
agents: agents.map((agent) => ({
...agent,
id: bounders.boundEntryId(agent.id),
label: bounders.clipMetadata(agent.label),
state: clipSubagentState(agent.state, bounders)
}))
}
}
const { outputFile, ...carried } = block
if (outputFile) {
bounders.markClipped('Background task output paths were omitted from transcript output.')
}
return {
...carried,
taskId: bounders.boundEntryId(block.taskId),
kind: normalizeBackgroundTaskKind(bounders.clipMetadata(block.kind)),
label: bounders.clipMetadata(block.label),
state: normalizeBackgroundTaskState(bounders.clipMetadata(block.state)),
...(block.summary ? { summary: bounders.clipText(block.summary) } : {}),
...(block.error ? { error: bounders.clipText(block.error) } : {})
}
}
function clipSubagentState(
value: NativeChatSubagentState,
bounders: WorkerTranscriptActivityBlockBounders
): NativeChatSubagentState {
const clipped = bounders.clipMetadata(value)
return clipped === value ? value : normalizeSubagentState(clipped)
}
@@ -132,6 +132,37 @@ describe('worker transcript wire bounds', () => {
expect(result.limited).toBe(true)
})
it('bounds a background-task kind and state a newer build wrote as open strings', () => {
const result = boundWorkerTranscriptMessages([
JSON.parse(
JSON.stringify({
id: 'message-task-state',
role: 'system',
timestamp: null,
source: 'transcript',
blocks: [
{
type: 'background-task',
taskId: 'task-1',
kind: 'k'.repeat(900),
label: 'l'.repeat(900),
state: 's'.repeat(900)
}
]
})
)
])
const block = result.messages[0]?.blocks[0]
if (block?.type !== 'background-task') {
throw new Error('expected a background-task block')
}
expect(block.kind).toBe('unknown')
expect(block.label).toHaveLength(512)
expect(block.state).toBe('unverifiable')
expect(result.limited).toBe(true)
})
it('keeps complete bounded messages unlimited', () => {
const result = boundWorkerTranscriptMessages([
{
@@ -1,11 +1,7 @@
import { createHash } from 'node:crypto'
import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary'
import type {
NativeChatBlock,
NativeChatMessage,
NativeChatSubagentState
} from '../../../shared/native-chat-types'
import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types'
import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds'
import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds'
export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40
export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50
@@ -13,10 +9,6 @@ const MAX_WORKER_TRANSCRIPT_BLOCKS = 6
const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200
const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20
const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100
// Matches the producer's per-group cap, so no group this build writes is clipped
// here. The bound stays because the journal schema declares no maximum and a
// remote host may run a build with a larger one.
const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64
// Message ids, turn ids, tool-call names and image urls, not only roster fields.
// Equal to `MAX_SUBAGENT_FIELD_CHARS` today, kept a separate literal so a
// roster-motivated change to that cap cannot silently move this one.
@@ -142,23 +134,13 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native
input: boundToolInput(block.input, budget, 0, state)
}
}
if (block.type === 'subagent-group') {
const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS)
if (agents.length < block.agents.length) {
markClipped(state, 'Some subagents were omitted from oversized spawn groups.')
}
// Labels, ids and states come from provider-supplied strings, so they get the
// same redaction and clipping every other piece of transcript metadata gets.
return {
...block,
groupId: clipMetadata(block.groupId, state),
agents: agents.map((agent) => ({
...agent,
id: boundEntryId(agent.id, state),
label: clipMetadata(agent.label, state),
state: clipSubagentState(agent.state, state)
}))
}
if (block.type === 'subagent-group' || block.type === 'background-task') {
return boundWorkerTranscriptActivityBlock(block, {
clipMetadata: (value) => clipMetadata(value, state),
clipText: (value) => clipText(value, state),
boundEntryId: (value) => boundEntryId(value, state),
markClipped: (warning) => markClipped(state, warning)
})
}
if (block.path || (block.url && isLocalFileLocator(block.url))) {
markClipped(state, 'Local image paths were omitted from transcript output.')
@@ -216,17 +198,6 @@ function clipMetadata(value: string, state: TranscriptBoundState): string {
return redacted.slice(0, MAX_WORKER_TRANSCRIPT_METADATA_CHARS)
}
/** `state` is an open string on the wire, so it takes the same bound. A value
* that had to be redacted or clipped names no state any build knows, which is
* exactly what `unverifiable` records. */
function clipSubagentState(
value: NativeChatSubagentState,
state: TranscriptBoundState
): NativeChatSubagentState {
const clipped = clipMetadata(value, state)
return clipped === value ? value : normalizeSubagentState(clipped)
}
function clipText(value: string, state: TranscriptBoundState): string {
const redacted = redactSensitiveText(value, state.warnings)
if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) {
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatBackgroundTaskBlock } from '../../../../shared/native-chat-types'
import { deriveNativeChatRowContent } from './native-chat-row-content'
import { NativeChatBackgroundTaskRun } from './NativeChatBackgroundTaskRun'
afterEach(cleanup)
function task(
overrides: Partial<NativeChatBackgroundTaskBlock> = {}
): NativeChatBackgroundTaskBlock {
return {
type: 'background-task',
taskId: 'byjnee2no',
kind: 'command',
label: 'Wait for the verification verdict',
state: 'working',
startedAt: 1_000,
...overrides
}
}
describe('NativeChatBackgroundTaskRun', () => {
it('draws the failure as a state, with the provider sentence beside it', () => {
render(
<NativeChatBackgroundTaskRun
block={task({
state: 'blocked',
settledAt: 61_000,
summary: 'Background command "Wait" failed with exit code 1',
tokens: 18_200
})}
/>
)
expect(screen.getByText('Wait for the verification verdict')).toBeInTheDocument()
// The outcome is a state word plus its reason — the same vocabulary the
// strip above the composer uses — not a red row of prose.
expect(screen.getByText(/^blocked · failed · 18\.2k · 1m 0s$/)).toBeInTheDocument()
expect(
screen.getByText('Background command "Wait" failed with exit code 1')
).toBeInTheDocument()
})
it('never draws a wire opcode, whatever the task reported', () => {
const { container } = render(<NativeChatBackgroundTaskRun block={task({ state: 'blocked' })} />)
expect(container.textContent).not.toContain('message:system')
expect(container.textContent).not.toContain('task_notification')
})
it('falls through to the kind when the provider named nothing usable', () => {
render(<NativeChatBackgroundTaskRun block={task({ label: 'task', kind: 'workflow' })} />)
expect(screen.getByText('Background workflow')).toBeInTheDocument()
})
it('reads a state this build has no word for as no contact, never as live', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: models a row a newer build wrote, which the wire admits as an open string.
render(<NativeChatBackgroundTaskRun block={task({ state: 'teleported' as 'done' })} />)
expect(screen.getByText(/unverifiable/)).toBeInTheDocument()
})
})
describe('background task rows in a transcript message', () => {
it('drops the frozen twin the block replaces, and keeps real prose', () => {
const block = task({ state: 'blocked', summary: 'it failed' })
const content = deriveNativeChatRowContent([
{ type: 'text', text: 'here is what happened' },
{ type: 'text', text: 'it failed' },
block
])
expect(content.markdown).toBe('here is what happened')
expect(content.backgroundTasks).toEqual([block])
})
it('counts a task row as content, so the transcript reserves its slot', () => {
const content = deriveNativeChatRowContent([
{ type: 'text', text: 'it failed' },
task({ state: 'blocked', summary: 'it failed' })
])
expect(content.markdown).toBe('')
expect(content.backgroundTasks).toHaveLength(1)
})
})
@@ -0,0 +1,83 @@
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { AgentStateDot } from '@/components/AgentStateDot'
import {
isSettledBackgroundTaskState,
normalizeBackgroundTaskKind,
normalizeBackgroundTaskState
} from '../../../../shared/native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock } from '../../../../shared/native-chat-types'
import {
backgroundTaskStateReason,
backgroundTaskStateWord,
formatBackgroundTaskTokens,
resolveBackgroundTaskName
} from './background-task-roster'
import { KIND_ICONS } from './NativeChatBackgroundTasksStatus'
import { formatNativeChatDuration } from './NativeChatWorkingStatus'
/**
* One background task's durable row: what it was, how it ended, and the
* provider's own sentence about it.
*
* The state is drawn exactly as the journal recorded it. Turn state is NOT
* consulted — a backgrounded task is explicitly told to outlive the turn that
* started it, so a turn boundary is no evidence about the task. Only the host
* that watched it can say it stopped reporting, and one does.
*/
export function NativeChatBackgroundTaskRun({
block
}: {
block: NativeChatBackgroundTaskBlock
}): React.JSX.Element {
const kind = normalizeBackgroundTaskKind(block.kind)
const Icon = KIND_ICONS[kind]
const state = normalizeBackgroundTaskState(block.state)
const settled = isSettledBackgroundTaskState(state)
// Same name resolution the strip above the composer uses, so one task does
// not read as two different things on the two surfaces.
const label = resolveBackgroundTaskName({ id: block.taskId, kind, description: block.label })
// Every attention state states its reason on the row, the same word the strip
// uses; `unverifiable` ("no contact") must never be silently dropped.
const reason = backgroundTaskStateReason(state)
// The sentence the provider itself wrote. It is the row's whole reason for
// existing when a task fails, and it is dropped from the prose above as the
// block's twin, so it has to be drawn here.
const sentence = block.summary?.trim() || block.error?.trim() || null
// A settled row keeps a fixed duration; a live one shows none rather than a
// frozen clock, which the strip above the composer counts for real.
const duration =
settled && block.startedAt !== undefined && block.settledAt !== undefined
? formatNativeChatDuration(Math.max(0, (block.settledAt - block.startedAt) / 1000))
: null
const meta = [
block.tokens === undefined ? null : formatBackgroundTaskTokens(block.tokens),
duration
].filter((part): part is string => part !== null)
return (
<div className="min-w-0 py-0.5 text-sm leading-relaxed text-muted-foreground">
<div className="flex min-h-6 min-w-0 items-center gap-1.5">
<Icon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<AgentStateDot state={state} size="sm" title={null} />
<span className={cn('min-w-0 truncate', !settled && 'text-foreground/85')}>{label}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{backgroundTaskStateWord(state)}
{reason === null ? null : ` · ${reason}`}
{meta.length > 0 ? ` · ${meta.join(' · ')}` : null}
</span>
</div>
{sentence === null ? null : (
<p className={cn('mt-0.5 pl-7 text-xs', state === 'blocked' && 'text-destructive')}>
{sentence}
</p>
)}
{block.outputFile ? (
<p className="mt-0.5 truncate pl-7 font-mono text-[11px] text-muted-foreground/80">
{translate('components.native-chat.backgroundTasks.outputFile', 'Output: {{value0}}', {
value0: block.outputFile
})}
</p>
) : null}
</div>
)
}
@@ -45,7 +45,9 @@ function useNarrowStrip(ref: React.RefObject<HTMLDivElement | null>): boolean {
return narrow
}
const KIND_ICONS = {
/** Shared with the transcript row so a task reads as the same thing in the
* strip above the composer and in the row that outlives it. */
export const KIND_ICONS = {
agent: Bot,
command: SquareTerminal,
monitor: Activity,
@@ -59,9 +59,8 @@ export const MessageRow = memo(function MessageRow({
const rowRef = useRef<HTMLDivElement | null>(null)
// One pass per block set, shared with the list that decides whether this row
// occupies a slot — so "draws nothing" means the same thing to both.
const { hasImages, markdown, prose, subagentGroups, tools } = deriveNativeChatRowContent(
message.blocks
)
const { backgroundTasks, hasImages, markdown, prose, subagentGroups, tools } =
deriveNativeChatRowContent(message.blocks)
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isSystem = message.role === 'system'
@@ -76,7 +75,13 @@ export const MessageRow = memo(function MessageRow({
// Skip rows with nothing renderable so the transcript shows no empty/ghost
// bubble.
// After all hooks, so hook order stays unconditional.
if (markdown.length === 0 && !hasImages && tools.length === 0 && subagentGroups.length === 0) {
if (
markdown.length === 0 &&
!hasImages &&
tools.length === 0 &&
subagentGroups.length === 0 &&
backgroundTasks.length === 0
) {
return null
}
@@ -183,7 +188,7 @@ export const MessageRow = memo(function MessageRow({
linkifyFilePaths={onLinkClick !== undefined}
/>
) : null}
{tools.length > 0 || subagentGroups.length > 0 ? (
{tools.length > 0 || subagentGroups.length > 0 || backgroundTasks.length > 0 ? (
<NativeChatToolRun
blocks={tools}
previousTodoWrite={previousTodoWrite}
@@ -192,6 +197,7 @@ export const MessageRow = memo(function MessageRow({
onRevealDiff={onScrollMessageToTop}
onLinkClick={onLinkClick}
subagentGroups={subagentGroups}
backgroundTasks={backgroundTasks}
expandSignal={expandSignal}
expandOverride={activityExpandOverride}
activeTurnIsWorking={activeTurnIsWorking}
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
isToolCallBlock,
type NativeChatBackgroundTaskBlock,
type NativeChatBlock,
type NativeChatSubagentGroupBlock,
type NativeChatToolCallBlock
@@ -33,12 +34,14 @@ import {
import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow'
import { NativeChatTaskList } from './NativeChatTaskList'
import { buildNativeChatTaskListRows } from './native-chat-task-list-history'
import { NativeChatBackgroundTaskRun } from './NativeChatBackgroundTaskRun'
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon'
import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label'
/** Stable empty default: a fresh array literal per render breaks memoization. */
const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = []
const NO_BACKGROUND_TASKS: NativeChatBackgroundTaskBlock[] = []
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. */
@@ -49,6 +52,7 @@ export function NativeChatToolRun({
revealedDiff,
onRevealDiff,
subagentGroups = NO_SUBAGENT_GROUPS,
backgroundTasks = NO_BACKGROUND_TASKS,
expandSignal,
activeTurnIsWorking,
expandOverride,
@@ -63,6 +67,8 @@ export function NativeChatToolRun({
onRevealDiff?: (element: HTMLElement) => void
/** Spawn-group rosters that belong with this run's activity, one row each. */
subagentGroups?: NativeChatSubagentGroupBlock[]
/** Background tasks that belong with this run's activity, one row each. */
backgroundTasks?: NativeChatBackgroundTaskBlock[]
/** Legacy view-level default; production native-chat entry points pass false. */
expandSignal: boolean
/** Per-turn disclosure state controlled by the completed turn status row. */
@@ -94,6 +100,13 @@ export function NativeChatToolRun({
const subagentRows = subagentGroups
.filter(isRenderableSubagentGroup)
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
// Neither a roster nor a background task is tool activity, so both take every
// escape below that the tool header does not: a task row outlives the turn
// that started it and is the only durable report of how it ended.
const standaloneRows = [
...subagentRows,
...backgroundTasks.map((task) => <NativeChatBackgroundTaskRun key={task.taskId} block={task} />)
]
const {
asks,
unansweredAsks,
@@ -170,7 +183,7 @@ export function NativeChatToolRun({
// whole transcript — and left the caller, which counts a spawn group as
// renderable, drawing the empty bubble it explicitly guards against.
if (blocks.length === 0) {
return subagentRows.length > 0 ? <div className="mt-3">{subagentRows}</div> : null
return standaloneRows.length > 0 ? <div className="mt-3">{standaloneRows}</div> : null
}
// Completed turn activity belongs behind the turn-status disclosure. Keeping
@@ -186,14 +199,14 @@ export function NativeChatToolRun({
// The roster is not tool activity, so it survives this guard exactly as it
// survives the tool-less escape above — otherwise a group sharing a message
// with tool calls is dropped from every settled turn.
return subagentRows.length > 0 ? <div className="mt-3">{subagentRows}</div> : null
return standaloneRows.length > 0 ? <div className="mt-3">{standaloneRows}</div> : null
}
return (
// Extra top margin sets the tool run apart from the assistant prose above it
// so the turn's activity doesn't crowd the message text.
<div className="mt-3">
{subagentRows}
{standaloneRows}
{hasAskCall ? (
<NativeChatAwaitingInputRow subject={askSubject} pending={askIsActive} />
) : null}
@@ -6,11 +6,19 @@
// Cached on the block array itself, so a streaming turn re-deriving on every frame
// pays once per revision rather than once per consumer.
import {
backgroundTaskBlocks,
claimBackgroundTaskTwins
} from '../../../../shared/native-chat-background-task-row'
import {
isSubagentGroupFallbackText,
subagentGroupBlocks
} from '../../../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatBlock } from '../../../../shared/native-chat-types'
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
type NativeChatBlock
} from '../../../../shared/native-chat-types'
import { splitNativeChatBlocks } from './native-chat-tool-fold'
import { nativeChatProseToMarkdown } from './native-chat-prose'
@@ -22,21 +30,28 @@ const derivations = new WeakMap<object, NativeChatRowContent>()
function derive(blocks: readonly NativeChatBlock[]) {
const split = splitNativeChatBlocks(blocks)
const groups = subagentGroupBlocks(split.prose)
// A spawn-group row carries a plain-text twin so a client without the block type
// still reads the roster. This draws the block, so only the twin is dropped —
// never real text beside it, which a lane folding a roster into a message keeps.
const tasks = backgroundTaskBlocks(split.prose)
// Both row kinds carry a plain-text twin so a client without the block type
// still reads them. This draws the blocks, so only the twins are dropped —
// never real text beside them, which a lane folding a roster into a message
// keeps. A task row's twin is claimed by exact text, because its sentence is
// often the provider's own and has no shape to match.
const taskTwins = claimBackgroundTaskTwins(split.prose)
const prose =
groups.length === 0
groups.length === 0 && tasks.length === 0
? split.prose
: split.prose.filter(
(block) =>
(block, index) =>
!isSubagentGroupBlock(block) &&
!(block.type === 'text' && isSubagentGroupFallbackText(block.text))
!isBackgroundTaskBlock(block) &&
!taskTwins.twinTextIndexes.has(index) &&
!(groups.length > 0 && block.type === 'text' && isSubagentGroupFallbackText(block.text))
)
return {
prose,
tools: split.tools,
subagentGroups: groups,
backgroundTasks: tasks,
markdown: nativeChatProseToMarkdown(prose),
hasImages: prose.some((block) => block.type === 'image-ref')
}
@@ -56,6 +71,13 @@ export function deriveNativeChatRowContent(
/** Whether the row draws anything. An empty row takes no slot in the transcript. */
export function nativeChatRowRendersContent(blocks: readonly NativeChatBlock[]): boolean {
const { markdown, hasImages, tools, subagentGroups } = deriveNativeChatRowContent(blocks)
return markdown.length > 0 || hasImages || tools.length > 0 || subagentGroups.length > 0
const { markdown, hasImages, tools, subagentGroups, backgroundTasks } =
deriveNativeChatRowContent(blocks)
return (
markdown.length > 0 ||
hasImages ||
tools.length > 0 ||
subagentGroups.length > 0 ||
backgroundTasks.length > 0
)
}
+1
View File
@@ -17236,6 +17236,7 @@
"command": "Background command",
"monitor": "Background monitor",
"task": "Background task",
"outputFile": "Output: {{value0}}",
"detailsUnavailable": "Task details are unavailable for this session.",
"countAgentsOne": "1 agent",
"countAgentsMany": "{{value0}} agents",
+19 -1
View File
@@ -46,7 +46,8 @@ const KNOWN_BLOCK_TYPES = new Set([
'tool-call',
'tool-result',
'image-ref',
'subagent-group'
'subagent-group',
'background-task'
])
/** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */
@@ -101,6 +102,23 @@ const Block = z.union([
type: z.literal('subagent-group'),
groupId: z.string(),
agents: z.array(SubagentEntry)
}),
// `kind` and `state` stay open strings for the same reason a child's
// lifecycle does: a vocabulary a newer build writes must not turn the row
// malformed. The renderer falls back on anything it cannot name.
z.object({
type: z.literal('background-task'),
taskId: z.string().min(1),
kind: z.string().min(1),
label: z.string(),
state: z.string().min(1),
parentToolUseId: z.string().optional(),
summary: z.string().optional(),
error: z.string().optional(),
outputFile: z.string().optional(),
tokens: z.number().optional(),
startedAt: z.number().optional(),
settledAt: z.number().optional()
})
]),
z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type))
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
backgroundTaskFallbackText,
canReplaceBackgroundTaskState,
claimBackgroundTaskTwins,
isSettledBackgroundTaskState,
normalizeBackgroundTaskState
} from './native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock, NativeChatBlock } from './native-chat-types'
function task(
overrides: Partial<NativeChatBackgroundTaskBlock> = {}
): NativeChatBackgroundTaskBlock {
return {
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'sleep 20',
state: 'working',
...overrides
}
}
describe('background task row text', () => {
it('leads with the provider sentence — the only account of the failure it ever gave', () => {
expect(
backgroundTaskFallbackText(
task({ state: 'blocked', summary: 'Background command "X" failed with exit code 1' })
)
).toBe('Background command "X" failed with exit code 1')
})
it('falls back to the provider error when no summary arrived', () => {
expect(backgroundTaskFallbackText(task({ state: 'blocked', error: 'ENOENT' }))).toBe('ENOENT')
})
it('claims only that a live task was started, never that it is still running', () => {
// The clients reading this instead of the block reconcile nothing and cannot
// re-check the task, so a frozen "running" would assert a liveness only the
// dead process could have observed.
expect(backgroundTaskFallbackText(task())).toBe('Started background command "sleep 20"')
expect(backgroundTaskFallbackText(task())).not.toContain('running')
})
it('names the outcome for a settled task with no provider sentence', () => {
expect(backgroundTaskFallbackText(task({ state: 'done' }))).toBe(
'Background command "sleep 20" finished'
)
expect(backgroundTaskFallbackText(task({ state: 'unverifiable' }))).toBe(
'Background command "sleep 20" stopped reporting'
)
})
it('falls through to the kind when the provider named nothing', () => {
expect(backgroundTaskFallbackText(task({ label: ' ', state: 'done' }))).toBe(
'Background command finished'
)
})
})
describe('background task row state', () => {
it('reads a state this build has no word for as unverifiable, never as live', () => {
expect(normalizeBackgroundTaskState('teleported')).toBe('unverifiable')
expect(isSettledBackgroundTaskState('teleported')).toBe(true)
expect(isSettledBackgroundTaskState('waiting')).toBe(false)
})
it('latches a reported outcome but lets a real verdict correct lost contact', () => {
expect(canReplaceBackgroundTaskState('working', 'blocked')).toBe(true)
expect(canReplaceBackgroundTaskState('blocked', 'working')).toBe(false)
expect(canReplaceBackgroundTaskState('done', 'blocked')).toBe(false)
expect(canReplaceBackgroundTaskState('unverifiable', 'done')).toBe(true)
expect(canReplaceBackgroundTaskState('unverifiable', 'working')).toBe(false)
})
})
describe('background task twins', () => {
it('pairs each row with the frozen sentence written beside it', () => {
const row = task({ state: 'blocked', summary: 'it failed' })
const blocks: NativeChatBlock[] = [{ type: 'text', text: 'it failed' }, row]
const claims = claimBackgroundTaskTwins(blocks)
expect([...claims.twinTextIndexes]).toEqual([0])
expect(claims.unpairedRows.size).toBe(0)
})
it('leaves real prose beside a row alone', () => {
const row = task({ state: 'blocked', summary: 'it failed' })
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'here is what I found' },
{ type: 'text', text: 'it failed' },
row
]
expect([...claimBackgroundTaskTwins(blocks).twinTextIndexes]).toEqual([1])
})
it('makes a row with no twin print its own sentence rather than nothing', () => {
const row = task({ state: 'blocked', summary: 'it failed' })
const claims = claimBackgroundTaskTwins([row])
expect(claims.unpairedRows.get(0)).toBe('it failed')
})
it('gives two rows sharing a sentence one twin each', () => {
const rows = [task({ taskId: 'a', state: 'done' }), task({ taskId: 'b', state: 'done' })]
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'Background command "sleep 20" finished' },
{ type: 'text', text: 'Background command "sleep 20" finished' },
...rows
]
const claims = claimBackgroundTaskTwins(blocks)
expect([...claims.twinTextIndexes]).toEqual([0, 1])
expect(claims.unpairedRows.size).toBe(0)
})
})
@@ -0,0 +1,168 @@
// One background task's durable transcript row: the vocabulary its producer,
// the desktop renderer and the plain-text surfaces all read it by.
//
// Shared because the row is written once and read by clients that cannot draw
// the block. The frozen sentence beside it is built here too, so the twin and
// the block can never describe the task differently.
import {
isBackgroundTaskBlock,
type NativeChatBackgroundTaskBlock,
type NativeChatBlock
} from './native-chat-types'
/** The only states a task can still leave. Everything else is an outcome,
* including `unverifiable`, which records that we stopped being able to see
* the task rather than what it did (docs/reference/ssh-execution-boundary.md).
* A state this build does not know reads as settled, never as in-flight: a row
* written by a newer build must not leave the transcript spinning forever. */
const IN_FLIGHT_TASK_STATES: ReadonlySet<string> = new Set(['working', 'monitoring', 'waiting'])
/** The task's own verdict about itself, which a later frame may not overwrite.
* `unverifiable` is deliberately absent: contact can return, and latching the
* loss would report a task that finished as one we never saw finish. */
const LATCHED_TASK_STATES: ReadonlySet<string> = new Set(['done', 'blocked', 'idle'])
const KNOWN_TASK_STATES = [
'working',
'monitoring',
'waiting',
'blocked',
'done',
'idle',
'unverifiable'
] as const satisfies NativeChatBackgroundTaskBlock['state'][]
/** A state this build has no word for reads as `unverifiable` — we cannot say
* what the task did, only that we cannot name what it reported. */
export function normalizeBackgroundTaskState(
state: string
): NativeChatBackgroundTaskBlock['state'] {
return KNOWN_TASK_STATES.find((known) => known === state) ?? 'unverifiable'
}
export function isSettledBackgroundTaskState(state: string): boolean {
return !IN_FLIGHT_TASK_STATES.has(state)
}
/** Whether `next` may replace `current`. Nothing returns to in-flight once we
* have given up on it, so a straggler progress tick cannot re-light a settled
* row. */
export function canReplaceBackgroundTaskState(current: string, next: string): boolean {
if (!isSettledBackgroundTaskState(current)) {
return true
}
if (LATCHED_TASK_STATES.has(current)) {
return false
}
return LATCHED_TASK_STATES.has(next)
}
const KNOWN_TASK_KINDS = [
'agent',
'workflow',
'command',
'monitor',
'unknown'
] as const satisfies NativeChatBackgroundTaskBlock['kind'][]
/** A kind this build has no name for is simply an unnamed task, not a guess. */
export function normalizeBackgroundTaskKind(kind: string): NativeChatBackgroundTaskBlock['kind'] {
return KNOWN_TASK_KINDS.find((known) => known === kind) ?? 'unknown'
}
const KIND_NOUNS: Record<NativeChatBackgroundTaskBlock['kind'], string> = {
agent: 'background agent',
workflow: 'background workflow',
command: 'background command',
monitor: 'background monitor',
unknown: 'background task'
}
const SETTLED_VERBS: Record<string, string> = {
done: 'finished',
blocked: 'failed',
idle: 'was stopped'
}
/**
* Plain-text stand-in for the row, frozen into the journal at write time for
* clients without the block type — mobile renders only this.
*
* It leads with the provider's OWN sentence whenever it sent one: that sentence
* is the point of the row, and paraphrasing it would discard the only account
* of the failure the provider ever gave. Everything else states what stays true
* once the writing process is gone. A live task claims only that it was
* started, never that it is still running: the clients reading this instead of
* the block reconcile nothing and cannot re-check the task, so a frozen
* "running" would assert a liveness only the dead process could have observed.
*/
export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock): string {
const sentence = block.summary?.trim() || block.error?.trim()
if (sentence) {
return sentence
}
const noun = KIND_NOUNS[normalizeBackgroundTaskKind(block.kind)]
// A task the provider never named falls through to its kind: quoting an empty
// label would print `background command ""`.
const subject = block.label.trim() ? `${noun} "${block.label}"` : noun
if (!isSettledBackgroundTaskState(block.state)) {
return `Started ${subject}`
}
const verb = SETTLED_VERBS[block.state] ?? 'stopped reporting'
return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}`
}
/** The background-task rows in `blocks`. */
export function backgroundTaskBlocks(
blocks: readonly NativeChatBlock[]
): NativeChatBackgroundTaskBlock[] {
return blocks.filter(isBackgroundTaskBlock)
}
export type BackgroundTaskTwinClaims = {
/** Text blocks a row's frozen twin occupies, by position, so a surface
* drawing the block does not print the same sentence beside it. */
twinTextIndexes: Set<number>
/** Rows left with no twin, by position, and the sentence each must print
* itself on a surface that cannot draw the block. */
unpairedRows: Map<number, string>
}
/** Pair every task row with the frozen twin written beside it.
*
* Matched on exact text, which the producer guarantees: it writes the twin
* from `backgroundTaskFallbackText` and nothing else into the row. A row
* written by a newer build that phrases a state differently matches nothing,
* and then prints its own sentence beside the unclaimed text — redundant, but
* never a lost report, which is the only degradation this row may have. */
export function claimBackgroundTaskTwins(
blocks: readonly NativeChatBlock[]
): BackgroundTaskTwinClaims {
const twinTextIndexes = new Set<number>()
const unpairedRows = new Map<number, string>()
const wanted = new Map<string, number>()
const rows: { index: number; sentence: string }[] = []
blocks.forEach((block, index) => {
if (isBackgroundTaskBlock(block)) {
const sentence = backgroundTaskFallbackText(block)
rows.push({ index, sentence })
wanted.set(sentence, (wanted.get(sentence) ?? 0) + 1)
}
})
for (const [index, block] of blocks.entries()) {
const count = block.type === 'text' ? (wanted.get(block.text) ?? 0) : 0
if (block.type === 'text' && count > 0) {
wanted.set(block.text, count - 1)
twinTextIndexes.add(index)
}
}
for (const row of rows) {
const count = wanted.get(row.sentence) ?? 0
if (count > 0) {
wanted.set(row.sentence, count - 1)
unpairedRows.set(row.index, row.sentence)
}
}
return { twinTextIndexes, unpairedRows }
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { foldToolMessages } from './native-chat-tool-fold'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import type {
NativeChatBackgroundTaskBlock,
NativeChatBlock,
NativeChatMessage
} from './native-chat-types'
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
return {
@@ -12,6 +16,23 @@ function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
}
}
function backgroundTaskMessage(id: string): NativeChatMessage {
const task: NativeChatBackgroundTaskBlock = {
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'wait',
state: 'working'
}
return {
id,
role: 'system',
blocks: [{ type: 'text', text: 'Started background command "wait"' }, task],
timestamp: null,
source: 'transcript'
}
}
describe('tool attribution allocation', () => {
it('does not append valid prose blocks to discarded attribution arrays', () => {
const prose: NativeChatBlock = { type: 'text', text: 'Ordinary prose' }
@@ -47,4 +68,22 @@ describe('tool attribution allocation', () => {
expect(input.blocks).toHaveLength(7)
expect(foldToolMessages([message('two', [result])])).toEqual([])
})
it('keeps tool attribution across a background-task activity row', () => {
const call: NativeChatBlock = {
type: 'tool-call',
name: 'read',
input: {}
}
const result: NativeChatBlock = { type: 'tool-result', output: 'done' }
const folded = foldToolMessages([
message('assistant', [call]),
backgroundTaskMessage('background-task'),
message('result', [result])
])
expect(folded).toHaveLength(2)
expect(folded[0]?.blocks).toEqual([call, result])
expect(folded[1]).toMatchObject({ id: 'background-task' })
})
})
+9 -3
View File
@@ -1,4 +1,5 @@
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
isToolCallBlock,
isToolResultBlock,
@@ -36,13 +37,17 @@ function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean {
)
}
/** The spawn-group roster row lands mid-turn, between the assistant's tool
* calls. It is activity chrome, not a new turn, so it must not end the run the
* following tool messages fold into. */
/** Activity rows land mid-turn, between the assistant's tool calls. They are
* chrome, not a new turn, so they must not end the run the following tool
* messages fold into. */
function isSubagentRosterMessage(message: NativeChatMessage): boolean {
return message.blocks.some(isSubagentGroupBlock)
}
function isBackgroundTaskMessage(message: NativeChatMessage): boolean {
return message.blocks.some(isBackgroundTaskBlock)
}
function isInterruptionBoundary(message: NativeChatMessage): boolean {
return message.blocks.some(
(block) =>
@@ -116,6 +121,7 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native
clonedAssistantIndex = -1
} else if (
!isSubagentRosterMessage(message) &&
!isBackgroundTaskMessage(message) &&
(!isNoiseMessage(message) || isInterruptionBoundary(message))
) {
mutableAssistantIndex = -1
+46
View File
@@ -6,6 +6,10 @@
// here must be plain JSON: these values cross the IPC boundary, so no class
// instances, Maps, or Dates.
import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskRunState
} from './agent-session-background-task-wire'
import type { AgentType } from './agent-status-types'
import type { NativeChatToolMetadata } from './native-chat-tool-identity'
@@ -136,12 +140,48 @@ export type NativeChatSubagentGroupBlock = {
agents: NativeChatSubagentEntry[]
}
/** One provider background task — a shell command, workflow or monitor run
* beside the turn — as its own durable row, revised in place from the
* provider's lifecycle frames. Sibling of the roster block rather than a
* one-child roster: a backgrounded `sleep 20` is not a subagent, and a group
* holding it would read "Ran 1 subagent".
*
* Outcome is a STATUS FIELD, never a red row: a task that failed is a task
* with a terminal state, and the row that reports it is the same row that
* reported it starting. */
export type NativeChatBackgroundTaskBlock = {
type: 'background-task'
/** Provider task id — the row key, stable across a resume. */
taskId: string
kind: AgentSessionBackgroundTask['kind']
/** Display name: the provider's description, else the identity it reported. */
label: string
/** Run state, in the vocabulary the background-tasks strip already renders. */
state: AgentSessionBackgroundTaskRunState
/** The tool call that spawned this task. The transcript has no structural
* parent link for a row, so the relationship is carried as a field here and
* consumers co-locate the row with that tool call. */
parentToolUseId?: string
/** The provider's own sentence about the outcome, when it sent one. */
summary?: string
/** The provider's error text, when it reported one apart from the summary. */
error?: string
/** Where the provider wrote the task's output. */
outputFile?: string
/** Latest total tokens the provider reported FOR THIS TASK. */
tokens?: number
startedAt?: number
/** Epoch ms the row latched terminal. */
settledAt?: number
}
export type NativeChatBlock =
| NativeChatTextBlock
| NativeChatToolCallBlock
| NativeChatToolResultBlock
| NativeChatImageRefBlock
| NativeChatSubagentGroupBlock
| NativeChatBackgroundTaskBlock
export type NativeChatMessage = {
/** Stable across re-reads/appends so the assembler and the renderer list can
@@ -231,3 +271,9 @@ export function isSubagentGroupBlock(
): block is NativeChatSubagentGroupBlock {
return block.type === 'subagent-group'
}
export function isBackgroundTaskBlock(
block: NativeChatBlock
): block is NativeChatBackgroundTaskBlock {
return block.type === 'background-task'
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import { formatWorkerTranscriptMessage } from './worker-transcript-text'
function message(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'm-1', role: 'system', blocks, timestamp: null, source: 'hook' }
}
describe('worker transcript text — background task rows', () => {
it('prints the twin once and never the raw block', () => {
const text = formatWorkerTranscriptMessage(
message([
{ type: 'text', text: 'Background command "X" failed with exit code 1' },
{
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'X',
state: 'blocked',
summary: 'Background command "X" failed with exit code 1'
}
])
)
expect(text).toBe('[system] Background command "X" failed with exit code 1')
expect(text).not.toContain('[unsupported block]')
})
it('prints a row that arrived with no twin rather than dropping its sentence', () => {
expect(
formatWorkerTranscriptMessage(
message([
{
type: 'background-task',
taskId: 'task-1',
kind: 'command',
label: 'X',
state: 'blocked',
summary: 'it failed'
}
])
)
).toBe('[system] it failed')
})
})
+8
View File
@@ -6,6 +6,7 @@
* copied: two renderings would let the two surfaces disagree about what a tool call looked like.
*/
import { claimBackgroundTaskTwins } from './native-chat-background-task-row'
import {
isSubagentGroupFallbackText,
subagentGroupFallbackText
@@ -20,10 +21,17 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin
// every fallback-shaped text block as soon as any group is present and draws
// each group, so it never has to decide which twin belongs to which group.
const standIns = claimSubagentGroupTwins(message.blocks)
// A background task's row is the same shape: one block, one frozen twin. Its
// twin is matched on exact text rather than by shape, because the sentence is
// often the provider's own and has none.
const taskTwins = claimBackgroundTaskTwins(message.blocks)
const blocks = message.blocks.map((block, index) => {
if (block.type === 'text') {
return block.text
}
if (block.type === 'background-task') {
return taskTwins.unpairedRows.get(index) ?? null
}
if (block.type === 'tool-call') {
return `[tool ${block.name}] ${safeJson(block.input)}`
}