Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
63cb07c
refactor(native-chat): the host hands out client delivery's status su…
brennanb2025 Sep 24, 2026
8d1a96a
feat(native-chat): Claude sessions write their subagents into the hos…
brennanb2025 Sep 23, 2026
d1694df
test(native-chat): pin the Claude child-work evidence and the host re…
brennanb2025 Sep 23, 2026
41a7438
test(native-chat): prove every hop from a Claude frame to the host's …
brennanb2025 Sep 23, 2026
bf038fe
test(native-chat): read an optional task list as optional in the prod…
brennanb2025 Sep 23, 2026
5fd3c30
test(native-chat): an address whose publish threw carries no child work
brennanb2025 Sep 23, 2026
5fd283d
test(agent-status): a foreign record differs from ours by producer alone
brennanb2025 Sep 23, 2026
7fdf1a3
feat(native-chat): a foreground Claude child's own tool call is what …
brennanb2025 Sep 23, 2026
5821a2e
fix(native-chat): a Claude child restarted under a new spawn call kee…
brennanb2025 Sep 23, 2026
0ded977
refactor(native-chat): the Claude child-tool queries and translator c…
brennanb2025 Sep 24, 2026
ac8e60a
refactor(native-chat): Claude child evidence carries only its own edg…
brennanb2025 Sep 24, 2026
fa71175
refactor(native-chat): Claude child evidence hands admission its raw …
brennanb2025 Sep 25, 2026
bf82728
fix(agent-status): admission alone decides a settled child's second e…
brennanb2025 Sep 25, 2026
4f4885a
perf(agent-status): keep child records off the status hot paths
brennanb2025 Sep 25, 2026
a366ecc
fix(native-chat): an errored Claude spawn result no longer decides ho…
brennanb2025 Sep 25, 2026
7efdd97
test(native-chat): pin a Claude foreground child's real finishing order
brennanb2025 Sep 25, 2026
c8a1f37
perf(agent-status): a store write costs what it touches, not the whol…
brennanb2025 Sep 25, 2026
368ea4d
fix(native-chat): a Claude child ends only on its own terminal frame
brennanb2025 Sep 25, 2026
35f9719
fix(agent-status): a session's end settles its live children instead …
brennanb2025 Sep 25, 2026
ed592e8
test(native-chat): replay the real Claude CLI's frame orders into a r…
brennanb2025 Sep 25, 2026
94e5159
test(agent-status): hold the store's running indexes and byte total t…
brennanb2025 Sep 25, 2026
1a9c5ee
Merge origin/main into brennanb2025/c3-claude-producer
brennanb2025 Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/main/agent-hooks/server-ingest-structured-children.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import {
makeStructuredAgentStatusSubject,
type AgentStatusStructuredSessionSubject
} from '../../shared/agent-status-subject'
import { AgentHookServer } from './server'

vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))

const SUBJECT = makeStructuredAgentStatusSubject(
{
executionHostId: 'ssh:build-box',
wslDistro: null,
workspaceId: 'workspace-one',
workspaceKind: 'folder'
},
'structured-session-one'
)

function summary(
subject: AgentStatusStructuredSessionSubject = SUBJECT
): AgentSessionStatusSummary {
return {
sessionId: subject.sessionId,
workspaceId: subject.workspaceId,
agent: 'claude',
status: 'working',
hostExecutionOwned: true,
latestPrompt: 'find the flaky tests',
updatedAt: 100
}
}

const started: AgentChildWorkEvidence[] = [
{
type: 'live',
observedAt: 200,
child: {
handle: { idKind: 'task_id', id: 'agent-1', runId: 'toolu_1' },
kind: 'agent',
residency: 'background',
state: 'working',
description: 'Audit the build',
stoppable: true
}
}
]

afterEach(() => vi.restoreAllMocks())

describe('structured child-work ingest', () => {
it('refuses children of a session whose own row the store does not hold', () => {
const server = new AgentHookServer()
expect(server.ingestStructuredChildWork(SUBJECT, started, 'claude')).toBeNull()
expect(server.getStructuredChildWork(SUBJECT)).toEqual([])
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
})

it('holds a child under the subject its parent row landed under, and leaves that row alone', () => {
const server = new AgentHookServer()
const changed = vi.fn()
server.ingestStructuredStatus(summary(), SUBJECT)
const rows = server.getStatusSnapshot()
server.subscribeStatusChanges(changed)
expect(server.ingestStructuredChildWork(SUBJECT, started, 'claude')).toMatchObject({
admitted: 1,
rejected: []
})
expect(server.getStructuredChildWork(SUBJECT)).toEqual([
expect.objectContaining({
parent: SUBJECT,
provider: 'claude',
description: 'Audit the build',
membership: 'live'
})
])
// Nothing reads the records yet: every published row is exactly what it was.
expect(server.getStatusSnapshot()).toEqual(rows)
expect(changed).not.toHaveBeenCalled()
})

it('drops the children with their parent row', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary(), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, started, 'claude')
server.dropStructuredStatus(SUBJECT)
expect(server.getStructuredChildWork(SUBJECT)).toEqual([])
expect(server.getCanonicalStatusSnapshot().children).toEqual([])
})

it('keeps sessions apart even when their provider ids collide', () => {
const server = new AgentHookServer()
const other = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-two',
workspaceKind: 'git-worktree'
},
'structured-session-two'
)
server.ingestStructuredStatus(summary(), SUBJECT)
server.ingestStructuredStatus(summary(other), other)
server.ingestStructuredChildWork(SUBJECT, started, 'claude')
server.ingestStructuredChildWork(other, started, 'claude')
const [first] = server.getStructuredChildWork(SUBJECT)
const [second] = server.getStructuredChildWork(other)
expect(first.childWorkId).not.toBe(second.childWorkId)
server.ingestStructuredChildWork(other, [{ type: 'session-ended', observedAt: 300 }], 'claude')
expect(server.getStructuredChildWork(other)).toEqual([
expect.objectContaining({ childWorkId: second.childWorkId, outcome: 'unknown' })
])
expect(server.getStructuredChildWork(SUBJECT)).toEqual([first])
})

it('rejects an address that names no session', () => {
const server = new AgentHookServer()
expect(() =>
server.ingestStructuredChildWork({ ...SUBJECT, sessionId: '' }, started, 'claude')
).toThrow('Structured child work requires its exact owner subject')
})
})
4 changes: 2 additions & 2 deletions src/main/agent-hooks/server/server-ingest-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import {
} from '../../../shared/agent-status-legacy-adapter'
import { isValidPiProviderSessionOnly } from './server-status-identity'
import { normalizeRemoteEnvelopeFields } from './server-remote-envelope-normalization'
import { AgentHookServerIngestStructured } from './server-ingest-structured'
import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children'

export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured {
export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren {
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
ingestRemote(
envelope: {
Expand Down
60 changes: 60 additions & 0 deletions src/main/agent-hooks/server/server-ingest-structured-children.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { randomUUID } from 'node:crypto'

import { createAgentChildWorkAdmission } from '../../../shared/agent-status-child-work-admission'
import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work'
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
import {
reconcileAgentChildWorkEvidence,
type AgentChildWorkReconcileOutcome
} from '../../../shared/agent-status-child-work-reconciliation'
import {
parseAgentStatusSubject,
type AgentStatusStructuredSessionSubject
} from '../../../shared/agent-status-subject'
import { AgentHookServerIngestStructured } from './server-ingest-structured'

/** Refusals that are the fence doing its job (late or superseded evidence), not a fault. */
const EXPECTED_REFUSALS: ReadonlySet<string> = new Set(['stale-invocation'])

export abstract class AgentHookServerIngestStructuredChildren extends AgentHookServerIngestStructured {
/**
* Admit one structured session's child-work evidence. The parent publication owns the subject
* and lands first; this refuses to act on a subject the store does not already hold, so a
* child can never conjure a parent row.
*/
ingestStructuredChildWork(
subject: AgentStatusStructuredSessionSubject,
evidence: AgentChildWorkEvidence[],
provider: string
): AgentChildWorkReconcileOutcome | null {
const parent = parseAgentStatusSubject(subject)
if (!parent || parent.kind !== 'structured-session') {
throw new Error('Structured child work requires its exact owner subject')
}
const store = this.canonicalStatusStore
if (!store.getParent(parent)) {
return null
}
const outcome = reconcileAgentChildWorkEvidence({
store,
admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => randomUUID() }),
parent,
provider,
evidence
})
const unexpected = outcome.rejected.filter(({ reason }) => !EXPECTED_REFUSALS.has(reason))
if (unexpected.length > 0) {
console.warn(
'[agent-status-child-work] refused structured child evidence',
unexpected.map(({ handleId, reason }) => `${handleId}:${reason}`)
)
}
return outcome
}

/** Every child record this host holds for one structured session. */
getStructuredChildWork(subject: AgentStatusStructuredSessionSubject): AgentChildWorkRecord[] {
const parent = parseAgentStatusSubject(subject)
return parent ? this.canonicalStatusStore.getChildren(parent) : []
}
}
2 changes: 1 addition & 1 deletion src/main/agent-hooks/server/server-ingest-structured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
if (this.state.lastStatusByPaneKey.has(paneKey)) {
throw new Error('Structured status address conflicts with legacy evidence')
}
const snapshot = this.canonicalStatusStore.getSnapshot()
const snapshot = this.canonicalStatusStore.getRevision()
const observedAt = Math.max(Date.now(), priorStatus?.receivedAt ?? 0)
const status: AgentStatusIpcPayload = {
paneKey,
Expand Down
2 changes: 1 addition & 1 deletion src/main/agent-hooks/server/server-listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW
})
}
for (const parent of this.canonicalStatusStore.getSnapshot().parents) {
for (const parent of this.canonicalStatusStore.getParents()) {
if (!parent.status) {
continue
}
Expand Down
147 changes: 147 additions & 0 deletions src/main/claude/claude-captured-foreground-frames.test-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Claude CLI 2.1.280 stream-json captures, cut to the frames and fields the child-work path reads.
// Ids, paths and prompts are replaced; the frame order and the relative clock (`at`, ms after the
// first user message) are the captured ones.

import {
result,
spawn,
system,
toolResult,
type CapturedFrame
} from './claude-captured-frame-builders.test-fixture'

const interruptedAgentStart = (at: number, toolUseId: string): CapturedFrame[] => [
{
at,
frame: spawn(toolUseId, 'Agent', { description: 'Run sleep command and report' }, null)
},
{
at: at + 16,
frame: system('task_started', {
task_id: 'agent-1',
tool_use_id: toolUseId,
description: 'Run sleep command and report',
subagent_type: 'general-purpose',
is_backgrounded: false,
task_type: 'local_agent'
})
},
{
at: at + 4_357,
frame: system('task_progress', {
task_id: 'agent-1',
tool_use_id: toolUseId,
usage: { total_tokens: 18_844, tool_uses: 1, duration_ms: 4_342 },
last_tool_name: 'Bash'
})
}
]

const REJECTED = "The user doesn't want to proceed with this tool use. The tool use was rejected."

/** An interrupt while the foreground agent sits between tools: its own stop comes first. */
export const INTERRUPTED_BETWEEN_TOOLS: CapturedFrame[] = [
...interruptedAgentStart(5_000, 'toolu_agent'),
{
at: 11_019,
frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'killed' } })
},
{
at: 11_019,
frame: system('task_notification', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
status: 'stopped',
summary: 'Run sleep command and report'
})
},
{ at: 11_020, frame: toolResult('toolu_agent', REJECTED, null, true) },
{ at: 11_026, frame: result('error_during_execution') }
]

/** An interrupt while the foreground agent's own shell runs: the spawn result precedes its stop. */
export const INTERRUPTED_IN_OWN_SHELL: CapturedFrame[] = [
...interruptedAgentStart(2_876, 'toolu_agent'),
{
at: 6_317,
frame: spawn('toolu_shell', 'Bash', { command: 'sleep 45; echo 1' }, 'toolu_agent')
},
{
at: 9_424,
frame: system('task_started', {
task_id: 'shell-1',
owned_by_subagent: true,
tool_use_id: 'toolu_shell',
description: 'Sleep 45 seconds then print 1',
is_backgrounded: false,
task_type: 'local_bash'
})
},
{
at: 11_321,
frame: system('task_notification', {
task_id: 'shell-1',
tool_use_id: 'toolu_shell',
status: 'stopped',
summary: 'Sleep 45 seconds then print 1'
})
},
{ at: 11_324, frame: toolResult('toolu_agent', REJECTED, null, true) },
{
at: 11_325,
frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'killed' } })
},
{
at: 11_325,
frame: system('task_notification', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
status: 'stopped',
summary: 'Run sleep command and report'
})
},
{ at: 11_328, frame: result('error_during_execution') }
]

/** A foreground agent that finishes: its own ending, then its summary, then the spawn result. */
export const FOREGROUND_SUCCESS: CapturedFrame[] = [
{ at: 3_695, frame: spawn('toolu_agent', 'Agent', { description: 'Run echo hi command' }, null) },
{
at: 3_709,
frame: system('task_started', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
description: 'Run echo hi command',
subagent_type: 'general-purpose',
is_backgrounded: false,
task_type: 'local_agent'
})
},
{
at: 5_501,
frame: system('task_progress', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
usage: { total_tokens: 14_576, tool_uses: 1, duration_ms: 1_792 },
last_tool_name: 'Bash'
})
},
{ at: 5_505, frame: spawn('toolu_echo', 'Bash', { command: 'echo hi' }, 'toolu_agent') },
{ at: 5_652, frame: toolResult('toolu_echo', 'hi', 'toolu_agent', false) },
{
at: 7_101,
frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } })
},
{
at: 7_101,
frame: system('task_notification', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
status: 'completed',
summary: 'The command executed successfully. Output: `hi`',
usage: { total_tokens: 16_908, tool_uses: 1, duration_ms: 3_393 }
})
},
{ at: 7_113, frame: toolResult('toolu_agent', 'hi', null) },
{ at: 8_952, frame: result('success') }
]
Loading
Loading