Files
orca/config/scripts/native-chat-live-session-benchmark.ts
Brennan BensonandMerge Sim 72befaf360 feat(native-chat): show Claude subagent activity on the shared carrier (#18806)
* feat(native-chat): show Claude subagent activity on the shared carrier

Claude's `message:system:task_*` frames are classified `status-chrome` and
reach the transcript as nothing at all, so a turn that spawns subagents
renders as an idle turn. The journal translator now reads them into the
shared subagent-group carrier — no new UI, and the frames stay
`status-chrome` so nothing prints a raw opcode row.

`local_agent`, `local_workflow` and `local_bash` tasks share that channel
and all carry a `tool_use_id`, so `task_type` is the discriminator and a
backgrounded `sleep 20` stays out of the roster; `subagent_type` covers
releases that predate `task_type`. `skip_transcript` tasks never render,
`is_backgrounded` children survive the turn-end sweep, and a resumed task
re-announced under a fresh tool id is aliased onto its `task_id` rather
than duplicated.

A child still reported as working when the turn — or the session — ends
becomes `unverifiable`: contact was lost, which is not evidence it exited.

* fix(native-chat): stop the Claude subagent roster dropping its own rows

The roster published under the same coalescing key it appends the row
with, and the sink queue replaces any queued operation sharing a key
regardless of kind: once a write was in flight, each new append evicted
the pending publish and the next publish evicted that append, so the
body never reached the journal and `lastSerialized` had already moved
past it. Publish now takes the sink's own slot, as the Codex streams do.

A tombstoned row could never come back: the non-batch item-row builder
derived its revision from `items` alone, so a re-add was built at
revision 1 against a tombstone at 2 and the reducer discarded it
forever. It now takes the same `max(items, tombstones)` the batch
builder already used — reachable here because an announcement that
reveals a `local_bash` task empties and tombstones the group row that
a genuine subagent later in the turn reuses.

`settleTurn` swept whatever group the key named at the time it ran, so
children rostered before any turn key existed were never swept, and a
turn whose result never arrives was left working forever. The ending
turn's key is now an argument, a superseding turn start settles the
turn it replaces, and every turn end also sweeps the outside-turn
group. Teardown without an `ended` event, and eviction past the group
bound, both lose contact instead of stranding a row at `working`.

Label ordinals are a high-water mark now: releasing one on a re-label
handed the next child an ordinal that was already on screen.

* fix(native-chat): bound subagent-group blocks on every wire that carries one

Adding a fifth arm to `NativeChatBlock` made every consumer that assumed
four wrong. Two of them ended in `return block`, so they compiled while
handing a roster straight through: the mobile RPC sanitizer shipped it
unclipped past both mobile char caps, and the legacy transcript import
stored an untrusted roster unbounded. Both now clip each label and cap
the entry count the way they bound their other blocks.

The remaining three sites did not compile at all. The worker transcript
payload and the live-session benchmark get real arms rather than casts —
a cast would have turned the transcript one into a third silent
passthrough inside the wire byte budget — and the CLI worker output
renders a roster with its shared summary instead of `[image omitted]`.

The mobile sanitizer moves to a sibling module beside the image-block
one: the file sat exactly on the max-lines bound, and the block bounds
are a self-contained concern with their own caps.

Also caps the roster's `subagent_type` label fallback, which reached the
journal uncapped, and covers the new block type in the schema audit.

* test(native-chat): cover the capped subagent_type label

The roster stores the frame's label verbatim, so the cap on the
`subagent_type` fallback is the only thing bounding it.

* fix(native-chat): type the roster fixture so the suite typechecks

The mobile-cap test built its entries with an inferred `state: string`, which
is not a `NativeChatSubagentState` — the only typecheck failure on the branch.

* fix(native-chat): stop child traffic rostering an id Claude never announced

`observeChildActivity` minted a provisional row for any `parent_tool_use_id`
outside the excluded set. An id that was never announced is never excluded, so
a nested Task, a workflow child, or a grandchild parented to a tool id inside
the sidechain each produced a permanently unlabelled `subagent` row that could
only ever end `unverifiable`. The bounded exclusion set cannot cover an id no
frame ever declared, and in a long session it can forget a genuine exclusion.

Track instead whether this CLI announces tasks at all — set by ANY
`task_started`, including one the subagent filter rejects. Once it has, an
undeclared child is provably not a new subagent, so no row is created. The
provisional path now serves only releases that announce no task frames, which
is what its comment already said it was for.

The label-ordinal test moves to an announcement-driven removal, the scenario
that path now actually reaches; it still fails if `remove` releases the ordinal.

* fix(native-chat): outrank the tombstone when building one too

`buildJournalTombstoneRow` still built its revision from `items` alone, leaving
it asymmetric with the item builder. It is correct today only because
`upsertItem` clears the tombstone whenever a re-add wins — an invariant that
lives in the reducer and was not pinned. Apply the same `Math.max`, and pin the
invariant so the reducer cannot drop it silently.

* fix(native-chat): stop an unrelated turn end settling an outside-turn child

`settleTurn` swept the `outside-turn` group on every turn end, so a child
Claude announced while no turn was live — a frame trailing the previous
turn's result, or one that arrives before the first turn starts — was
marked `unverifiable` by the next, unrelated turn ending. That state is
terminal and latches, so the `task_updated: completed` that followed was
discarded: loss of contact was recorded as the child's outcome on
evidence that was never about that child.

A turn end now sweeps exactly the group its key names. `outside-turn`
belongs to no turn, so only an end with no key of its own reaches it, and
what no turn end reaches `settleSession` does — reliably, since teardown
without an `ended` event also routes through it. The cost is a child
outside every turn showing `working` a little longer; the alternative
prints a wrong outcome that nothing can revise.

Also pins that a subagent announced after a task the filter rejected
still rosters: the announcement path was never what the child-traffic
gate closes.

* fix(native-chat): bound a subagent entry's id, not just its label

Every site that bounds a `subagent-group` block clipped the label and
handed the id through whole. From the Claude producer the id is bounded
upstream, but the legacy transcript import reads an untrusted file, so an
oversized id survived into the journal and then out to every wire that
replays it — 64 entries of it, since only the entry count was capped.

Each site now clips the id with the helper it already uses for its other
bounded fields: the journal's inline-text bound on import, the
transcript payload's metadata clip, and the mobile char cap (renamed,
since it is no longer a label-only cap).

* fix(native-chat): surface an adverse subagent outcome in the fallback sentence

The roster row's plain-text stand-in counted only `working`, so a fan-out whose
children all latched `unverifiable` (or `failed`, or `stopped`) rendered as
"Ran 3 subagents" — a completion claim. Mobile and paired web have no roster
renderer, so that write-time-frozen sentence is the entire row there, and
collapsing `unverifiable` into something that reads like success is exactly what
the SSH execution boundary forbids.

It now appends the worst adverse count, worst-first across failed/stopped/
unverifiable, and shows it even while siblings still work — matching the Codex
lane's shared `subagentGroupFallbackText` verbatim so collapsing the two copies
later is a deletion, not a behaviour change.

Also bounds the provisional entry id. `observeChildActivity` wrote the
`parent_tool_use_id` straight into the entry's durable id with no length cap,
while the announced path already rejects an over-long id via `claudeTaskId`.
Both now share `isBoundedClaudeTaskId`, and the provisional path rejects rather
than truncates, as the announced one does.

* fix(native-chat): stop a subagent label ordinal and a clipped roster key colliding

- claimLabel probes the labels the group actually rendered instead of a
  per-base counter, so a generated `Audit 2` cannot duplicate a provider's
  own `Audit 2`.
- Bound `NativeChatSubagentEntry.id` with a head plus a digest of the whole
  id at every site that bounds it. The id is the roster key: a prefix clip
  merged two distinct children onto one entry.
- Correct a stale journal-reducer test comment: tombstone cleanup is a
  map-state invariant, no longer load-bearing for revision ordering.

* fix(claude): merge duplicate unhandled-provider-frame imports

* fix(claude): preserve subagent lifecycle and bounded invocation identity

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 21:44:10 -07:00

339 lines
12 KiB
TypeScript

/**
* Synthetic benchmark for native chat's post-incremental renderer hot paths.
* Run: pnpm exec tsx config/scripts/native-chat-live-session-benchmark.ts
*
* This excludes transcript parsing, IPC/remote latency, React, and DOM work. The
* 2 KB/message fixtures intentionally stress fallback-key normalization and are
* not estimates of average production message size or end-to-end frame latency.
*/
import { deepStrictEqual, strictEqual } from 'node:assert'
import { performance } from 'node:perf_hooks'
import type { NativeChatMessage, NativeChatSession } from '../../src/shared/native-chat-types'
import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles'
import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-command-envelope'
import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation'
import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status'
import {
matchingNativeChatUserRows,
selectPendingIndicesRepresentedByUserRows,
type NativeChatUserRow
} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence'
import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending'
import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler'
type Operation = (index: number) => number
type ExpectedChecksum = (iterations: number) => number
type Calibration = { iterations: number; elapsedMs: number; capped: boolean }
const TARGET_SAMPLE_MS = 50
const MAX_ITERATIONS = 16_777_216
const ROUNDS = 10
let checksum = 0
let expectedChecksum = 0
let validatedCases = 0
let cappedCalibrations = 0
let sessionSink: NativeChatSession | null = null
let messageArraySink: NativeChatMessage[] | null = null
let contentSink = ''
let pendingMatchSink: Set<number> | null = null
let userRowSink: readonly NativeChatUserRow[] | null = null
const benchmarkStartedAt = performance.now()
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes)
return Array.from({ length: count }, (_, index) => ({
id: `message-${index}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
blocks: [{ type: 'text' as const, text: `${payload}-${index}` }],
timestamp: index,
source: 'transcript' as const,
...(withTurnId ? { turnId: `turn-${index}` } : {})
}))
}
function toolFixture(count: number): NativeChatMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `tool-${index}`,
role: index % 2 === 0 ? ('assistant' as const) : ('tool' as const),
blocks:
index % 2 === 0
? [
{
type: 'tool-call' as const,
name: 'read',
input: { path: `${index}.txt`, context: 'x'.repeat(512) }
}
]
: [{ type: 'tool-result' as const, output: `result-${index}-${'x'.repeat(512)}` }],
timestamp: index,
source: 'transcript' as const
}))
}
function legacySession(messages: NativeChatMessage[]): NativeChatSession {
return assembleNativeChatSession({
sources: { transcript: messages },
sessionId: 'benchmark',
agent: 'claude'
})
}
function legacyMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
const commandNames = new Set(
getVerifiedNativeChatCommands('claude').map((command) => command.name)
)
return legacySession(surfaceSkillInvocationUserTurns(messages, commandNames))
}
function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
return mergeNativeChatLiveSession({
messages: prepareNativeChatLiveMessages(messages, 'claude'),
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
}
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
// An empty queue makes the renderer skip candidate-row construction entirely, so the
// row scan is the whole cost here — keep it escaping and let the matcher take its exit.
userRowSink = matchingNativeChatUserRows(messages)
pendingMatchSink = selectPendingIndicesRepresentedByUserRows([], [])
return []
}
function blockContent(message: NativeChatMessage): string {
const block = message.blocks[0]
if (!block) {
return ''
}
if (block.type === 'text') {
return block.text
}
if (block.type === 'tool-call') {
return block.name
}
if (block.type === 'tool-result') {
return block.output
}
return block.type === 'image-ref' ? (block.path ?? block.url ?? block.alt ?? '') : block.groupId
}
function messageWeight(message: NativeChatMessage, content: string): number {
const idTail = message.id.length > 0 ? message.id.charCodeAt(message.id.length - 1) : 0
const contentTail = content.length > 0 ? content.charCodeAt(content.length - 1) : 0
return message.id.length + idTail + message.role.charCodeAt(0) + content.length + contentTail
}
function consumeSession(session: NativeChatSession, index: number): number {
sessionSink = session
messageArraySink = session.messages
const message = session.messages[index % session.messages.length]
if (!message) {
contentSink = ''
return session.status.charCodeAt(0)
}
contentSink = blockContent(message)
return session.status.charCodeAt(0) + messageWeight(message, contentSink)
}
function consumePendingOutput(messages: NativeChatMessage[], index: number): number {
messageArraySink = messages
return (index % 7) + 1
}
function cyclicChecksum(values: readonly number[], iterations: number): number {
if (values.length === 0) {
return 0
}
const cycle = values.reduce((sum, value) => sum + value, 0)
const fullCycles = Math.floor(iterations / values.length)
let total = cycle * fullCycles
for (let index = 0; index < iterations % values.length; index += 1) {
total += values[index]!
}
return total
}
function sessionExpectedChecksum(session: NativeChatSession): ExpectedChecksum {
const statusWeight = session.status.charCodeAt(0)
const weights = session.messages.map((message) => {
const content = blockContent(message)
return messageWeight(message, content)
})
return (iterations) => statusWeight * iterations + cyclicChecksum(weights, iterations)
}
function pendingExpectedChecksum(iterations: number): number {
return cyclicChecksum([1, 2, 3, 4, 5, 6, 7], iterations)
}
function runSample(operation: Operation, expected: ExpectedChecksum, iterations: number): number {
let sampleChecksum = 0
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
sampleChecksum += operation(index)
}
const elapsedMs = performance.now() - startedAt
checksum += sampleChecksum
expectedChecksum += expected(iterations)
return elapsedMs
}
function median(samples: number[]): number {
return [...samples].sort((left, right) => left - right)[Math.floor(samples.length / 2)]!
}
function calibrate(operation: Operation, expected: ExpectedChecksum): Calibration {
let iterations = 1
let reachedTarget = false
while (true) {
const elapsedMs = runSample(operation, expected, iterations)
if (elapsedMs >= TARGET_SAMPLE_MS) {
if (reachedTarget) {
return { iterations, elapsedMs, capped: false }
}
reachedTarget = true
continue
}
if (iterations >= MAX_ITERATIONS) {
return { iterations, elapsedMs, capped: true }
}
reachedTarget = false
iterations = Math.min(iterations * 2, MAX_ITERATIONS)
}
}
function benchmark(
name: string,
baseline: Operation,
optimized: Operation,
expected: ExpectedChecksum
): void {
const baselineValue = baseline(0)
const optimizedValue = optimized(0)
strictEqual(optimizedValue, baselineValue, `${name}: timed arms returned different checksums`)
validatedCases += 1
const baselineCalibration = calibrate(baseline, expected)
const optimizedCalibration = calibrate(optimized, expected)
cappedCalibrations += Number(baselineCalibration.capped) + Number(optimizedCalibration.capped)
const baselineSamples: number[] = []
const optimizedSamples: number[] = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
} else {
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
}
}
const baselineMs = median(baselineSamples)
const optimizedMs = median(optimizedSamples)
const speedup = baselineMs / Math.max(optimizedMs, Number.EPSILON)
console.log(
`${name}\t${baselineCalibration.iterations}\t${optimizedCalibration.iterations}\t${baselineCalibration.elapsedMs.toFixed(1)}\t${optimizedCalibration.elapsedMs.toFixed(1)}\t${baselineMs.toFixed(6)}\t${optimizedMs.toFixed(6)}\t${speedup.toFixed(1)}x`
)
}
function benchmarkSessionArms(
name: string,
baselineSession: () => NativeChatSession,
optimizedSession: () => NativeChatSession
): void {
const expectedSession = baselineSession()
deepStrictEqual(optimizedSession(), expectedSession, `${name}: session mismatch`)
const expected = sessionExpectedChecksum(expectedSession)
benchmark(
name,
(index) => consumeSession(baselineSession(), index),
(index) => consumeSession(optimizedSession(), index),
expected
)
}
function benchmarkMessageUpdate(name: string, messages: NativeChatMessage[]): void {
benchmarkSessionArms(
name,
() => legacyMessageUpdateSession(messages),
() => directMessageUpdateSession(messages)
)
}
const prose300 = proseFixture(300, 2_048, false)
const fixtures = [
['300 x 2KB prose, no turnId', prose300],
['300 x 2KB prose, with turnId', proseFixture(300, 2_048, true)],
['300 tool-heavy, no turnId', toolFixture(300)],
['100 x 2KB prose, no turnId', proseFixture(100, 2_048, false)],
['500 x 2KB prose, no turnId', proseFixture(500, 2_048, false)]
] as const
console.log(
`Node ${process.version}; ${ROUNDS} alternating interleaved median rounds; ${TARGET_SAMPLE_MS} ms calibration target; ${MAX_ITERATIONS} iteration cap`
)
console.log(
'case\tbaseline iters\toptimized iters\tbaseline cal ms\toptimized cal ms\tbaseline ms/op\toptimized ms/op\tspeedup'
)
for (const [name, messages] of fixtures) {
benchmarkMessageUpdate(name, messages)
}
benchmarkSessionArms(
'300 x 2KB status-only frame',
() => legacySession(prose300),
() =>
mergeNativeChatLiveSession({
messages: prose300,
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
)
deepStrictEqual(pendingSendsAsMessages([], prose300), oldEmptyPending(prose300))
benchmark(
'300 x 2KB empty pending',
(index) => consumePendingOutput(oldEmptyPending(prose300), index),
(index) => consumePendingOutput(pendingSendsAsMessages([], prose300), index),
pendingExpectedChecksum
)
const combinedSessionExpected = sessionExpectedChecksum(legacyMessageUpdateSession(prose300))
benchmark(
'300 x 2KB combined',
(index) =>
consumeSession(legacyMessageUpdateSession(prose300), index) +
consumePendingOutput(oldEmptyPending(prose300), index),
(index) =>
consumeSession(directMessageUpdateSession(prose300), index) +
consumePendingOutput(pendingSendsAsMessages([], prose300), index),
(iterations) => combinedSessionExpected(iterations) + pendingExpectedChecksum(iterations)
)
strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted')
strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape')
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
strictEqual(contentSink.length > 0, true, 'message content did not escape')
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
strictEqual(Array.isArray(userRowSink), true, 'user row scan did not escape')
console.log(
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
performance.now() - benchmarkStartedAt
).toFixed(0)} ms`
)