mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* 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>
339 lines
12 KiB
TypeScript
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`
|
|
)
|