fix(native-chat): harden background task rows

This commit is contained in:
Brennan Benson
2026-09-13 17:49:26 -07:00
parent 0dbd2fe942
commit b8293eb0ef
18 changed files with 976 additions and 328 deletions
@@ -29,6 +29,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
@@ -0,0 +1,39 @@
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle'
export function claudeBackgroundTaskIdentity(taskId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-background-task:${taskId}` }
}
export function claudeBackgroundTaskBody(
block: NativeChatBackgroundTaskBlock
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }]
}
}
export function writeClaudeBackgroundTaskRow(
sink: StructuredAgentSessionEventSink,
id: string,
row: ClaudeBackgroundTaskRow
): void {
const body = claudeBackgroundTaskBody(row.block)
const serialized = JSON.stringify(body)
if (serialized === row.lastSerialized) {
return
}
row.lastSerialized = serialized
sink.appendItem(claudeBackgroundTaskIdentity(id), body, {
coalescingKey: `claude-background-task:${id}`
})
sink.publish()
}
@@ -0,0 +1,203 @@
import {
canReplaceBackgroundTaskState,
isSettledBackgroundTaskState
} from '../../shared/native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types'
import {
classifyClaudeBackgroundTaskKind,
liveClaudeTaskRunState,
record,
taskAliasId,
taskDescription,
taskName,
taskText,
taskUsageTotalTokens,
terminalClaudeTaskRunState
} from './claude-background-task-frames'
export type ClaudeBackgroundTaskRow = {
block: NativeChatBackgroundTaskBlock
lastSerialized: string | null
toolUseId?: string
}
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 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
): ClaudeBackgroundTaskRow {
const totalTokens = taskUsageTotalTokens(message)
const toolUseId = claudeBackgroundTaskToolUseId(message)
return {
lastSerialized: null,
...(toolUseId === undefined ? {} : { toolUseId }),
block: {
type: 'background-task',
taskId: id,
kind: classifyClaudeBackgroundTaskKind(message.task_type),
label: taskDescription(message.description) ?? taskName(message) ?? '',
state:
terminalClaudeTaskRunState(message.status) ??
liveClaudeTaskRunState(message.status) ??
'working',
startedAt: now,
...(totalTokens === undefined ? {} : { tokens: totalTokens })
}
}
}
export function newClaudeBackgroundTaskTerminalRow(
id: string,
message: Record<string, unknown>
): ClaudeBackgroundTaskRow {
const patch = record(message.patch)
const source = patch ?? message
const toolUseId = claudeBackgroundTaskToolUseId(message)
return {
lastSerialized: null,
...(toolUseId === undefined ? {} : { toolUseId }),
block: {
type: 'background-task',
taskId: id,
kind: 'task_type' in source ? classifyClaudeBackgroundTaskKind(source.task_type) : 'unknown',
label: taskDescription(source.description) ?? taskName(source) ?? '',
state: 'working'
}
}
}
export function reopenClaudeBackgroundTaskRow(
row: ClaudeBackgroundTaskRow,
id: string,
message: Record<string, unknown>,
state: NativeChatBackgroundTaskBlock['state'],
now: number
): ClaudeBackgroundTaskRow {
const kind =
'task_type' in message ? classifyClaudeBackgroundTaskKind(message.task_type) : row.block.kind
const totalTokens = taskUsageTotalTokens(message)
const toolUseId = claudeBackgroundTaskToolUseId(message)
return {
...row,
...(toolUseId === undefined ? {} : { toolUseId }),
block: {
type: 'background-task',
taskId: id,
kind: kind === 'unknown' ? row.block.kind : kind,
label: taskDescription(message.description) ?? taskName(message) ?? row.block.label,
state,
startedAt: now,
...(totalTokens === undefined ? {} : { tokens: totalTokens })
}
}
}
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)
return toolUseId !== undefined && toolUseId !== row.toolUseId
}
export function canReopenClaudeBackgroundTaskRowFromAggregate(
row: ClaudeBackgroundTaskRow,
state: NativeChatBackgroundTaskBlock['state'] | null
): state is NativeChatBackgroundTaskBlock['state'] {
return (
state !== null &&
!isSettledBackgroundTaskState(state) &&
isSettledBackgroundTaskState(row.block.state) &&
row.block.state !== 'blocked'
)
}
export function isClaudeBackgroundTranscriptTask(
message: Record<string, unknown>,
kind: NativeChatBackgroundTaskBlock['kind']
): boolean {
return message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor'
}
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
}
@@ -155,6 +155,27 @@ describe('claude background task rows', () => {
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('revises in place rather than opening a row from a patch', () => {
const { rows, items, latest } = harness()
rows.observe({
@@ -176,6 +197,52 @@ describe('claude background task rows', () => {
expect(latest()).toMatchObject({ label: 'Wait for the verification verdict', tokens: 1_200 })
})
it('opens a row for a terminal update that arrives before the announcement', () => {
const { rows, latest, latestTwin } = harness()
expect(
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: 'pre-journal',
patch: { status: 'failed', description: 'Check logs', error: 'boom' }
})
).toBe(true)
expect(latest()).toMatchObject({
taskId: 'pre-journal',
label: 'Check logs',
state: 'blocked',
error: 'boom'
})
expect(latestTwin()).toBe('boom')
})
it('does not resurrect a task whose terminal edge arrived before its start', () => {
const { rows, items } = harness()
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'done-before-start',
status: 'completed'
})
).toBe(true)
expect(rows.observe({ ...START_BASH, task_id: 'done-before-start' })).toBe(true)
expect(items).toEqual([])
})
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([])
})
it('latches a reported outcome against a later live tick', () => {
const { rows, latest } = harness()
rows.observe(START_BASH)
@@ -188,6 +255,66 @@ describe('claude background task rows', () => {
expect(latest()).toMatchObject({ state: 'blocked' })
})
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' })
})
it('lets an aggregate live roster reopen a settled same-id row', () => {
const { rows, latest } = harness()
rows.observe({ ...START_BASH, task_id: 'aggregate-resume' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'aggregate-resume',
status: 'completed'
})
rows.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [
{
task_id: 'aggregate-resume',
status: 'running',
task_type: 'local_bash',
description: 'Resumed by roster'
}
]
})
expect(latest()).toMatchObject({ state: 'working', label: 'Resumed by roster' })
})
it('never burns a revision on a duplicate delivery', () => {
const { rows, items } = harness()
rows.observe(START_BASH)
@@ -212,6 +339,94 @@ describe('claude background task rows', () => {
expect(latest()).toMatchObject({ taskId: 'byjnee2no', state: 'blocked' })
})
it('rejects overlong tool-use aliases instead of clipping them into collisions', () => {
const { rows, items, latest } = harness()
const overlong = `${'x'.repeat(512)}A`
rows.observe({ ...START_BASH, task_id: 'task-a', tool_use_id: overlong })
const afterStart = items.length
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
tool_use_id: overlong,
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('declines coverage so fallback can report a failure when every row is live', () => {
const { rows, items } = harness()
for (let index = 0; index < 64; index += 1) {
rows.observe({ ...START_BASH, task_id: `live-${index}` })
}
expect(
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'overflow-live',
status: 'failed',
summary: 'overflow failed'
})
).toBe(false)
expect(
items.some((item) =>
item.identity.provider === 'orca'
? item.identity.clientMessageId === 'claude-background-task:overflow-live'
: false
)
).toBe(false)
})
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'
})
}
const foreign = Reflect.get(rows, 'foreign')
expect(foreign).toBeInstanceOf(Map)
expect(foreign.size).toBeLessThanOrEqual(512)
})
it('loses contact rather than claiming an outcome when the provider goes away', () => {
const { rows, latest, latestTwin } = harness()
rows.observe(START_BASH)
+141 -207
View File
@@ -1,51 +1,38 @@
// The durable transcript row one Claude background task writes.
//
// Claude announces every task — subagent, workflow, monitor, backgrounded shell
// — on one `message:system:task_*` channel. The subagent roster claims the
// agents and excludes the rest, which left the rest with no typed row at all:
// their frames were catalogued as chrome, and the generic payload sniffer then
// promoted the failed ones to a red row whose visible text was the wire opcode.
//
// Suppressing those frames instead is not an option, and that is measured, not
// assumed: when the last background task settles, the tracker flushes it and
// the strip unmounts, `local_bash` is excluded from the roster, and the status
// feed publishes live tasks only. For a lone backgrounded command, this row is
// the ONLY place its failure is ever reported.
//
// So one row per `task_id`, opened by the announcement, revised in place by the
// lifecycle frames, closed by the notification — never one row per frame, which
// is what printed a single failure twice.
// 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 type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import {
backgroundTaskFallbackText,
canReplaceBackgroundTaskState,
isSettledBackgroundTaskState
} from '../../shared/native-chat-background-task-row'
import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types'
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
classifyClaudeBackgroundTaskKind,
isBoundedClaudeTaskId,
liveClaudeTaskRunState,
record,
taskDescription,
taskId as readTaskId,
taskName,
taskText,
taskUsageTotalTokens,
terminalClaudeTaskRunState
} from './claude-background-task-frames'
import {
canReopenClaudeBackgroundTaskRowFromAggregate,
claudeBackgroundTaskNotificationChange,
claudeBackgroundTaskPatchChange,
claudeBackgroundTaskToolUseId,
isClaudeBackgroundTranscriptTask,
newClaudeBackgroundTaskRow,
newClaudeBackgroundTaskTerminalRow,
reopenClaudeBackgroundTaskRow,
reviseClaudeBackgroundTaskRow,
shouldRestartClaudeBackgroundTaskRow,
type ClaudeBackgroundTaskChange,
type ClaudeBackgroundTaskRow
} from './claude-background-task-row-lifecycle'
import { writeClaudeBackgroundTaskRow } from './claude-background-task-row-journal'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import { isClaudeSubagentTask } from './claude-subagent-task-frames'
/** Rows kept per session. Bounds an event-accumulated map no provider snapshot
* prunes; a session running more concurrent background tasks than this gets no
* row for the overflow rather than an unbounded journal. */
const MAX_TASK_ROWS = 64
const MAX_FOREIGN_TASK_ROWS = 512
const MAX_TERMINAL_TASK_IDS = 512
const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_started',
@@ -54,34 +41,7 @@ const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_notification'
])
/** Who owns an id this module is not writing a row for. */
type ForeignOwner =
/** The subagent roster announced it as an agent and renders it already. */
| 'roster'
/** Housekeeping Claude runs for itself; the user never asked for it. */
| 'ambient'
type TaskRow = { block: NativeChatBackgroundTaskBlock; lastSerialized: string | null }
/** Durable journal identity for a task's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending one. */
export function claudeBackgroundTaskIdentity(taskId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-background-task:${taskId}` }
}
/** The row: the structured block plus the plain sentence a client without the
* block type renders in its place. A message whose only block is the new
* variant would reach such a client with nothing it can draw, and a new item
* KIND would reach it as nothing at all. */
export function claudeBackgroundTaskBody(
block: NativeChatBackgroundTaskBlock
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }]
}
}
type ForeignOwner = 'roster' | 'ambient' | 'foreground'
export type ClaudeBackgroundTaskRowsDeps = {
sink: StructuredAgentSessionEventSink
@@ -89,8 +49,9 @@ export type ClaudeBackgroundTaskRowsDeps = {
}
export class ClaudeBackgroundTaskRows {
private readonly rows = new Map<string, TaskRow>()
private readonly rows = new Map<string, ClaudeBackgroundTaskRow>()
private readonly foreign = new Map<string, ForeignOwner>()
private readonly terminalTaskIds = new Set<string>()
private readonly ids = new ClaudeSubagentIds()
private readonly now: () => number
@@ -98,14 +59,14 @@ export class ClaudeBackgroundTaskRows {
this.now = deps.now ?? (() => Date.now())
}
/** Consume a background-task lifecycle frame. Returns false when it is not
* one. A `true` return means this module owns the frame, INCLUDING when it
* deliberately writes nothing for it. */
observe(message: Record<string, unknown>): boolean {
if (message.type !== 'system') {
return false
}
if (message.subtype === 'background_tasks_changed') {
if (!Array.isArray(message.tasks)) {
return false
}
this.observeAggregateRoster(message.tasks)
return true
}
@@ -114,28 +75,20 @@ export class ClaudeBackgroundTaskRows {
}
const id = this.canonicalId(message)
if (id === null) {
return true
return false
}
if (message.subtype === 'task_started') {
this.observeStart(id, message)
return true
return this.observeStart(id, message)
}
if (this.foreign.has(id)) {
return true
}
if (message.subtype === 'task_notification') {
this.observeNotification(id, message)
return true
return this.observeNotification(id, message)
}
// `task_updated` and `task_progress` are patches, not announcements: neither
// carries a `task_type`, so honouring one for an id nothing declared would
// row whatever else shares this channel. They revise, never create.
this.revise(id, this.patchChange(message))
return true
return this.observePatch(id, message)
}
/** Nothing more will arrive for any task, so every live row loses contact.
* That is not evidence it exited (docs/reference/ssh-execution-boundary.md). */
settleSession(): void {
for (const [id, row] of this.rows) {
if (!isSettledBackgroundTaskState(row.block.state)) {
@@ -145,128 +98,109 @@ export class ClaudeBackgroundTaskRows {
}
dispose(): void {
// Teardown reaches here without an `ended` event, so a row still reporting
// live work would have nothing left to revise it.
this.settleSession()
this.rows.clear()
this.foreign.clear()
this.terminalTaskIds.clear()
this.ids.clear()
}
/** The task id a frame names, with its tool id recorded as an alias: Claude
* re-announces a resumed task under a NEW `tool_use_id` while `task_id`
* stays put, so keying on the tool id would show the task twice. */
private canonicalId(message: Record<string, unknown>): string | null {
const patch = record(message.patch)
const declared = readTaskId(message)
const toolUseId = taskText(message.tool_use_id) ?? taskText(patch?.tool_use_id)
const toolUseId = claudeBackgroundTaskToolUseId(message)
if (declared === null) {
const aliased = toolUseId === undefined ? null : this.ids.canonical(toolUseId)
return aliased !== null && aliased !== toolUseId ? aliased : null
}
if (toolUseId !== undefined && isBoundedClaudeTaskId(toolUseId)) {
if (toolUseId !== undefined) {
this.ids.alias(toolUseId, declared)
}
return declared
}
private observeStart(id: string, message: Record<string, unknown>): void {
private observeStart(id: string, message: Record<string, unknown>): boolean {
if (message.ambient === true || message.skip_transcript === true) {
this.foreign.set(id, 'ambient')
return
this.rememberForeign(id, 'ambient')
return true
}
if (isClaudeSubagentTask(message)) {
this.foreign.set(id, 'roster')
return
this.rememberForeign(id, 'roster')
return true
}
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
if (!isClaudeBackgroundTranscriptTask(message, kind)) {
this.rememberForeign(id, 'foreground')
return true
}
this.foreign.delete(id)
const existing = this.rows.get(id)
if (existing) {
this.revise(id, this.patchChange(message))
return
}
if (this.rows.size >= MAX_TASK_ROWS) {
return
}
const now = this.now()
this.rows.set(id, {
lastSerialized: null,
block: {
type: 'background-task',
taskId: id,
kind: classifyClaudeBackgroundTaskKind(message.task_type),
label: taskDescription(message.description) ?? taskName(message) ?? '',
state: liveClaudeTaskRunState(message.status) ?? 'working',
startedAt: now,
...(taskUsageTotalTokens(message) === undefined
? {}
: { tokens: taskUsageTotalTokens(message) })
if (shouldRestartClaudeBackgroundTaskRow(existing, message)) {
this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now()))
this.write(id)
} else {
this.revise(id, claudeBackgroundTaskPatchChange(message))
}
})
return true
}
if (this.terminalTaskIds.has(id)) {
return true
}
if (!this.ensureRowSlot()) {
return false
}
this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now()))
this.write(id)
return true
}
private observeNotification(id: string, message: Record<string, unknown>): void {
// The notification is affirmative terminal evidence even when its status is
// unreadable, matching the liveness semantics this channel always had.
const state = terminalClaudeTaskRunState(message.status) ?? 'done'
const change: TaskChange = {
state,
summary: taskText(message.summary),
error: taskText(message.error),
outputFile: taskText(message.output_file),
tokens: taskUsageTotalTokens(message)
}
private observeNotification(id: string, message: Record<string, unknown>): boolean {
const change = claudeBackgroundTaskNotificationChange(message)
const state = change.state ?? 'done'
this.rememberTerminalId(id)
if (this.rows.has(id)) {
this.revise(id, change)
return
return true
}
// The first and last frame for a task this session never saw start — a
// resumed session, or an announcement that predates the journal. A failure
// here is the only report the user will ever get, so it opens a row of its
// own; a silent success is not worth one nobody asked for.
if (state === 'done' && change.error === undefined) {
return
return true
}
if (this.rows.size >= MAX_TASK_ROWS) {
return
if (!this.ensureRowSlot()) {
return false
}
this.rows.set(id, {
lastSerialized: null,
block: {
type: 'background-task',
taskId: id,
kind: 'unknown',
label: taskDescription(message.description) ?? taskName(message) ?? '',
state: 'working'
}
...newClaudeBackgroundTaskTerminalRow(id, message)
})
this.revise(id, change)
return true
}
/** A `task_updated` patch or a `task_progress` tick, read as a row change.
* `task_progress` carries the CURRENT ACTIVITY in `description`, not the
* task's name, so only a row still missing a label takes one from it. */
private patchChange(message: Record<string, unknown>): TaskChange {
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)
private observePatch(id: string, message: Record<string, unknown>): boolean {
const patch = record(message.patch)
if (patch?.is_backgrounded === false) {
this.rememberForeign(id, 'foreground')
return true
}
const change = claudeBackgroundTaskPatchChange(message)
if (this.rows.has(id)) {
this.revise(id, change)
return true
}
if (change.state && isSettledBackgroundTaskState(change.state)) {
this.rememberTerminalId(id)
if (change.state === 'done' && change.error === undefined) {
return true
}
if (!this.ensureRowSlot()) {
return false
}
this.rows.set(id, newClaudeBackgroundTaskTerminalRow(id, message))
this.revise(id, change)
return true
}
return change.error === undefined
}
/** The aggregate roster enumerates BACKGROUND work only, so it is
* authoritative over the tasks it lists and silent about everything else. A
* task missing from it is not thereby finished — only its own terminal frame
* says that — so this revises listed rows and creates none. */
private observeAggregateRoster(value: unknown): void {
if (!Array.isArray(value)) {
return
@@ -277,8 +211,15 @@ export class ClaudeBackgroundTaskRows {
if (task === null || id === null || !this.rows.has(id)) {
continue
}
const state = terminalClaudeTaskRunState(task.status) ?? liveClaudeTaskRunState(task.status)
const row = this.rows.get(id)
if (row && canReopenClaudeBackgroundTaskRowFromAggregate(row, state)) {
this.rows.set(id, reopenClaudeBackgroundTaskRow(row, id, task, state, this.now()))
this.write(id)
continue
}
this.revise(id, {
state: terminalClaudeTaskRunState(task.status) ?? liveClaudeTaskRunState(task.status),
state,
label: taskDescription(task.description) ?? taskName(task),
kind:
task.task_type === undefined
@@ -288,39 +229,54 @@ export class ClaudeBackgroundTaskRows {
}
}
private revise(id: string, change: TaskChange): void {
private revise(id: string, change: ClaudeBackgroundTaskChange): void {
const row = this.rows.get(id)
if (!row) {
return
}
const next: NativeChatBackgroundTaskBlock = { ...row.block }
if (change.label && !next.label) {
next.label = change.label
reviseClaudeBackgroundTaskRow(row, change, this.now())
this.write(id)
}
private ensureRowSlot(): boolean {
if (this.rows.size < MAX_TASK_ROWS) {
return true
}
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
}
// Proven outcomes latch; lost contact can still receive a later verdict.
if (change.state && canReplaceBackgroundTaskState(next.state, change.state)) {
next.state = change.state
if (isSettledBackgroundTaskState(change.state)) {
next.settledAt = this.now()
for (const [id, row] of this.rows) {
if (isSettledBackgroundTaskState(row.block.state)) {
this.rows.delete(id)
return true
}
}
row.block = next
this.write(id)
return false
}
private rememberForeign(id: string, owner: ForeignOwner): void {
if (this.foreign.has(id)) {
this.foreign.delete(id)
}
this.foreign.set(id, owner)
while (this.foreign.size > MAX_FOREIGN_TASK_ROWS) {
const oldest = this.foreign.keys().next()
if (oldest.done || oldest.value === id) {
break
}
this.foreign.delete(oldest.value)
}
}
private rememberTerminalId(id: string): void {
if (this.terminalTaskIds.has(id)) {
this.terminalTaskIds.delete(id)
}
this.terminalTaskIds.add(id)
while (this.terminalTaskIds.size > MAX_TERMINAL_TASK_IDS) {
const oldest = this.terminalTaskIds.values().next()
if (oldest.done || oldest.value === id) {
break
}
this.terminalTaskIds.delete(oldest.value)
}
}
private write(id: string): void {
@@ -328,28 +284,6 @@ export class ClaudeBackgroundTaskRows {
if (!row) {
return
}
const body = claudeBackgroundTaskBody(row.block)
const serialized = JSON.stringify(body)
if (serialized === row.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return
}
row.lastSerialized = serialized
this.deps.sink.appendItem(claudeBackgroundTaskIdentity(id), body, {
coalescingKey: `claude-background-task:${id}`
})
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
// each queued publish evict the append it was meant to flush.
this.deps.sink.publish()
writeClaudeBackgroundTaskRow(this.deps.sink, id, row)
}
}
type TaskChange = {
state?: NativeChatBackgroundTaskBlock['state'] | null
label?: string | undefined
kind?: NativeChatBackgroundTaskBlock['kind'] | undefined
summary?: string | undefined
error?: string | undefined
outputFile?: string | undefined
tokens?: number | undefined
}
@@ -122,4 +122,18 @@ describe('claude journal translation — background task rows', () => {
// 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'])
})
})
@@ -22,12 +22,8 @@ import {
readClaudeMessageEnvelope,
type ClaudeToolUse
} from './claude-structured-item-translation'
import {
claudeApprovalItem,
claudePromptIdentity,
claudeQuestionItems
} from './claude-structured-prompt-items'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { appendClaudePromptJournalItems } from './claude-structured-prompt-journal'
import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
appendUnmodeledClaudeContent,
@@ -108,7 +104,6 @@ export function createClaudeJournalTranslator(
const publishLifecycle = (turn: ClaudeCurrentTurn, end?: ClaudeTurnEnd): void => {
const item = claudeTurnLifecycleItem(turn, end)
deps.sink.appendItem(item.identity, item.body, item.options)
// Preserve first-work evidence when completion arrives before the journal drains.
deps.sink.publish({ coalescingKey: item.publishCoalescingKey })
}
@@ -146,7 +141,6 @@ export function createClaudeJournalTranslator(
}
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)
@@ -173,9 +167,7 @@ export function createClaudeJournalTranslator(
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
}
@@ -198,8 +190,6 @@ export function createClaudeJournalTranslator(
message.parent_tool_use_id === null
) {
if (currentTurn) {
// A new turn starting is the only end the previous one gets when its
// result never arrives; settling it later would sweep THIS turn.
subagents.settleTurn(groupKeyOf(currentTurn))
publishLifecycle(currentTurn, { state: 'interrupted', completedAt: observedAt })
}
@@ -207,7 +197,6 @@ export function createClaudeJournalTranslator(
sessionId: envelope.sessionId,
turnId: envelope.uuid,
startedAt: observedAt,
// A user echo lands on its own message identity, so this is the user row's key.
userItemId: agentJournalItemKey(identity)
}
publishLifecycle(currentTurn)
@@ -220,25 +209,11 @@ export function createClaudeJournalTranslator(
}
const handlePrompt = (event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>): void => {
const identities: AgentJournalItemIdentity[] = []
if (event.prompt.kind === 'question') {
for (const question of claudeQuestionItems({
sessionId: event.sessionId,
prompt: event.prompt
})) {
identities.push(question.identity)
deps.sink.appendItem(question.identity, question.body)
deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey)
}
} else {
const identity = claudePromptIdentity({
sessionId: event.sessionId,
promptKey: event.prompt.promptKey
})
identities.push(identity)
deps.sink.appendItem(identity, claudeApprovalItem(event.prompt))
deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey)
}
const identities = appendClaudePromptJournalItems({
event,
sink: deps.sink,
...(deps.bindPromptItemId ? { bindPromptItemId: deps.bindPromptItemId } : {})
})
promptItems.set(event.prompt.promptKey, identities)
deps.sink.publish()
}
@@ -247,10 +222,8 @@ export function createClaudeJournalTranslator(
handle: (event) => {
if (event.type === 'ended') {
streamedText.flush()
// No event will ever settle a child once the provider is gone.
subagents.settleSession()
if (currentTurn) {
// The host saw the child end, so the turn's end is observed, not lost.
publishLifecycle(currentTurn, {
state: 'interrupted',
completedAt: event.observedAt ?? Date.now()
@@ -273,8 +246,6 @@ export function createClaudeJournalTranslator(
promptItems.delete(event.promptKey)
deps.sink.publish()
} else if (event.type === 'message' && event.message.type === 'result') {
// The turn is over however it ended, so a foreground child still
// reported as working will never be settled by an event.
subagents.settleTurn(groupKeyOf(currentTurn))
if (currentTurn) {
publishLifecycle(
@@ -284,27 +255,23 @@ export function createClaudeJournalTranslator(
currentTurn = null
}
deps.sink.setActivity?.(null)
// The turn is over. A block still awaiting its final keeps the text the
// flush above journaled, but its live state goes: an interrupted turn
// would otherwise retain that text for the life of the session.
streamedBlocks.clear()
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') {
// The roster claims the agent tasks and the row owner claims the rest;
// both kinds are covered, so the fallback below emits nothing for them.
subagents.observeSystemFrame(event.message)
backgroundTasks.observe(event.message)
const backgroundTaskCovered = backgroundTasks.observe(event.message)
const kind = claudeProviderFrameKind(event.message)
if (
!handleMessage(event.message, event.startsTurn === true, event.observedAt ?? Date.now())
) {
providerFallback.append(kind, event.message)
providerFallback.append(kind, event.message, undefined, {
coveredByTypedTranslator: backgroundTaskCovered
})
}
publishActivity(kind, event.message)
} else if (event.type === 'provider-frame') {
@@ -322,9 +289,6 @@ export function createClaudeJournalTranslator(
promptItems.clear()
streamedBlocks.clear()
subagents.dispose()
// Settles every live row: `ended` is delivered immediately before this on
// every provider-exit path, and this one also covers a teardown with no
// `ended` at all.
backgroundTasks.dispose()
}
}
@@ -0,0 +1,37 @@
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import {
claudeApprovalItem,
claudePromptIdentity,
claudeQuestionItems
} from './claude-structured-prompt-items'
export function appendClaudePromptJournalItems(input: {
event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>
sink: StructuredAgentSessionEventSink
bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void
}): AgentJournalItemIdentity[] {
const identities: AgentJournalItemIdentity[] = []
const { event, sink, bindPromptItemId } = input
if (event.prompt.kind === 'question') {
for (const question of claudeQuestionItems({
sessionId: event.sessionId,
prompt: event.prompt
})) {
identities.push(question.identity)
sink.appendItem(question.identity, question.body)
bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey)
}
return identities
}
const identity = claudePromptIdentity({
sessionId: event.sessionId,
promptKey: event.prompt.promptKey
})
identities.push(identity)
sink.appendItem(identity, claudeApprovalItem(event.prompt))
bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey)
return identities
}
@@ -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'
@@ -106,13 +107,24 @@ export function createClaudeProviderFrameFallback(
acquisitionId: string
): {
/** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */
append: (kind: string, payload: unknown, displayText?: string | null) => void
append: (
kind: string,
payload: unknown,
displayText?: string | null,
options?: UnhandledProviderFrameJournalItemOptions
) => void
} {
let sequence = 0
return {
append: (kind, payload, displayText) => {
append: (kind, payload, displayText, options) => {
sequence += 1
const translated = unhandledProviderFrameJournalItem('claude', kind, payload)
const translated = unhandledProviderFrameJournalItem(
'claude',
kind,
payload,
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
options
)
if (!translated) {
return
}
@@ -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 {
@@ -180,16 +181,34 @@ describe('typed translator coverage', () => {
'message:system:background_tasks_changed'
]) {
expect(
unhandledProviderFrameJournalItem('claude', kind, {
task_id: 'byjnee2no',
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
}),
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', () => {
expect(
unhandledProviderFrameJournalItem('claude', 'message:system:task_notification', {
status: 'failed',
summary: 'Background command "Wait" failed with exit code 1'
})
).toMatchObject({
classification: 'error-surface',
body: { text: 'Background command "Wait" failed with exit code 1' }
})
})
it('covers Claude only — the same method name on another provider still falls back', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'message:system:task_notification', {
@@ -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
}
@@ -17,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)
@@ -33,6 +38,7 @@ const MESSAGE_KEYS = [
'text',
'warning',
'detail',
'summary',
'description',
'reason',
// `error` is how a failed dependency reports itself — an MCP server that could not start says
@@ -79,14 +85,18 @@ 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 (hasTypedProviderFrameTranslator(provider, kind)) {
if (
options.coveredByTypedTranslator === true &&
hasTypedProviderFrameTranslator(provider, kind)
) {
return null
}
const classification = classifyProviderFrame(provider, kind, payload)
@@ -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,40 +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 === 'background-task') {
// Provider strings get the same redaction and clipping as every other piece
// of transcript metadata. `outputFile` is dropped outright for the reason
// the image branch below drops local paths: it names a file on the
// execution host, which no peer reading this payload can open.
const { outputFile, ...carried } = block
if (outputFile) {
markClipped(state, 'Background task output paths were omitted from transcript output.')
}
return {
...carried,
taskId: boundEntryId(block.taskId, state),
label: clipMetadata(block.label, state),
...(block.summary ? { summary: clipText(block.summary, state) } : {}),
...(block.error ? { error: clipText(block.error, 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.')
@@ -233,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) {