From f8b8382fc00c5eaf7572493608f611c55cad6235 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:33:36 -0700 Subject: [PATCH 01/45] refactor(claude-sidecar): the SDK content frames are a pure mapping (B1) The assistant, stream-event and compaction frames move out of handleSdkMessage into sdk-message-frames.mjs (framesForSdkMessage, one function per kind), next to core's result mapping. index.mjs keeps the kinds with side effects (init, commands_changed, result, the checkpoint replay binding). New characterization tests feed the pinned CLI's recorded SDK sequences through the mapping and pin today's frames. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 37 +----- .../sdk-message-frames.mjs | 79 ++++++++++++- .../claude-sidecar/sdk-content-frames.test.ts | 107 ++++++++++++++++++ 3 files changed, 191 insertions(+), 32 deletions(-) create mode 100644 test/unit/claude-sidecar/sdk-content-frames.test.ts diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 30c22cca2..68495766b 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -103,7 +103,7 @@ import { createInterface } from 'node:readline' import { randomBytes } from 'node:crypto' import { configureSession, userMessageContent } from './session-settings.mjs' -import { resultFrames } from './sdk-message-frames.mjs' +import { createFrameState, framesForSdkMessage, resultFrames } from './sdk-message-frames.mjs' import { createTurnCompleteGate } from './turn-complete-gate.mjs' import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { nextMonotonic } from './monotonic-clock.mjs' @@ -323,34 +323,9 @@ function handleSdkMessage(sessionId, msg) { st.commandCatalog = commands publishCommandsIfChanged(sessionId, st) } - } else if (msg.subtype === 'status' && msg.status === 'compacting') { - emit({ type: 'sdk.status', sessionId, status: 'compacting' }) - } else if (msg.subtype === 'compact_boundary') { - // kata 1wxv ep3-r1 F1: the bare compacting STATUS frame carries no - // trigger — the SDK fires it for an explicit `/compact` AND for its - // own automatic context compaction, and misattributing the automatic - // one to a queued explicit compact wedges the rollback busy gate - // (the phantom compact absorbs the turn's own terminal edge). Only the - // compact COMPLETION boundary discriminates the trigger; relay it. - // Fail toward 'auto' on a missing/unknown trigger so promotion to a - // queued explicit compact never happens without a proven manual run. - const trigger = msg.compact_metadata?.trigger === 'manual' ? 'manual' : 'auto' - emit({ type: 'sdk.compact_boundary', sessionId, trigger }) } break } - case 'assistant': { - const content = msg.message?.content || [] - const blocks = content.map((b) => { - if (b.type === 'text') return { type: 'text', text: b.text } - if (b.type === 'thinking') return { type: 'thinking', thinking: b.thinking } - if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input } - if (b.type === 'tool_result') return { type: 'tool_result', tool_use_id: b.tool_use_id, content: b.content, is_error: b.is_error } - return b - }) - emit({ type: 'sdk.assistant', sessionId, content: blocks, model: msg.message?.model }) - break - } case 'result': { // The awaited turn's terminal frame arrived: drop one pending send // (clamped at 0 — an unsolicited result must not underflow the counter). @@ -369,14 +344,12 @@ function handleSdkMessage(sessionId, msg) { } break } - case 'stream_event': { - emit({ type: 'sdk.stream', sessionId, event: msg.event, parentToolUseId: msg.parent_tool_use_id }) - break - } default: - // Unhandled SDK message type — ignored (matches SdkBridge default). break } + // The content kinds (assistant, stream events, compaction): a pure mapping, + // sdk-message-frames.mjs. Any kind it does not map is dropped there. + for (const frame of framesForSdkMessage(sessionId, msg, { stream: st.frameState })) emit(frame) } async function consumeStream(sessionId, sdkQuery) { @@ -508,6 +481,8 @@ function handleCreate(req) { turnCompleteGate: createTurnCompleteGate(), pendingResults: 0, pendingCheckpointAttempts: new Map(), + // The content mapping's own per-session state (sdk-message-frames.mjs). + frameState: createFrameState(), } const { iterable, handle } = createInputStream((isCompact) => { if (isCompact) state.handedCompactLikely = true diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index 4a0995166..37801cb48 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -2,7 +2,8 @@ // (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.2 and §9.1 N6). One // function per SDK message kind; index.mjs keeps the side effects (the turn // gate, pendingResults, the attention clock, emit). The core runtime owns the -// `result` mapping below; the transcript work adds the content kinds here. +// `result` mapping below; the transcript work owns the content kinds +// (framesForSdkMessage and the functions it dispatches to, at the end). // // Result shapes are the pinned CLI's (test/integration/providers/ // claude-cli-contract.test.ts): a Stop ends the turn with @@ -74,3 +75,79 @@ export function resultFrames(sessionId, result, { userStopped }) { if (failure) frames.push({ type: 'sdk.error', sessionId, message: failure, turnFailure: true }) return frames } + +// ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ── +// +// framesForSdkMessage maps the SDK messages whose content reaches the server: +// assistant messages, stream events and the compaction status frames. Every +// other kind maps to no frame here: `result`, `system/init`, `commands_changed` +// and the checkpoint replay binding carry side effects, so index.mjs handles +// them itself. + +/** + * The per-session state of the content mapping. index.mjs keeps one per + * session and passes it in as `session.stream`. + */ +export function createFrameState() { + return {} +} + +/** + * The frames for one SDK message of a content kind (none for any other kind). + * `session` is what the sidecar knows about the session when the message + * arrives: `{ stream }`, the session's createFrameState(). + */ +export function framesForSdkMessage(sessionId, msg, session) { + switch (msg?.type) { + case 'assistant': return assistantFrames(sessionId, msg) + case 'stream_event': return streamEventFrames(sessionId, msg, session.stream) + case 'system': + switch (msg.subtype) { + case 'status': return statusFrames(sessionId, msg) + case 'compact_boundary': return compactBoundaryFrames(sessionId, msg) + default: return [] + } + default: return [] + } +} + +/** A content block as the server reads it (a thinking block's signature stays behind). */ +function contentBlock(block) { + if (block.type === 'text') return { type: 'text', text: block.text } + if (block.type === 'thinking') return { type: 'thinking', thinking: block.thinking } + if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input } + if (block.type === 'tool_result') return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, is_error: block.is_error } + return block +} + +/** An assistant message (the CLI sends one per completed content block): `sdk.assistant`. */ +export function assistantFrames(sessionId, msg) { + const content = msg.message?.content || [] + return [{ type: 'sdk.assistant', sessionId, content: content.map(contentBlock), model: msg.message?.model }] +} + +/** A streaming event: `sdk.stream`. */ +export function streamEventFrames(sessionId, msg, _stream) { + return [{ type: 'sdk.stream', sessionId, event: msg.event, parentToolUseId: msg.parent_tool_use_id }] +} + +/** A status frame: the CLI started compacting. */ +export function statusFrames(sessionId, msg) { + return msg.status === 'compacting' ? [{ type: 'sdk.status', sessionId, status: 'compacting' }] : [] +} + +/** + * A compaction finished: its trigger. kata 1wxv ep3-r1 F1: the bare + * compacting STATUS frame carries no trigger — the SDK fires it for an + * explicit `/compact` AND for its own automatic context compaction, and + * misattributing the automatic one to a queued explicit compact wedges the + * rollback busy gate (the phantom compact absorbs the turn's own terminal + * edge). Only the compact COMPLETION boundary discriminates the trigger, so + * the server gets it from here. A missing or unknown trigger counts as + * 'auto', so promotion to a queued explicit compact never happens without a + * proven manual run. + */ +export function compactBoundaryFrames(sessionId, msg) { + const trigger = msg.compact_metadata?.trigger === 'manual' ? 'manual' : 'auto' + return [{ type: 'sdk.compact_boundary', sessionId, trigger }] +} diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts new file mode 100644 index 000000000..988907c07 --- /dev/null +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node + +// The content half of the pure SDK message -> sidecar frame mapping +// (crates/freshell-claude-sidecar/sdk-message-frames.mjs): what reaches the +// server for the messages the transcript owns (assistant, user, stream_event, +// compaction and status frames). The `result` half has its own tests +// (sdk-message-frames.test.ts). +// +// The sequences fed here were recorded from the pinned CLI 2.1.237 against a +// local fake endpoint (test/integration/providers/claude-capture-fixtures.mjs), +// so every shape is one the real CLI produces. + +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + createFrameState, + framesForSdkMessage, +} from '../../../crates/freshell-claude-sidecar/sdk-message-frames.mjs' + +const RECORDINGS = path.resolve(__dirname, '../../fixtures/fresh-agent/claude/real-cli-2.1.237') +const SCENARIOS = ['tools-sync', 'agent-async', 'agent-failed', 'compact-ok', 'compact-fail', 'api-error', 'image'] as const + +type Msg = Record + +/** The SDK messages the pinned CLI produced for one recorded scenario, in order. */ +function recorded(scenario: string): Msg[] { + return fs.readFileSync(path.join(RECORDINGS, scenario, 'sdk.jsonl'), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line).msg as Msg) +} + +/** What the sidecar knows about its session when a message arrives. */ +function session(state = createFrameState(), overrides: Record = {}) { + return { stream: state, permissionMode: 'bypassPermissions', turnPending: true, ...overrides } +} + +/** Every message of a recording with the frames it maps to (one mapping state per session). */ +function mapRecording(scenario: string) { + const state = createFrameState() + return recorded(scenario).map((msg) => ({ msg, frames: framesForSdkMessage('s1', msg, session(state)) })) +} + +const isStatus = (status: unknown) => (msg: Msg) => msg.type === 'system' && msg.subtype === 'status' && msg.status === status + +describe('framesForSdkMessage over the pinned CLI\'s recorded sequences', () => { + it.each(SCENARIOS)('%s: each assistant message becomes one sdk.assistant with its content blocks and model', (scenario) => { + const mapped = mapRecording(scenario).filter(({ msg }) => msg.type === 'assistant') + expect(mapped.length).toBeGreaterThan(0) + for (const { msg, frames } of mapped) { + const assistant = frames.filter((frame: Msg) => frame.type === 'sdk.assistant') + expect(assistant).toHaveLength(1) + expect(assistant[0]).toMatchObject({ sessionId: 's1', model: msg.message.model }) + expect(assistant[0].content.map((block: Msg) => block.type)).toEqual(msg.message.content.map((block: Msg) => block.type)) + } + }) + + it.each(SCENARIOS)('%s: messages that carry nothing for the server map to no frame', (scenario) => { + const silent = (msg: Msg) => msg.type === 'result' + || (msg.type === 'system' && ['init', 'task_started', 'task_progress', 'task_updated', 'task_notification', 'background_tasks_changed', 'thinking_tokens'].includes(msg.subtype)) + || isStatus('requesting')(msg) + for (const { msg, frames } of mapRecording(scenario).filter(({ msg }) => silent(msg))) { + expect(frames, JSON.stringify(msg).slice(0, 200)).toEqual([]) + } + }) + + it.each(SCENARIOS)('%s: each stream event is forwarded as sdk.stream with its event and parent tool use id', (scenario) => { + for (const { msg, frames } of mapRecording(scenario).filter(({ msg }) => msg.type === 'stream_event')) { + expect(frames).toEqual([{ type: 'sdk.stream', sessionId: 's1', event: msg.event, parentToolUseId: msg.parent_tool_use_id }]) + } + }) + + it('projects each content block the way the server reads it (a thinking block loses its signature)', () => { + const blocks = mapRecording('tools-sync') + .filter(({ msg }) => msg.type === 'assistant' && msg.parent_tool_use_id === null) + .slice(0, 3) + .map(({ frames }) => frames.find((frame: Msg) => frame.type === 'sdk.assistant').content) + expect(blocks).toEqual([ + [{ type: 'thinking', thinking: 'plan the write' }], + [{ type: 'text', text: 'Writing the file now.' }], + [{ type: 'tool_use', id: 'toolu_write1', name: 'Write', input: { file_path: '/fixture/ws/a.txt', content: 'one\n' } }], + ]) + }) + + it('a compaction: the compacting status and the boundary (with its trigger) reach the server', () => { + const mapped = mapRecording('compact-ok') + const compacting = mapped.find(({ msg }) => isStatus('compacting')(msg))! + expect(compacting.frames).toEqual([{ type: 'sdk.status', sessionId: 's1', status: 'compacting' }]) + const boundary = mapped.find(({ msg }) => msg.type === 'system' && msg.subtype === 'compact_boundary')! + expect(boundary.frames.filter((frame: Msg) => frame.type === 'sdk.compact_boundary')) + .toEqual([{ type: 'sdk.compact_boundary', sessionId: 's1', trigger: 'manual' }]) + }) + + it('a compaction boundary with a missing or unknown trigger counts as automatic', () => { + for (const compact_metadata of [undefined, {}, { trigger: 'other' }, { trigger: 'auto' }]) { + const frames = framesForSdkMessage('s1', { type: 'system', subtype: 'compact_boundary', compact_metadata, uuid: 'b1' }, session()) + expect(frames.find((frame: Msg) => frame.type === 'sdk.compact_boundary')).toEqual({ type: 'sdk.compact_boundary', sessionId: 's1', trigger: 'auto' }) + } + }) + + it('ignores kinds it does not map, and a message with no type', () => { + expect(framesForSdkMessage('s1', { type: 'tool_progress', tool_use_id: 't1', heartbeat: true }, session())).toEqual([]) + expect(framesForSdkMessage('s1', { type: 'system', subtype: 'session_state_changed', state: 'idle' }, session())).toEqual([]) + expect(framesForSdkMessage('s1', {}, session())).toEqual([]) + }) +}) From 1ea78d0655cdac84382ec4fe73f04ae4498a0421 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:35:39 -0700 Subject: [PATCH 02/45] feat(claude-sidecar): announce each transcript message; sdk.assistant carries its ids (B2) The CLI writes every assistant and user message, compaction boundary and local command output to its transcript under the message's own uuid, about 100 ms after emitting it. The sidecar now follows each such message with sdk.session.changed {reason:'transcript', uuid, parentToolUseId}, which the client already treats as a snapshot invalidation, so a visible pane refetches per message instead of waiting for the 3 s busy poll. sdk.assistant also carries uuid, messageId and parentToolUseId, so a live streamed block can be bound to the transcript line it becomes. Tests: the mapping over every recorded real-CLI sequence announces exactly the messages whose uuid is a transcript line (main or subagent); through index.mjs over the simulator, each announced uuid lands in the transcript within 2 s. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 9 ++- .../sdk-message-frames.mjs | 52 ++++++++++++-- test/unit/claude-sidecar/live-frames.test.ts | 67 ++++++++++++++++++ .../claude-sidecar/sdk-content-frames.test.ts | 70 +++++++++++++++++++ 4 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 test/unit/claude-sidecar/live-frames.test.ts diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 68495766b..06cb34a08 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -37,7 +37,9 @@ // turn), and the sidecar then exits with code 2. // { type:'sdk.configured', sessionId, requestId, ok, settings?, message? } // { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools } // durable Claude UUID -// { type:'sdk.assistant', sessionId, content, model } +// { type:'sdk.assistant', sessionId, content, model, uuid, messageId, parentToolUseId } +// — one per completed content block (blocks of one API message share +// messageId); parentToolUseId is set inside a subagent, else null // { type:'sdk.stream', sessionId, event, parentToolUseId } // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except @@ -51,6 +53,11 @@ // — the command catalog without the terminal-only commands (the pinned // CLI's until a turn's init names its own): from the boot, then // whenever a turn's init or commands_changed changes it +// { type:'sdk.session.changed', sessionId, reason:'transcript', uuid, parentToolUseId } +// — the CLI writes this message (assistant, user, compaction boundary, +// local command output) to its transcript under `uuid`, about 100 ms +// later; sent right after the message's own frames +// (sdk-message-frames.mjs) // { type:'sdk.status', sessionId, status } // compacting (an ended stream sends // no status: the server reports the session as exited) // { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index 37801cb48..a0ec15f80 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -79,10 +79,17 @@ export function resultFrames(sessionId, result, { userStopped }) { // ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ── // // framesForSdkMessage maps the SDK messages whose content reaches the server: -// assistant messages, stream events and the compaction status frames. Every -// other kind maps to no frame here: `result`, `system/init`, `commands_changed` -// and the checkpoint replay binding carry side effects, so index.mjs handles -// them itself. +// assistant and user messages, stream events and the compaction status frames. +// Every other kind maps to no frame here: `result`, `system/init`, +// `commands_changed` and the checkpoint replay binding carry side effects, so +// index.mjs handles them itself. +// +// Transcript announcements: the CLI writes each assistant and user message, +// each compaction boundary and each local command's output to its transcript +// (JSONL) under the message's own uuid (main or subagent file), about 100 ms +// after it emits it. Each such message is followed by +// `sdk.session.changed {reason:'transcript', uuid, parentToolUseId}`, which +// tells the client to refetch the snapshot and which line to expect in it. /** * The per-session state of the content mapping. index.mjs keeps one per @@ -100,11 +107,13 @@ export function createFrameState() { export function framesForSdkMessage(sessionId, msg, session) { switch (msg?.type) { case 'assistant': return assistantFrames(sessionId, msg) + case 'user': return transcriptAnnouncement(sessionId, msg) case 'stream_event': return streamEventFrames(sessionId, msg, session.stream) case 'system': switch (msg.subtype) { case 'status': return statusFrames(sessionId, msg) case 'compact_boundary': return compactBoundaryFrames(sessionId, msg) + case 'local_command_output': return transcriptAnnouncement(sessionId, msg) default: return [] } default: return [] @@ -120,10 +129,39 @@ function contentBlock(block) { return block } -/** An assistant message (the CLI sends one per completed content block): `sdk.assistant`. */ +/** The parent tool use of a subagent (sidechain) message, or null for the main thread. */ +function parentToolUseIdOf(msg) { + return typeof msg.parent_tool_use_id === 'string' && msg.parent_tool_use_id ? msg.parent_tool_use_id : null +} + +/** + * The announcement that the CLI writes this message to its transcript under + * its uuid (none for a message without one). + */ +export function transcriptAnnouncement(sessionId, msg) { + if (typeof msg.uuid !== 'string' || !msg.uuid) return [] + return [{ type: 'sdk.session.changed', sessionId, reason: 'transcript', uuid: msg.uuid, parentToolUseId: parentToolUseIdOf(msg) }] +} + +/** + * An assistant message (the CLI sends one per completed content block, all + * sharing the API message id): `sdk.assistant`, then its announcement. The + * uuid and messageId bind a streamed block to the transcript line it becomes. + */ export function assistantFrames(sessionId, msg) { const content = msg.message?.content || [] - return [{ type: 'sdk.assistant', sessionId, content: content.map(contentBlock), model: msg.message?.model }] + return [ + { + type: 'sdk.assistant', + sessionId, + content: content.map(contentBlock), + model: msg.message?.model, + uuid: msg.uuid, + messageId: msg.message?.id, + parentToolUseId: parentToolUseIdOf(msg), + }, + ...transcriptAnnouncement(sessionId, msg), + ] } /** A streaming event: `sdk.stream`. */ @@ -149,5 +187,5 @@ export function statusFrames(sessionId, msg) { */ export function compactBoundaryFrames(sessionId, msg) { const trigger = msg.compact_metadata?.trigger === 'manual' ? 'manual' : 'auto' - return [{ type: 'sdk.compact_boundary', sessionId, trigger }] + return [{ type: 'sdk.compact_boundary', sessionId, trigger }, ...transcriptAnnouncement(sessionId, msg)] } diff --git a/test/unit/claude-sidecar/live-frames.test.ts b/test/unit/claude-sidecar/live-frames.test.ts new file mode 100644 index 000000000..c8335695d --- /dev/null +++ b/test/unit/claude-sidecar/live-frames.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +// +// The frames a live turn sends the server, through the production index.mjs +// over the CLI simulator (harness.ts): transcript announcements whose uuids +// land in the transcript the (simulated) CLI writes, assistant frames that +// carry their ids, and what the compaction and permission-mode status frames +// become. The mapping itself is pinned on the pinned CLI's recorded sequences +// in sdk-content-frames.test.ts; test/integration/providers/ +// claude-live-frames.test.ts runs the same sidecar over the real pinned CLI. + +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness' + +/** The uuids of the lines the simulated CLI has written for its session so far. */ +function transcriptUuids(sidecar: RealSidecar, cliSessionId: string): Set { + const projects = path.join(sidecar.claudeConfigDir, 'projects') + const uuids = new Set() + for (const dir of fs.existsSync(projects) ? fs.readdirSync(projects) : []) { + const file = path.join(projects, dir, `${cliSessionId}.jsonl`) + if (!fs.existsSync(file)) continue + const text = fs.readFileSync(file, 'utf8') + for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n').filter(Boolean)) { + const row = JSON.parse(line) + if (typeof row.uuid === 'string') uuids.add(row.uuid) + } + } + return uuids +} + +const isAnnouncement = (frame: SidecarFrame) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript' + +describe('a live turn\'s frames', () => { + it('announces each transcript message right after its content, and every announced uuid lands in the transcript', async () => { + const sidecar = await startRealSidecar() + const sessionId = await sidecar.create() + const checkpointAttempt = { uuid: '0b8e1f4c-6a55-4f4e-9d0a-3c2b1a090807', requestId: 'send-1', logicalAttemptId: 'attempt-1' } + sidecar.send({ type: 'send', sessionId, text: 'TOOL:sleep=0', checkpointAttempt }) + await sidecar.waitFor('sdk.result', 'the turn result') + const { cliSessionId } = await sidecar.waitFor('sdk.session.init') + + const announcements = sidecar.frames.filter(isAnnouncement) + // The prompt, the tool call, the tool result and the reply. + expect(announcements.map((frame) => frame.uuid)).toContain(checkpointAttempt.uuid) + expect(announcements.length).toBeGreaterThanOrEqual(4) + for (const announcement of announcements) expect(announcement).toMatchObject({ sessionId, parentToolUseId: null }) + + // Each sdk.assistant is followed at once by the announcement of its own uuid. + const assistants = sidecar.frames.filter((frame) => frame.type === 'sdk.assistant') + expect(assistants.length).toBeGreaterThanOrEqual(2) + for (const assistant of assistants) { + expect(assistant).toMatchObject({ uuid: expect.any(String), messageId: expect.any(String), parentToolUseId: null }) + expect(sidecar.frames[sidecar.frames.indexOf(assistant) + 1]).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript', uuid: assistant.uuid }) + } + // The prompt's replay binds the checkpoint first, then is announced. + const identity = sidecar.frames.findIndex((frame) => frame.type === 'sdk.checkpointTurnIdentity') + expect(identity).toBeGreaterThanOrEqual(0) + expect(sidecar.frames[identity + 1]).toMatchObject({ type: 'sdk.session.changed', uuid: checkpointAttempt.uuid }) + + // The CLI writes its transcript about 100 ms after it emits a message. + await expect.poll(() => { + const written = transcriptUuids(sidecar, cliSessionId) + return announcements.filter((frame) => !written.has(frame.uuid)).map((frame) => frame.uuid) + }, { timeout: 2_000 }).toEqual([]) + }) +}) diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts index 988907c07..fa517e0d3 100644 --- a/test/unit/claude-sidecar/sdk-content-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -31,6 +31,23 @@ function recorded(scenario: string): Msg[] { .map((line) => JSON.parse(line).msg as Msg) } +/** The uuids of the lines the CLI wrote: the main transcript and every subagent transcript. */ +function persistedUuids(scenario: string): Set { + const files = [path.join(RECORDINGS, scenario, 'session.jsonl')] + const subagents = path.join(RECORDINGS, scenario, 'session', 'subagents') + if (fs.existsSync(subagents)) { + for (const name of fs.readdirSync(subagents)) if (name.endsWith('.jsonl')) files.push(path.join(subagents, name)) + } + const uuids = new Set() + for (const file of files) { + for (const line of fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)) { + const row = JSON.parse(line) + if (typeof row.uuid === 'string') uuids.add(row.uuid) + } + } + return uuids +} + /** What the sidecar knows about its session when a message arrives. */ function session(state = createFrameState(), overrides: Record = {}) { return { stream: state, permissionMode: 'bypassPermissions', turnPending: true, ...overrides } @@ -56,6 +73,59 @@ describe('framesForSdkMessage over the pinned CLI\'s recorded sequences', () => } }) + it.each(SCENARIOS)('%s: sdk.assistant carries the message uuid, the API message id and the parent tool use id', (scenario) => { + for (const { msg, frames } of mapRecording(scenario).filter(({ msg }) => msg.type === 'assistant')) { + expect(frames.find((frame: Msg) => frame.type === 'sdk.assistant')).toMatchObject({ + uuid: msg.uuid, + messageId: msg.message.id, + parentToolUseId: msg.parent_tool_use_id ?? null, + }) + } + }) + + it.each(SCENARIOS)('%s: every message the CLI wrote to its transcript is announced once, after its content, and nothing else is', (scenario) => { + const persisted = persistedUuids(scenario) + const mapped = mapRecording(scenario) + const announced: string[] = [] + for (const { msg, frames } of mapped) { + const announcements = frames.filter((frame: Msg) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript') + for (const announcement of announcements) { + expect(announcement).toEqual({ type: 'sdk.session.changed', sessionId: 's1', reason: 'transcript', uuid: msg.uuid, parentToolUseId: msg.parent_tool_use_id ?? null }) + expect(frames.at(-1)).toBe(announcement) + announced.push(announcement.uuid) + } + } + // Exactly the SDK messages whose uuid is a line of the main or a subagent transcript. + const expected = mapped.map(({ msg }) => msg.uuid).filter((uuid) => persisted.has(uuid)) + expect(expected.length).toBeGreaterThan(0) + expect(announced).toEqual(expected) + }) + + it('announces sidechain (subagent) messages with the tool use that started the subagent', () => { + const sidechain = mapRecording('tools-sync').filter(({ msg }) => (msg.type === 'assistant' || msg.type === 'user') && msg.parent_tool_use_id) + expect(sidechain.length).toBeGreaterThan(0) + for (const { frames } of sidechain) { + expect(frames.at(-1)).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript', parentToolUseId: 'toolu_agent1' }) + } + }) + + it('announces the transcript lines of a compaction and of local command output', () => { + const boundary = mapRecording('compact-ok').find(({ msg }) => msg.type === 'system' && msg.subtype === 'compact_boundary')! + expect(boundary.frames.map((frame: Msg) => frame.type)).toEqual(['sdk.compact_boundary', 'sdk.session.changed']) + expect(boundary.frames[1]).toMatchObject({ reason: 'transcript', uuid: boundary.msg.uuid, parentToolUseId: null }) + // The SDK's own local command output message (SDKLocalCommandOutputMessage). + const output = { type: 'system', subtype: 'local_command_output', content: 'Total cost: $0.00', uuid: '5f0f3a3e-0d0e-4a43-9a0e-0a3c6b1c2d3e', session_id: 'c1' } + expect(framesForSdkMessage('s1', output, session())).toEqual([ + { type: 'sdk.session.changed', sessionId: 's1', reason: 'transcript', uuid: output.uuid, parentToolUseId: null }, + ]) + }) + + it('a message without a uuid is not announced', () => { + expect(framesForSdkMessage('s1', { type: 'user', message: { role: 'user', content: 'hi' }, parent_tool_use_id: null }, session())).toEqual([]) + const frames = framesForSdkMessage('s1', { type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'x' }] }, parent_tool_use_id: null }, session()) + expect(frames.map((frame: Msg) => frame.type)).toEqual(['sdk.assistant']) + }) + it.each(SCENARIOS)('%s: messages that carry nothing for the server map to no frame', (scenario) => { const silent = (msg: Msg) => msg.type === 'result' || (msg.type === 'system' && ['init', 'task_started', 'task_progress', 'task_updated', 'task_notification', 'background_tasks_changed', 'thinking_tokens'].includes(msg.subtype)) From 257d5233ce0d33a82d851932df9518872ecfa89a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:37:56 -0700 Subject: [PATCH 03/45] feat(claude-sidecar): stream only what a live pane renders, stamped with its message id (B3) sdk.stream now carries the main thread's message_start, block starts and stops, and text/thinking deltas only, each stamped with the API message id of the message being streamed (from its message_start). A subagent's events, tool input JSON, signatures, message_delta and message_stop are no longer sent. The id equals the messageId of the sdk.assistant each block becomes, which is what binds a live block to its transcript line. The CLI simulator now gives each assistant message the id of the message_start it was streamed under, as the real CLI does (F2); a normalized conformance trace cannot see ids, so a simulator test pins it. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 6 +- .../sdk-message-frames.mjs | 26 +++++++-- test/fixtures/claude-cli-sim/query.mjs | 11 +++- .../providers/claude-cli-sim.test.ts | 20 +++++++ test/unit/claude-sidecar/live-frames.test.ts | 27 +++++++++ .../claude-sidecar/sdk-content-frames.test.ts | 57 ++++++++++++++++++- 6 files changed, 137 insertions(+), 10 deletions(-) diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 06cb34a08..13b1863b6 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -40,7 +40,11 @@ // { type:'sdk.assistant', sessionId, content, model, uuid, messageId, parentToolUseId } // — one per completed content block (blocks of one API message share // messageId); parentToolUseId is set inside a subagent, else null -// { type:'sdk.stream', sessionId, event, parentToolUseId } +// { type:'sdk.stream', sessionId, event, parentToolUseId:null, messageId } +// — the main thread's message_start, content_block_start/stop and +// text/thinking deltas only, stamped with the API message id of the +// message being streamed (a subagent's events, tool input JSON, +// signatures, message_delta and message_stop are not sent) // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except // the stopped turn's own aborted result (an accepted user interrupt's mark diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index a0ec15f80..7aea848e7 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -93,10 +93,11 @@ export function resultFrames(sessionId, result, { userStopped }) { /** * The per-session state of the content mapping. index.mjs keeps one per - * session and passes it in as `session.stream`. + * session and passes it in as `session.stream`. `messageId` is the API + * message id of the main thread's message being streamed (its message_start). */ export function createFrameState() { - return {} + return { messageId: null } } /** @@ -164,9 +165,24 @@ export function assistantFrames(sessionId, msg) { ] } -/** A streaming event: `sdk.stream`. */ -export function streamEventFrames(sessionId, msg, _stream) { - return [{ type: 'sdk.stream', sessionId, event: msg.event, parentToolUseId: msg.parent_tool_use_id }] +/** The stream events a live pane renders (a block's start and stop, and its text or thinking). */ +const STREAMED_EVENTS = new Set(['message_start', 'content_block_start', 'content_block_stop']) +const STREAMED_DELTAS = new Set(['text_delta', 'thinking_delta']) + +/** + * A streaming event of the main thread: `sdk.stream`, stamped with the API + * message id it belongs to (the one each of its blocks' sdk.assistant then + * carries). Only what a live pane renders is streamed: a subagent's events, + * tool input JSON, signatures and the message's own delta and stop are not. + */ +export function streamEventFrames(sessionId, msg, stream) { + if (parentToolUseIdOf(msg) !== null) return [] + const event = msg.event + if (event?.type === 'message_start') stream.messageId = event.message?.id ?? null + const streamed = STREAMED_EVENTS.has(event?.type) + || (event?.type === 'content_block_delta' && STREAMED_DELTAS.has(event.delta?.type)) + if (!streamed) return [] + return [{ type: 'sdk.stream', sessionId, event, parentToolUseId: null, messageId: stream.messageId }] } /** A status frame: the CLI started compacting. */ diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index d910bd38b..fba760690 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -645,18 +645,25 @@ class SimulatedCli { return this.finishTurn(turn, { subtype: 'success' }) } + /** + * The start of one model response. Its id is the API message id every + * content block of that response carries on its assistant message (F2). + */ messageStart() { + this.streamedMessageId = `msg_${randomUUID().replaceAll('-', '')}` return { type: 'message_start', - message: { id: `msg_${randomUUID().replaceAll('-', '')}`, type: 'message', role: 'assistant', model: FAKE_MODEL, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }, + message: { id: this.streamedMessageId, type: 'message', role: 'assistant', model: FAKE_MODEL, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }, } } + /** One completed content block of the main thread's current response (or of a subagent's, with parent_tool_use_id). */ assistant(content, extra = {}) { const uuid = randomUUID() + const id = !extra.parent_tool_use_id && this.streamedMessageId ? this.streamedMessageId : `msg_${randomUUID().replaceAll('-', '')}` const message = { type: 'assistant', - message: { id: `msg_${randomUUID().replaceAll('-', '')}`, type: 'message', role: 'assistant', model: FAKE_MODEL, content, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, context_management: null }, + message: { id, type: 'message', role: 'assistant', model: FAKE_MODEL, content, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, context_management: null }, parent_tool_use_id: null, uuid, timestamp: new Date().toISOString(), diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 99dc17f4b..ff4eafd05 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -110,6 +110,26 @@ async function snapshotTree(root: string): Promise> { const program = (rules: unknown[]) => ({ FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify({ rules }), FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) const results = (messages: any[]) => messages.filter((m) => m.type === 'result') +describe('Claude CLI simulator ids a normalized trace cannot see', () => { + it('each assistant message carries the API message id of the message_start it was streamed under, as on the real CLI (F2)', async () => { + const s = await open({ includePartialMessages: true }, { FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) + s.prompt.push('TOOL:sleep=0') + await s.until(() => results(s.messages).length === 1) + s.prompt.push('SLOW') + await s.until(() => results(s.messages).length === 2) + const pairs: Array<[string, string]> = [] + let streaming: string | undefined + for (const m of s.messages) { + if (m.type === 'stream_event' && m.event.type === 'message_start') streaming = m.event.message.id + if (m.type === 'assistant' && m.parent_tool_use_id === null) pairs.push([streaming!, m.message.id]) + } + // The tool call, its follow-up reply and the slow reply: three messages, three ids. + expect(pairs).toHaveLength(3) + for (const [started, assistant] of pairs) expect(assistant).toBe(started) + expect(new Set(pairs.map(([id]) => id)).size).toBe(3) + }) +}) + describe('Claude CLI simulator programs', () => { it('a matching prompt rule replaces the marker behavior: a question goes through canUseTool and the answer ends the turn', async () => { const calls: any[] = [] diff --git a/test/unit/claude-sidecar/live-frames.test.ts b/test/unit/claude-sidecar/live-frames.test.ts index c8335695d..7067b2b1a 100644 --- a/test/unit/claude-sidecar/live-frames.test.ts +++ b/test/unit/claude-sidecar/live-frames.test.ts @@ -64,4 +64,31 @@ describe('a live turn\'s frames', () => { return announcements.filter((frame) => !written.has(frame.uuid)).map((frame) => frame.uuid) }, { timeout: 2_000 }).toEqual([]) }) + + it('streams text and block starts stamped with the message id of the sdk.assistant they become, and nothing a pane does not render', async () => { + const sidecar = await startRealSidecar({ timeScale: 0.1 }) + const sessionId = await sidecar.create() + sidecar.send({ type: 'send', sessionId, text: 'SLOW reply in chunks' }) + await sidecar.waitFor('sdk.result', 'the slow turn result') + sidecar.send({ type: 'send', sessionId, text: 'TOOL:sleep=0' }) + await sidecar.waitFor((frame) => frame.type === 'sdk.result' && sidecar.frames.filter((f) => f.type === 'sdk.result').length === 2, 'the tool turn result') + + const streams = sidecar.frames.filter((frame) => frame.type === 'sdk.stream') + expect(streams.length).toBeGreaterThan(0) + for (const frame of streams) { + expect(frame).toMatchObject({ sessionId, parentToolUseId: null, messageId: expect.any(String) }) + expect(['message_start', 'content_block_start', 'content_block_delta', 'content_block_stop']).toContain(frame.event.type) + if (frame.event.type === 'content_block_delta') expect(['text_delta', 'thinking_delta']).toContain(frame.event.delta.type) + } + // Every streamed block start names the message of the assistant block that follows it. + for (const start of streams.filter((frame) => frame.event.type === 'content_block_start')) { + const assistant = sidecar.frames.slice(sidecar.frames.indexOf(start)).find((frame) => frame.type === 'sdk.assistant') + expect(assistant?.messageId).toBe(start.messageId) + } + // The slow reply's deltas add up to its text. + const slowId = streams.find((frame) => frame.event.type === 'content_block_start' && frame.event.content_block.type === 'text')!.messageId + const streamed = streams.filter((frame) => frame.messageId === slowId && frame.event.type === 'content_block_delta').map((frame) => frame.event.delta.text).join('') + const reply = sidecar.frames.find((frame) => frame.type === 'sdk.assistant' && frame.messageId === slowId) + expect(streamed).toBe(reply?.content[0].text) + }) }) diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts index fa517e0d3..43e67b6ab 100644 --- a/test/unit/claude-sidecar/sdk-content-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -135,12 +135,65 @@ describe('framesForSdkMessage over the pinned CLI\'s recorded sequences', () => } }) - it.each(SCENARIOS)('%s: each stream event is forwarded as sdk.stream with its event and parent tool use id', (scenario) => { + it.each(SCENARIOS)('%s: only the main thread\'s message and block starts, text and thinking deltas and block stops are streamed', (scenario) => { for (const { msg, frames } of mapRecording(scenario).filter(({ msg }) => msg.type === 'stream_event')) { - expect(frames).toEqual([{ type: 'sdk.stream', sessionId: 's1', event: msg.event, parentToolUseId: msg.parent_tool_use_id }]) + const event = msg.event + const streamed = msg.parent_tool_use_id == null && ( + ['message_start', 'content_block_start', 'content_block_stop'].includes(event.type) + || (event.type === 'content_block_delta' && ['text_delta', 'thinking_delta'].includes(event.delta?.type)) + ) + if (streamed) expect(frames).toEqual([{ type: 'sdk.stream', sessionId: 's1', event, parentToolUseId: null, messageId: expect.any(String) }]) + else expect(frames, JSON.stringify(event)).toEqual([]) } }) + it('drops the events nothing renders: tool input JSON, signatures, message deltas and stops', () => { + const dropped = mapRecording('tools-sync') + .filter(({ msg }) => msg.type === 'stream_event') + .filter(({ frames }) => frames.length === 0) + .map(({ msg }) => msg.event.delta?.type ?? msg.event.type) + expect(new Set(dropped)).toEqual(new Set(['input_json_delta', 'signature_delta', 'message_delta', 'message_stop'])) + }) + + it.each(SCENARIOS)('%s: each streamed block carries the API message id of the sdk.assistant it becomes, and its deltas add up to that block', (scenario) => { + const frames = mapRecording(scenario).flatMap((entry) => entry.frames) + let open: { messageId: string; index: number; kind: string; text: string } | undefined + const finished: Array<{ messageId: string; kind: string; text: string }> = [] + for (const frame of frames.filter((f: Msg) => f.type === 'sdk.stream')) { + const event = frame.event + if (event.type === 'content_block_start') open = { messageId: frame.messageId, index: event.index, kind: event.content_block.type, text: '' } + if (event.type === 'content_block_delta') { + expect(open).toMatchObject({ messageId: frame.messageId, index: event.index }) + open!.text += event.delta.text ?? event.delta.thinking + } + if (event.type === 'content_block_stop') { + expect(open).toMatchObject({ messageId: frame.messageId, index: event.index }) + finished.push({ messageId: open!.messageId, kind: open!.kind, text: open!.text }) + open = undefined + } + } + // The main thread's assistant messages, block by block, in the same order. + const blocks = frames + .filter((f: Msg) => f.type === 'sdk.assistant' && f.parentToolUseId === null && f.model !== '') + .map((f: Msg) => ({ messageId: f.messageId, kind: f.content[0].type, text: f.content[0].text ?? f.content[0].thinking ?? '' })) + expect(finished).toEqual(blocks) + }) + + it('a subagent\'s stream does not touch the main thread\'s message id', () => { + const state = createFrameState() + const start = (id: string, parent: string | null) => ({ type: 'stream_event', event: { type: 'message_start', message: { id, content: [] } }, parent_tool_use_id: parent, uuid: `u-${id}` }) + const delta = { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }, parent_tool_use_id: null, uuid: 'u-d' } + expect(framesForSdkMessage('s1', start('msg_main', null), session(state))).toHaveLength(1) + expect(framesForSdkMessage('s1', start('msg_sub', 'toolu_agent1'), session(state))).toEqual([]) + expect(framesForSdkMessage('s1', delta, session(state))).toEqual([ + { type: 'sdk.stream', sessionId: 's1', event: delta.event, parentToolUseId: null, messageId: 'msg_main' }, + ]) + // Each session keeps its own stream state. + const other = createFrameState() + framesForSdkMessage('s2', start('msg_other', null), session(other)) + expect(framesForSdkMessage('s1', delta, session(state))[0].messageId).toBe('msg_main') + }) + it('projects each content block the way the server reads it (a thinking block loses its signature)', () => { const blocks = mapRecording('tools-sync') .filter(({ msg }) => msg.type === 'assistant' && msg.parent_tool_use_id === null) From 24026e9c25f2de35d50ae952819e8c51a0779921 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:39:31 -0700 Subject: [PATCH 04/45] feat(claude-sidecar): API retries, rate limits and informational banners reach the server (B4) The sidecar used to drop these SDK messages. Now: - system/api_retry becomes sdk.retry {attempt, maxRetries, delayMs, errorStatus, error}; - rate_limit_event becomes sdk.notice RATE_LIMIT_WARNING (allowed_warning) or RATE_LIMITED (rejected) carrying the limit and its reset time; an allowed status says nothing; - system/informational becomes sdk.notice INFORMATIONAL with its text. None of them is a turn end or an error frame. Each is logged on stderr (api.retry at warn; notice at its level) with ids and numbers only: an informational text is logged by its size. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 20 ++++- .../sdk-message-frames.mjs | 86 ++++++++++++++++++- .../claude-sidecar/sdk-content-frames.test.ts | 72 ++++++++++++++++ 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 13b1863b6..4f58d3565 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -45,6 +45,13 @@ // text/thinking deltas only, stamped with the API message id of the // message being streamed (a subagent's events, tool input JSON, // signatures, message_delta and message_stop are not sent) +// { type:'sdk.retry', sessionId, attempt, maxRetries, delayMs, errorStatus, error } +// — the CLI will retry a failed API request (errorStatus null: no HTTP +// response); live only, never a turn end +// { type:'sdk.notice', sessionId, level:'info'|'warning'|'error', code, message, rateLimit? } +// — a non-terminal provider notice: RATE_LIMIT_WARNING / RATE_LIMITED +// (rateLimit {status, resetsAt?, rateLimitType?, utilization?}), +// INFORMATIONAL (the CLI's own banner text); never a turn end // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except // the stopped turn's own aborted result (an accepted user interrupt's mark @@ -114,7 +121,7 @@ import { createInterface } from 'node:readline' import { randomBytes } from 'node:crypto' import { configureSession, userMessageContent } from './session-settings.mjs' -import { createFrameState, framesForSdkMessage, resultFrames } from './sdk-message-frames.mjs' +import { createFrameState, frameLogRecord, framesForSdkMessage, resultFrames } from './sdk-message-frames.mjs' import { createTurnCompleteGate } from './turn-complete-gate.mjs' import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { nextMonotonic } from './monotonic-clock.mjs' @@ -358,9 +365,14 @@ function handleSdkMessage(sessionId, msg) { default: break } - // The content kinds (assistant, stream events, compaction): a pure mapping, - // sdk-message-frames.mjs. Any kind it does not map is dropped there. - for (const frame of framesForSdkMessage(sessionId, msg, { stream: st.frameState })) emit(frame) + // The content kinds (assistant, user, stream events, compaction, retries + // and notices): a pure mapping, sdk-message-frames.mjs. Any kind it does + // not map is dropped there. + for (const frame of framesForSdkMessage(sessionId, msg, { stream: st.frameState })) { + emit(frame) + const record = frameLogRecord(frame) + if (record) log(record.severity, record.event, record.fields) + } } async function consumeStream(sessionId, sdkQuery) { diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index 7aea848e7..b2030674e 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -79,10 +79,12 @@ export function resultFrames(sessionId, result, { userStopped }) { // ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ── // // framesForSdkMessage maps the SDK messages whose content reaches the server: -// assistant and user messages, stream events and the compaction status frames. -// Every other kind maps to no frame here: `result`, `system/init`, -// `commands_changed` and the checkpoint replay binding carry side effects, so -// index.mjs handles them itself. +// assistant and user messages, stream events, the compaction status frames, +// and the provider events that never end a turn (an API retry becomes +// `sdk.retry`; a rate limit or an informational message becomes +// `sdk.notice`). Every other kind maps to no frame here: `result`, +// `system/init`, `commands_changed` and the checkpoint replay binding carry +// side effects, so index.mjs handles them itself. // // Transcript announcements: the CLI writes each assistant and user message, // each compaction boundary and each local command's output to its transcript @@ -110,11 +112,14 @@ export function framesForSdkMessage(sessionId, msg, session) { case 'assistant': return assistantFrames(sessionId, msg) case 'user': return transcriptAnnouncement(sessionId, msg) case 'stream_event': return streamEventFrames(sessionId, msg, session.stream) + case 'rate_limit_event': return rateLimitFrames(sessionId, msg) case 'system': switch (msg.subtype) { case 'status': return statusFrames(sessionId, msg) case 'compact_boundary': return compactBoundaryFrames(sessionId, msg) case 'local_command_output': return transcriptAnnouncement(sessionId, msg) + case 'api_retry': return apiRetryFrames(sessionId, msg) + case 'informational': return informationalFrames(sessionId, msg) default: return [] } default: return [] @@ -205,3 +210,76 @@ export function compactBoundaryFrames(sessionId, msg) { const trigger = msg.compact_metadata?.trigger === 'manual' ? 'manual' : 'auto' return [{ type: 'sdk.compact_boundary', sessionId, trigger }, ...transcriptAnnouncement(sessionId, msg)] } + +/** + * The CLI will retry a failed API request (live only; never in the + * transcript): `sdk.retry`. `errorStatus` is null for a connection error. + */ +export function apiRetryFrames(sessionId, msg) { + return [{ + type: 'sdk.retry', + sessionId, + attempt: msg.attempt, + maxRetries: msg.max_retries, + delayMs: msg.retry_delay_ms, + errorStatus: msg.error_status ?? null, + error: msg.error, + }] +} + +const RATE_LIMIT_NOTICES = { + allowed_warning: { level: 'warning', code: 'RATE_LIMIT_WARNING', message: 'You are close to your Claude usage limit.' }, + rejected: { level: 'error', code: 'RATE_LIMITED', message: 'You have reached your Claude usage limit.' }, +} +const RATE_LIMIT_FIELDS = ['status', 'resetsAt', 'rateLimitType', 'utilization'] + +/** + * A change in the account's usage limits: a notice when the limit is near + * (`allowed_warning`) or reached (`rejected`), carrying the limit so the + * client can show when it resets. An `allowed` status says nothing. + */ +export function rateLimitFrames(sessionId, msg) { + const info = msg.rate_limit_info ?? {} + const notice = RATE_LIMIT_NOTICES[info.status] + if (!notice) return [] + const rateLimit = {} + for (const key of RATE_LIMIT_FIELDS) if (info[key] !== undefined) rateLimit[key] = info[key] + return [{ type: 'sdk.notice', sessionId, ...notice, rateLimit }] +} + +/** + * A banner the CLI shows as plain text (hook feedback, status lines): a + * notice with its text. Only the CLI's `warning` level is more than info. + */ +export function informationalFrames(sessionId, msg) { + const message = typeof msg.content === 'string' ? msg.content.trim() : '' + if (!message) return [] + return [{ type: 'sdk.notice', sessionId, level: msg.level === 'warning' ? 'warning' : 'info', code: 'INFORMATIONAL', message }] +} + +const NOTICE_SEVERITY = { info: 'info', warning: 'warn', error: 'error' } + +/** + * The sidecar's log record for a frame worth logging (`{severity, event, + * fields}`), else undefined. Like every sidecar record (log.mjs) it carries + * ids, numbers and error text only: the text of an informational notice is + * logged by its size. + */ +export function frameLogRecord(frame) { + if (frame?.type === 'sdk.retry') { + const { type: _type, ...fields } = frame + return { severity: 'warn', event: 'api.retry', fields } + } + if (frame?.type === 'sdk.notice') { + const fields = { sessionId: frame.sessionId, code: frame.code } + if (frame.rateLimit) { + fields.rateLimitStatus = frame.rateLimit.status + if (frame.rateLimit.rateLimitType !== undefined) fields.rateLimitType = frame.rateLimit.rateLimitType + if (frame.rateLimit.resetsAt !== undefined) fields.resetsAt = frame.rateLimit.resetsAt + } else { + fields.bytes = Buffer.byteLength(frame.message ?? '') + } + return { severity: NOTICE_SEVERITY[frame.level] ?? 'info', event: 'notice', fields } + } + return undefined +} diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts index 43e67b6ab..2d8c86556 100644 --- a/test/unit/claude-sidecar/sdk-content-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -15,6 +15,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' import { createFrameState, + frameLogRecord, framesForSdkMessage, } from '../../../crates/freshell-claude-sidecar/sdk-message-frames.mjs' @@ -228,3 +229,74 @@ describe('framesForSdkMessage over the pinned CLI\'s recorded sequences', () => expect(framesForSdkMessage('s1', {}, session())).toEqual([]) }) }) + +// Shapes the pinned CLI emits that the recordings above do not contain +// (docs/plans/2026-10-22-freshclaude-transcript-live.md §3: F11 api_retry, +// recorded from a 529 response) or that only a claude.ai subscription or a +// hook produces (rate_limit_event, informational: the SDK's own typings). +const RETRY = { type: 'system', subtype: 'api_retry', attempt: 1, max_retries: 10, retry_delay_ms: 563, error_status: 529, error: 'overloaded', session_id: 'c1', uuid: '67c2e12a-e020-406a-b7b8-4d90df03dc10' } +const rateLimit = (info: Record) => ({ type: 'rate_limit_event', rate_limit_info: info, session_id: 'c1', uuid: '8a2f7c1e-55a0-4d3b-9f1e-2b6c0d9e4a11' }) +const informational = (level: string, content: string) => ({ type: 'system', subtype: 'informational', content, level, session_id: 'c1', uuid: 'f3b1c2d4-1e2f-4a3b-8c4d-5e6f7a8b9c0d' }) + +describe('framesForSdkMessage: provider events that never end a turn', () => { + it('an API retry becomes sdk.retry with its attempt, limit, delay and error', () => { + expect(framesForSdkMessage('s1', RETRY, session())).toEqual([ + { type: 'sdk.retry', sessionId: 's1', attempt: 1, maxRetries: 10, delayMs: 563, errorStatus: 529, error: 'overloaded' }, + ]) + // A connection error (a timeout) had no HTTP status. + expect(framesForSdkMessage('s1', { ...RETRY, attempt: 3, error_status: null, error: 'unknown' }, session())[0]) + .toMatchObject({ attempt: 3, errorStatus: null, error: 'unknown' }) + }) + + it('a rate limit warning or rejection becomes a notice carrying the limit; an allowed status says nothing', () => { + expect(framesForSdkMessage('s1', rateLimit({ status: 'allowed', resetsAt: 1761300000 }), session())).toEqual([]) + expect(framesForSdkMessage('s1', rateLimit({ status: 'allowed_warning', resetsAt: 1761300000, rateLimitType: 'five_hour', utilization: 0.92, isUsingOverage: false }), session())).toEqual([ + { + type: 'sdk.notice', + sessionId: 's1', + level: 'warning', + code: 'RATE_LIMIT_WARNING', + message: expect.any(String), + rateLimit: { status: 'allowed_warning', resetsAt: 1761300000, rateLimitType: 'five_hour', utilization: 0.92 }, + }, + ]) + const [rejected] = framesForSdkMessage('s1', rateLimit({ status: 'rejected', resetsAt: 1761300000, rateLimitType: 'seven_day' }), session()) + expect(rejected).toMatchObject({ type: 'sdk.notice', level: 'error', code: 'RATE_LIMITED', rateLimit: { status: 'rejected', resetsAt: 1761300000, rateLimitType: 'seven_day' } }) + expect(rejected.rateLimit).not.toHaveProperty('utilization') + expect(rejected.message).not.toBe(framesForSdkMessage('s1', rateLimit({ status: 'allowed_warning' }), session())[0].message) + }) + + it('an informational message becomes a notice with its text; only a warning is more than info', () => { + expect(framesForSdkMessage('s1', informational('warning', ' A hook blocked this prompt.\n'), session())).toEqual([ + { type: 'sdk.notice', sessionId: 's1', level: 'warning', code: 'INFORMATIONAL', message: 'A hook blocked this prompt.' }, + ]) + for (const level of ['info', 'notice', 'suggestion', undefined]) { + expect(framesForSdkMessage('s1', informational(level as string, 'Plain status'), session())[0]).toMatchObject({ level: 'info', code: 'INFORMATIONAL', message: 'Plain status' }) + } + expect(framesForSdkMessage('s1', informational('warning', ' '), session())).toEqual([]) + }) +}) + +describe('frameLogRecord', () => { + it('logs a retry as a warning with its numbers', () => { + const [frame] = framesForSdkMessage('s1', RETRY, session()) + expect(frameLogRecord(frame)).toEqual({ severity: 'warn', event: 'api.retry', fields: { sessionId: 's1', attempt: 1, maxRetries: 10, delayMs: 563, errorStatus: 529, error: 'overloaded' } }) + }) + + it('logs a notice at its level, with the limit for a rate limit and only the size of an informational text', () => { + const [warning] = framesForSdkMessage('s1', rateLimit({ status: 'allowed_warning', resetsAt: 1761300000, rateLimitType: 'five_hour', utilization: 0.92 }), session()) + expect(frameLogRecord(warning)).toEqual({ severity: 'warn', event: 'notice', fields: { sessionId: 's1', code: 'RATE_LIMIT_WARNING', rateLimitStatus: 'allowed_warning', rateLimitType: 'five_hour', resetsAt: 1761300000 } }) + const [rejected] = framesForSdkMessage('s1', rateLimit({ status: 'rejected' }), session()) + expect(frameLogRecord(rejected)).toMatchObject({ severity: 'error', event: 'notice', fields: { code: 'RATE_LIMITED' } }) + const [info] = framesForSdkMessage('s1', informational('notice', 'text a hook wrote'), session()) + const record = frameLogRecord(info) + expect(record).toEqual({ severity: 'info', event: 'notice', fields: { sessionId: 's1', code: 'INFORMATIONAL', bytes: 17 } }) + expect(JSON.stringify(record)).not.toContain('hook wrote') + }) + + it('logs nothing for content frames', () => { + for (const { frames } of mapRecording('tools-sync')) { + for (const frame of frames) expect(frameLogRecord(frame)).toBeUndefined() + } + }) +}) From 7eb80b40ddb2e94fe9a3eb9893cefaffc64fd463 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:43:03 -0700 Subject: [PATCH 05/45] feat(claude-sidecar): a compaction's outcome and the CLI's own permission mode (B5) A status frame that ends a compaction (status:null with compact_result) used to be dropped, so a pane kept saying "compacting" until the turn's result and a failed compaction left no trace but the transcript line. Now: - a failed compaction is sdk.notice COMPACTION_FAILED (level error) with the CLI's reason, never an error frame or a turn end; - while a send still waits for its result, the end of a compaction sends sdk.status running (interim until core's turn ledger, core plan N2). When the CLI reports a permission mode that differs from the session's (after an ExitPlanMode approval), the session adopts it and says so in sdk.session.metadata {permissionMode}; a later configure to that mode therefore calls nothing on the CLI. The simulator records each settings call it handles (ledger kind `control`), which is how the spawn-level test sees that no redundant setPermissionMode was made. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 30 ++++++-- .../sdk-message-frames.mjs | 63 +++++++++++++--- crates/freshell-ws/tests/common/claude_rig.rs | 2 +- test/fixtures/claude-cli-sim/query.mjs | 8 +- .../providers/claude-cli-sim.test.ts | 19 +++++ test/unit/claude-sidecar/harness.ts | 2 +- test/unit/claude-sidecar/live-frames.test.ts | 74 +++++++++++++++++++ .../claude-sidecar/sdk-content-frames.test.ts | 42 +++++++++++ 8 files changed, 221 insertions(+), 19 deletions(-) diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 4f58d3565..ef4652eac 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -51,7 +51,8 @@ // { type:'sdk.notice', sessionId, level:'info'|'warning'|'error', code, message, rateLimit? } // — a non-terminal provider notice: RATE_LIMIT_WARNING / RATE_LIMITED // (rateLimit {status, resetsAt?, rateLimitType?, utilization?}), -// INFORMATIONAL (the CLI's own banner text); never a turn end +// INFORMATIONAL (the CLI's own banner text), COMPACTION_FAILED (the +// CLI's reason); never a turn end // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except // the stopped turn's own aborted result (an accepted user interrupt's mark @@ -69,8 +70,11 @@ // local command output) to its transcript under `uuid`, about 100 ms // later; sent right after the message's own frames // (sdk-message-frames.mjs) -// { type:'sdk.status', sessionId, status } // compacting (an ended stream sends -// no status: the server reports the session as exited) +// { type:'sdk.status', sessionId, status } // compacting; running when a +// compaction ends while a send still waits for its result (interim, core +// N2); an ended stream sends no status: the server reports it as exited +// { type:'sdk.session.metadata', sessionId, permissionMode } // the CLI changed its own +// permission mode (an ExitPlanMode approval); the session adopts it first // { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result // (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop // { type:'sdk.stream.ended', sessionId, reason, message } // the session's SDK stream ended @@ -365,10 +369,22 @@ function handleSdkMessage(sessionId, msg) { default: break } - // The content kinds (assistant, user, stream events, compaction, retries - // and notices): a pure mapping, sdk-message-frames.mjs. Any kind it does - // not map is dropped there. - for (const frame of framesForSdkMessage(sessionId, msg, { stream: st.frameState })) { + // The content kinds (assistant, user, stream events, status, retries and + // notices): a pure mapping, sdk-message-frames.mjs. Any kind it does not + // map is dropped there. + const frames = framesForSdkMessage(sessionId, msg, { + stream: st.frameState, + permissionMode: st.settings.permissionMode, + turnPending: st.pendingResults > 0, + }) + for (const frame of frames) { + // The CLI changed its own permission mode (an ExitPlanMode approval): the + // session adopts it before saying so, so a configure to that mode applies + // nothing and the permission channel sees the mode the CLI is in. + if (frame.type === 'sdk.session.metadata' && frame.permissionMode !== undefined) { + st.settings.permissionMode = frame.permissionMode + st.permissionMode = frame.permissionMode + } emit(frame) const record = frameLogRecord(frame) if (record) log(record.severity, record.event, record.fields) diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index b2030674e..5c7966545 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -79,12 +79,13 @@ export function resultFrames(sessionId, result, { userStopped }) { // ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ── // // framesForSdkMessage maps the SDK messages whose content reaches the server: -// assistant and user messages, stream events, the compaction status frames, -// and the provider events that never end a turn (an API retry becomes -// `sdk.retry`; a rate limit or an informational message becomes -// `sdk.notice`). Every other kind maps to no frame here: `result`, -// `system/init`, `commands_changed` and the checkpoint replay binding carry -// side effects, so index.mjs handles them itself. +// assistant and user messages, stream events, the status frames (compacting, +// a compaction's outcome, the CLI's permission mode), and the provider events +// that never end a turn (an API retry becomes `sdk.retry`; a rate limit, an +// informational message or a failed compaction becomes `sdk.notice`). Every +// other kind maps to no frame here: `result`, `system/init`, +// `commands_changed` and the checkpoint replay binding carry side effects, so +// index.mjs handles them itself. // // Transcript announcements: the CLI writes each assistant and user message, // each compaction boundary and each local command's output to its transcript @@ -105,7 +106,9 @@ export function createFrameState() { /** * The frames for one SDK message of a content kind (none for any other kind). * `session` is what the sidecar knows about the session when the message - * arrives: `{ stream }`, the session's createFrameState(). + * arrives: `{ stream, permissionMode, turnPending }` — the session's + * createFrameState(), the permission mode it believes the CLI is in, and + * whether a send still waits for its result. */ export function framesForSdkMessage(sessionId, msg, session) { switch (msg?.type) { @@ -115,7 +118,11 @@ export function framesForSdkMessage(sessionId, msg, session) { case 'rate_limit_event': return rateLimitFrames(sessionId, msg) case 'system': switch (msg.subtype) { - case 'status': return statusFrames(sessionId, msg) + case 'status': return [ + ...statusFrames(sessionId, msg), + ...compactionOutcomeFrames(sessionId, msg, session), + ...permissionModeFrames(sessionId, msg, session), + ] case 'compact_boundary': return compactBoundaryFrames(sessionId, msg) case 'local_command_output': return transcriptAnnouncement(sessionId, msg) case 'api_retry': return apiRetryFrames(sessionId, msg) @@ -195,6 +202,39 @@ export function statusFrames(sessionId, msg) { return msg.status === 'compacting' ? [{ type: 'sdk.status', sessionId, status: 'compacting' }] : [] } +const COMPACTION_FAILED_COPY = 'Claude could not compact the conversation.' + +/** + * A compaction ended (`status:null` with `compact_result`, T15). A failed one + * is a notice with the CLI's reason (the transcript keeps the same reason as + * a command error); it never ends the turn. Either way, while a send still + * waits for its result the session is running again, not compacting. + * (Interim: core's turn ledger will compute this itself, docs/plans/ + * 2026-10-22-freshclaude-core-runtime.md §9.1 N2.) + */ +export function compactionOutcomeFrames(sessionId, msg, { turnPending }) { + if (msg.status !== null || msg.compact_result === undefined) return [] + const frames = [] + if (msg.compact_result === 'failed') { + const reason = typeof msg.compact_error === 'string' ? msg.compact_error.trim() : '' + frames.push({ type: 'sdk.notice', sessionId, level: 'error', code: 'COMPACTION_FAILED', message: reason || COMPACTION_FAILED_COPY }) + } + if (turnPending) frames.push({ type: 'sdk.status', sessionId, status: 'running' }) + return frames +} + +/** + * The CLI reports the permission mode it is in now (after an ExitPlanMode + * approval, F12, or a mode suggestion the user accepted). A mode that differs + * from the session's becomes `sdk.session.metadata {permissionMode}`; index.mjs + * adopts it as the session's mode, so a later configure to that mode applies + * nothing. + */ +export function permissionModeFrames(sessionId, msg, { permissionMode }) { + if (typeof msg.permissionMode !== 'string' || msg.permissionMode === permissionMode) return [] + return [{ type: 'sdk.session.metadata', sessionId, permissionMode: msg.permissionMode }] +} + /** * A compaction finished: its trigger. kata 1wxv ep3-r1 F1: the bare * compacting STATUS frame carries no trigger — the SDK fires it for an @@ -272,7 +312,9 @@ export function frameLogRecord(frame) { } if (frame?.type === 'sdk.notice') { const fields = { sessionId: frame.sessionId, code: frame.code } - if (frame.rateLimit) { + if (frame.code === 'COMPACTION_FAILED') { + fields.error = frame.message + } else if (frame.rateLimit) { fields.rateLimitStatus = frame.rateLimit.status if (frame.rateLimit.rateLimitType !== undefined) fields.rateLimitType = frame.rateLimit.rateLimitType if (frame.rateLimit.resetsAt !== undefined) fields.resetsAt = frame.rateLimit.resetsAt @@ -281,5 +323,8 @@ export function frameLogRecord(frame) { } return { severity: NOTICE_SEVERITY[frame.level] ?? 'info', event: 'notice', fields } } + if (frame?.type === 'sdk.session.metadata' && frame.permissionMode !== undefined) { + return { severity: 'info', event: 'permission_mode.changed', fields: { sessionId: frame.sessionId, permissionMode: frame.permissionMode } } + } return undefined } diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 1102a1a39..ee0987bd6 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -245,7 +245,7 @@ impl ClaudeRig { } /// The simulator's ledger rows of one kind (`query`, `boot`, `prompt`, - /// `can_use_tool`, `emission`); see `test/fixtures/claude-cli-sim/query.mjs`. + /// `can_use_tool`, `control`, `emission`); see `test/fixtures/claude-cli-sim/query.mjs`. pub fn ledger_rows(&self, kind: &str) -> Vec { assert_eq!( self.kind, diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index fba760690..01e1bd92b 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -51,7 +51,10 @@ // command as it starts: uuid, text, content and // the model, effort and permission mode it runs // under), `can_use_tool` (each answer the host's -// canUseTool gave) and `emission` (program steps) +// canUseTool gave), `control` (each settings call +// the CLI handles: setModel, setPermissionMode, +// applyFlagSettings, with its arguments) and +// `emission` (program steps) import fs from 'node:fs' import path from 'node:path' @@ -887,6 +890,7 @@ class SimulatedCli { async setModel(model) { return this.control(async () => { + this.ledger('control', { name: 'setModel', args: [model] }) this.model = model ?? FAKE_MODEL const text = `Set model to ${this.model}` this.emit({ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, timestamp: new Date().toISOString(), isReplay: true }) @@ -899,6 +903,7 @@ class SimulatedCli { async setPermissionMode(mode) { return this.control(() => { + this.ledger('control', { name: 'setPermissionMode', args: [mode] }) // The only settings call the CLI validates (any model or effort name is accepted). if (!PERMISSION_MODES.includes(mode)) throw new Error(`Cannot set permission mode: must be one of ${PERMISSION_MODES.join(', ')}`) this.permissionMode = mode @@ -909,6 +914,7 @@ class SimulatedCli { async applyFlagSettings(settings) { return this.control(() => { + this.ledger('control', { name: 'applyFlagSettings', args: [settings] }) this.flagSettings = { ...(this.flagSettings ?? {}), ...(settings ?? {}) } if (settings && 'effortLevel' in settings) this.effort = settings.effortLevel ?? undefined }) diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index ff4eafd05..ad61d4a6e 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -338,6 +338,25 @@ describe('Claude CLI simulator programs', () => { ]) }) + it('records every settings call the CLI handles in the ledger, in order, including a refused one', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-sim-ledger-')) + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })) + const ledger = path.join(dir, 'ledger.jsonl') + const s = await open({ permissionMode: 'default' }, { FRESHELL_CLAUDE_SIM_LEDGER: ledger }) + await s.q.initializationResult() + await s.q.setPermissionMode('plan') + await s.q.setModel('opus') + await s.q.applyFlagSettings({ effortLevel: 'low' }) + await expect(s.q.setPermissionMode('no-such-mode')).rejects.toThrow() + const rows = (await fs.readFile(ledger, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(rows.filter((row) => row.kind === 'control').map(({ name, args }) => ({ name, args }))).toEqual([ + { name: 'setPermissionMode', args: ['plan'] }, + { name: 'setModel', args: ['opus'] }, + { name: 'applyFlagSettings', args: [{ effortLevel: 'low' }] }, + { name: 'setPermissionMode', args: ['no-such-mode'] }, + ]) + }) + it('a program chooses the tool_use id the model gives a tool call and the whole input of an AskUserQuestion call', async () => { const seen: any[] = [] const s = await open({ diff --git a/test/unit/claude-sidecar/harness.ts b/test/unit/claude-sidecar/harness.ts index 888cfa151..935d206fd 100644 --- a/test/unit/claude-sidecar/harness.ts +++ b/test/unit/claude-sidecar/harness.ts @@ -39,7 +39,7 @@ export interface LedgerRow { pid: number /** The ordinal of the query() call that wrote the row (1 = the first in this sidecar). */ query: number - kind: 'query' | 'boot' | 'prompt' | 'can_use_tool' | 'emission' | string + kind: 'query' | 'boot' | 'prompt' | 'can_use_tool' | 'control' | 'emission' | string [key: string]: any } diff --git a/test/unit/claude-sidecar/live-frames.test.ts b/test/unit/claude-sidecar/live-frames.test.ts index 7067b2b1a..22c53736f 100644 --- a/test/unit/claude-sidecar/live-frames.test.ts +++ b/test/unit/claude-sidecar/live-frames.test.ts @@ -29,6 +29,13 @@ function transcriptUuids(sidecar: RealSidecar, cliSessionId: string): Set> { + return sidecar.stderr().split('\n').flatMap((line) => { + try { return [JSON.parse(line)] } catch { return [] } + }) +} + const isAnnouncement = (frame: SidecarFrame) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript' describe('a live turn\'s frames', () => { @@ -92,3 +99,70 @@ describe('a live turn\'s frames', () => { expect(streamed).toBe(reply?.content[0].text) }) }) + +describe('what a compaction and a permission mode change send', () => { + const resultsOf = (sidecar: RealSidecar) => sidecar.frames.filter((frame) => frame.type === 'sdk.result') + + it('a failed /compact: a COMPACTION_FAILED notice, then running until its result, and no error frame', async () => { + const sidecar = await startRealSidecar({ timeScale: 0.1 }) + const sessionId = await sidecar.create() + sidecar.send({ type: 'send', sessionId, text: '/compact APIERR:400' }) + await sidecar.waitFor('sdk.result', 'the compaction result') + const types = sidecar.frames.map((frame) => frame.type) + const compacting = sidecar.frames.findIndex((frame) => frame.type === 'sdk.status' && frame.status === 'compacting') + const notice = sidecar.frames.findIndex((frame) => frame.type === 'sdk.notice') + const running = sidecar.frames.findIndex((frame) => frame.type === 'sdk.status' && frame.status === 'running') + expect(compacting).toBeGreaterThanOrEqual(0) + expect(notice).toBeGreaterThan(compacting) + expect(running).toBeGreaterThan(notice) + expect(types.indexOf('sdk.result')).toBeGreaterThan(running) + expect(sidecar.frames[notice]).toEqual({ + type: 'sdk.notice', + sessionId, + level: 'error', + code: 'COMPACTION_FAILED', + message: 'Error during compaction: API Error: 400 local provider error', + }) + expect(types).not.toContain('sdk.error') + // The failure is on the sidecar's log (stderr, its own pipe), with its reason. + await expect.poll(() => logRecords(sidecar).find((record) => record.code === 'COMPACTION_FAILED')) + .toMatchObject({ severity: 'error', event: 'notice', sessionId, error: 'Error during compaction: API Error: 400 local provider error' }) + }) + + it('a successful /compact: running again once compacting ends, then its result, and no notice', async () => { + const sidecar = await startRealSidecar({ timeScale: 0.1 }) + const sessionId = await sidecar.create() + sidecar.send({ type: 'send', sessionId, text: 'something to compact' }) + await sidecar.waitFor('sdk.result') + sidecar.send({ type: 'send', sessionId, text: '/compact' }) + await sidecar.waitFor(() => resultsOf(sidecar).length === 2, 'the compaction result') + const statuses = sidecar.frames.filter((frame) => frame.type === 'sdk.status' || frame.type === 'sdk.result').map((frame) => frame.status ?? frame.type) + expect(statuses).toEqual(['sdk.result', 'compacting', 'running', 'sdk.result']) + expect(sidecar.frames.some((frame) => frame.type === 'sdk.notice')).toBe(false) + }) + + it('approving ExitPlanMode: the new mode is stated once, and configuring that mode afterwards calls nothing on the CLI', async () => { + const sidecar = await startRealSidecar({ timeScale: 0.1 }) + const sessionId = await sidecar.create({ permissionMode: 'plan' }) + sidecar.send({ type: 'send', sessionId, text: 'PLAN:exit' }) + const request = await sidecar.waitFor('sdk.permission.request') + expect(request.tool.name).toBe('ExitPlanMode') + sidecar.send({ type: 'permission.respond', sessionId, requestId: request.requestId, decision: { behavior: 'allow', updatedInput: request.tool.input } }) + expect(await sidecar.waitFor('sdk.session.metadata')).toEqual({ type: 'sdk.session.metadata', sessionId, permissionMode: 'default' }) + await sidecar.waitFor('sdk.result') + + // The server asks for the mode the CLI is already in: nothing to apply. + sidecar.send({ type: 'configure', sessionId, requestId: 'same-mode', settings: { permissionMode: 'default' } }) + expect(await sidecar.waitFor((frame) => frame.type === 'sdk.configured' && frame.requestId === 'same-mode')) + .toMatchObject({ ok: true, settings: { permissionMode: 'default' } }) + // Another mode is applied (and the CLI's report of it states nothing new). + sidecar.send({ type: 'configure', sessionId, requestId: 'other-mode', settings: { permissionMode: 'acceptEdits' } }) + await sidecar.waitFor((frame) => frame.type === 'sdk.configured' && frame.requestId === 'other-mode' && frame.ok === true) + sidecar.send({ type: 'send', sessionId, text: 'after the mode change' }) + await sidecar.waitFor(() => resultsOf(sidecar).length === 2, 'the next turn result') + expect((await sidecar.waitForPrompt(2)).permissionMode).toBe('acceptEdits') + expect(sidecar.ledger().filter((row) => row.kind === 'control').map((row) => [row.name, ...row.args])) + .toEqual([['setPermissionMode', 'acceptEdits']]) + expect(sidecar.frames.filter((frame) => frame.type === 'sdk.session.metadata')).toHaveLength(1) + }) +}) diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts index 2d8c86556..1726153cc 100644 --- a/test/unit/claude-sidecar/sdk-content-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -300,3 +300,45 @@ describe('frameLogRecord', () => { } }) }) + +describe('framesForSdkMessage: what a status frame reports besides compacting', () => { + const compactEnd = (scenario: string) => recorded(scenario).find((msg) => msg.type === 'system' && msg.subtype === 'status' && msg.status === null && msg.compact_result)! + + it('a failed compaction is a notice with the CLI\'s reason; the turn that ran it is running again', () => { + expect(framesForSdkMessage('s1', compactEnd('compact-fail'), session(undefined, { turnPending: true }))).toEqual([ + { type: 'sdk.notice', sessionId: 's1', level: 'error', code: 'COMPACTION_FAILED', message: 'Error during compaction: API Error: 400 compaction refused by fake' }, + { type: 'sdk.status', sessionId: 's1', status: 'running' }, + ]) + }) + + it('a finished compaction reports running only while a send is still waiting for its result', () => { + expect(framesForSdkMessage('s1', compactEnd('compact-ok'), session(undefined, { turnPending: true }))) + .toEqual([{ type: 'sdk.status', sessionId: 's1', status: 'running' }]) + expect(framesForSdkMessage('s1', compactEnd('compact-ok'), session(undefined, { turnPending: false }))).toEqual([]) + expect(framesForSdkMessage('s1', compactEnd('compact-fail'), session(undefined, { turnPending: false })).map((frame: Msg) => frame.type)).toEqual(['sdk.notice']) + }) + + it('a failed compaction with no reason still says what failed', () => { + const [notice] = framesForSdkMessage('s1', { type: 'system', subtype: 'status', status: null, compact_result: 'failed', uuid: 'x' }, session()) + expect(notice).toMatchObject({ code: 'COMPACTION_FAILED', level: 'error', message: expect.stringMatching(/\S/) }) + }) + + it('a permission mode the CLI reports that differs from the session\'s becomes sdk.session.metadata (F12: after ExitPlanMode)', () => { + const planExit = { type: 'system', subtype: 'status', status: null, permissionMode: 'default', uuid: '27298f9c-df16-4cbd-a017-e33c7b518c0b', session_id: 'c1' } + expect(framesForSdkMessage('s1', planExit, session(undefined, { permissionMode: 'plan' }))).toEqual([ + { type: 'sdk.session.metadata', sessionId: 's1', permissionMode: 'default' }, + ]) + // The mode the session already has, or none at all, states nothing; a + // status frame that ends no compaction is no reason to say running. + expect(framesForSdkMessage('s1', planExit, session(undefined, { permissionMode: 'default' }))).toEqual([]) + expect(framesForSdkMessage('s1', { ...planExit, permissionMode: undefined }, session(undefined, { permissionMode: 'plan' }))).toEqual([]) + }) + + it('logs a failed compaction as an error with its reason, and a permission mode change at info', () => { + const [notice] = framesForSdkMessage('s1', compactEnd('compact-fail'), session()) + expect(frameLogRecord(notice)).toEqual({ severity: 'error', event: 'notice', fields: { sessionId: 's1', code: 'COMPACTION_FAILED', error: 'Error during compaction: API Error: 400 compaction refused by fake' } }) + expect(frameLogRecord({ type: 'sdk.session.metadata', sessionId: 's1', permissionMode: 'default' })) + .toEqual({ severity: 'info', event: 'permission_mode.changed', fields: { sessionId: 's1', permissionMode: 'default' } }) + expect(frameLogRecord({ type: 'sdk.status', sessionId: 's1', status: 'running' })).toBeUndefined() + }) +}) From ac5ba59c772130ce20ea39aec5e7bdf40f599944 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:52:24 -0700 Subject: [PATCH 06/45] test(claude): real-CLI contract for the sidecar's live frames (B6) claude-live-frames runs the production sidecar over the pinned SDK 0.3.237 and CLI 2.1.237 against the local provider (never the real API): text with a thinking block, Write then Edit, a synchronous Agent whose subagent reads a file, two 529 retries, a successful and a failed /compact, and an ExitPlanMode approval. It pins the stream filter and its message ids, the transcript announcements (each announced uuid is on disk within 2 s, subagent ones in the subagent file), sdk.retry with its log records, the compaction outcome frames, and the permission-mode fold. Against the pre-Phase-B sidecar, 8 of its 10 tests fail. Real-CLI fact it pins: approving ExitPlanMode returns the CLI to the mode it had before plan mode (here bypassPermissions), not always default. startLocalProvider gains an optional `respond` hook (a test's own model, asked before the prompt markers), so the shared marker language the simulator conforms to is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../providers/claude-live-frames.mjs | 246 ++++++++++++++++++ .../providers/claude-live-frames.test.ts | 188 +++++++++++++ .../providers/claude-local-provider.mjs | 74 +++++- 3 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 test/integration/providers/claude-live-frames.mjs create mode 100644 test/integration/providers/claude-live-frames.test.ts diff --git a/test/integration/providers/claude-live-frames.mjs b/test/integration/providers/claude-live-frames.mjs new file mode 100644 index 000000000..289417bff --- /dev/null +++ b/test/integration/providers/claude-live-frames.mjs @@ -0,0 +1,246 @@ +// Contract harness (transcript-live plan B6): the REAL Claude sidecar +// (crates/freshell-claude-sidecar/index.mjs) over the pinned SDK and its +// bundled native CLI (resolvePinnedClaude), in the shared throwaway fixture +// (createClaudeFixture), against the local provider endpoint +// (startLocalProvider; the real Anthropic API is never contacted). One +// session runs these turns in order and records every stdout frame: +// +// text a thinking block, then text streamed in small pieces +// edit Write, then Edit, then a reply (bypassPermissions) +// agent a synchronous Agent (subagent) call: the subagent reads the +// notes and reports; then a reply +// retry the provider answers 529 twice; the CLI retries, then succeeds +// compact-ok /compact +// more one more model turn (so there is something to compact) +// compact-fail /compact whose summary request fails (APIERR:400) +// plan configure plan mode, then ExitPlanMode approved through the +// sidecar's permission channel; then configure the mode the +// CLI says it returned to +// +// Meanwhile it polls the CLI's transcript files and records when each line's +// uuid first appears, so the test can check every announced uuid landed. +// +// Usage: node claude-live-frames.mjs . Prints ONE JSON line (the +// receipt); structured progress and failures go to stderr as JSONL. +import fs from 'node:fs/promises' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { randomUUID } from 'node:crypto' +import { createClaudeFixture, FAKE_MODEL, resolvePinnedClaude, startLocalProvider } from './claude-local-provider.mjs' +import { SUBAGENT_MARKER } from '../../fixtures/claude-cli-sim/prompt-markers.mjs' + +const repoRoot = process.argv[2] +if (!repoRoot) throw new Error('repository root argument is required') + +const TURN_TIMEOUT_MS = 45_000 +/** How long an announced uuid may take to reach the transcript. */ +const LANDING_BUDGET_MS = 2_000 +const POLL_MS = 25 + +const log = (severity, event, fields = {}) => { + process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), severity, component: 'claude-live-frames', event, ...fields })}\n`) +} +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const pinned = await resolvePinnedClaude(repoRoot) +const fixture = await createClaudeFixture({ prefix: 'freshell-claude-live-frames-' }) +const { cwd } = fixture +const notes = path.join(cwd, 'notes.txt') + +// ── the model ──────────────────────────────────────────────────────────────── +// +// The scripted model answers by the scenario running now and by the tool +// result it is shown (the CLI merges prompts, reminders and command records +// into user messages, so prompt text is no key). A compaction's summary +// request is left to the prompt markers: the args of `/compact APIERR:400` +// reach it (T15). + +/** The scenario whose turn the provider is answering. */ +let current = 'boot' +let retryAttempts = 0 +const PLAN = '# Plan\n\n1. Edit the notes' + +function respond({ isSubagent, toolResult, tools }) { + if (isSubagent) { + // The subagent reads the notes, then reports. + if (!toolResult) return { blocks: [{ type: 'tool_use', id: 'toolu_live_sub_read', name: 'Read', input: { file_path: notes } }] } + return { blocks: [{ type: 'text', text: 'The notes say two.' }] } + } + switch (current) { + case 'text': + return { blocks: [{ type: 'thinking', thinking: 'Planning a short answer.' }, { type: 'text', text: 'Streaming this reply in small pieces.' }] } + case 'edit': + if (!toolResult) return { blocks: [{ type: 'text', text: 'Writing the notes.' }, { type: 'tool_use', id: 'toolu_live_write', name: 'Write', input: { file_path: notes, content: 'one\n' } }] } + if (toolResult.tool_use_id === 'toolu_live_write') return { blocks: [{ type: 'tool_use', id: 'toolu_live_edit', name: 'Edit', input: { file_path: notes, old_string: 'one', new_string: 'two' } }] } + return { blocks: [{ type: 'text', text: 'Edited the notes.' }] } + case 'agent': { + if (toolResult) return { blocks: [{ type: 'text', text: 'The subagent checked the notes.' }] } + const agentTool = tools.find((name) => name === 'Agent' || name === 'Task') ?? 'Agent' + return { blocks: [{ type: 'tool_use', id: 'toolu_live_agent', name: agentTool, input: { description: 'check the notes', prompt: `${SUBAGENT_MARKER} check the notes`, subagent_type: 'general-purpose', run_in_background: false } }] } + } + case 'retry': + retryAttempts += 1 + if (retryAttempts <= 2) return { status: 529, errorType: 'overloaded_error', message: 'Overloaded' } + return { blocks: [{ type: 'text', text: 'Answered after two retries.' }] } + case 'more': + return { blocks: [{ type: 'text', text: 'More to compact later.' }] } + case 'plan': + if (!toolResult) return { blocks: [{ type: 'tool_use', id: 'toolu_live_plan', name: 'ExitPlanMode', input: { plan: PLAN } }] } + return { blocks: [{ type: 'text', text: 'Leaving plan mode.' }] } + default: + return undefined + } +} + +// ── the run ────────────────────────────────────────────────────────────────── + +let provider +let sidecar +let sidecarExit +let failure +const frames = [] +const scenarios = {} +/** uuid -> { at, file: 'main' | 'subagent' }: when the uuid first appeared in a transcript file. */ +const seen = new Map() +const stderrLines = [] +let polling = true +const t0 = Date.now() + +async function pollTranscripts() { + while (polling) { + for (const file of await fixture.transcripts()) { + let text + try { text = await fs.readFile(path.join(fixture.claudeConfigDir, file), 'utf8') } catch { continue } + const where = file.includes(`${path.sep}subagents${path.sep}`) ? 'subagent' : 'main' + for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) { + if (!line) continue + let row + try { row = JSON.parse(line) } catch { continue } + if (typeof row.uuid === 'string' && !seen.has(row.uuid)) seen.set(row.uuid, { at: Date.now() - t0, file: where }) + } + } + await sleep(POLL_MS) + } +} + +/** A frame as the receipt keeps it: the big catalogs (commands, tools) by size only. */ +function slim(frame) { + const out = { ...frame } + if (Array.isArray(out.commands)) out.commands = out.commands.length + if (Array.isArray(out.tools)) out.tools = out.tools.length + return out +} + +let poller +try { + provider = await startLocalProvider({ respond }) + sidecar = spawn(process.execPath, [path.join(repoRoot, 'crates/freshell-claude-sidecar/index.mjs')], { + cwd: path.join(repoRoot, 'crates/freshell-claude-sidecar'), + env: fixture.env(provider.url, { CLAUDE_CMD: pinned.nativeCli, FRESHELL_CLAUDE_ONECLI_API_KEY: '1' }), + stdio: ['pipe', 'pipe', 'pipe'], + }) + sidecar.stdin.on('error', () => {}) + const exited = new Promise((resolve) => sidecar.once('exit', (code, signal) => { sidecarExit = { code, signal }; resolve() })) + createInterface({ input: sidecar.stderr }).on('line', (line) => stderrLines.push(line)) + const waiters = [] + createInterface({ input: sidecar.stdout }).on('line', (line) => { + let frame + try { frame = JSON.parse(line) } catch { frame = { type: '', line } } + frames.push({ t: Date.now() - t0, ...frame }) + for (const waiter of [...waiters]) if (waiter.match(frame)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(frame) } + }) + const waitFor = (match, what, ms = TURN_TIMEOUT_MS) => new Promise((resolve, reject) => { + const hit = frames.find(match) + if (hit) return resolve(hit) + const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), ms) + waiters.push({ match, resolve: (frame) => { clearTimeout(timer); resolve(frame) } }) + }) + const write = (message) => sidecar.stdin.write(`${JSON.stringify(message)}\n`) + const resultCount = () => frames.filter((frame) => frame.type === 'sdk.result').length + + const requestId = randomUUID() + write({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions' }) + const created = await waitFor((frame) => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 'created') + if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + const sessionId = created.sessionId + poller = pollTranscripts() + + /** Run one scenario: `act` starts it; it ends at the next result. */ + const scenario = async (name, act) => { + const results = resultCount() + // An unfinished scenario keeps every frame after its start (a failure's evidence). + scenarios[name] = { start: frames.length } + current = name + log('info', 'scenario.start', { name }) + await act() + await waitFor(() => resultCount() > results, `the ${name} result`) + scenarios[name].end = frames.length + } + const send = (text) => write({ type: 'send', sessionId, text }) + const configure = async (requestId, settings) => { + write({ type: 'configure', sessionId, requestId, settings }) + const answer = await waitFor((frame) => frame.type === 'sdk.configured' && frame.requestId === requestId, `configure ${requestId}`) + if (answer.ok !== true) throw new Error(`configure ${requestId} failed: ${answer.message}`) + } + + await scenario('text', () => send('say something')) + await scenario('edit', () => send('edit the notes')) + await scenario('agent', () => send('delegate a check')) + await scenario('retry', () => send('answer once the API recovers')) + await scenario('compact-ok', () => send('/compact')) + // Right after a compaction the CLI has "Not enough messages to compact" and + // never asks the model: one more turn first (T15's order). + await scenario('more', () => send('one more turn')) + await scenario('compact-fail', () => send('/compact APIERR:400')) + await scenario('plan', async () => { + await configure('to-plan', { permissionMode: 'plan' }) + send('make a plan, then leave plan mode') + const request = await waitFor((frame) => frame.type === 'sdk.permission.request', 'the ExitPlanMode approval request') + write({ type: 'permission.respond', sessionId, requestId: request.requestId, decision: { behavior: 'allow', updatedInput: request.tool.input } }) + }) + // The server then asks for the mode the CLI said it is in: accepted as is. + const stated = frames.filter((frame) => frame.type === 'sdk.session.metadata').at(-1)?.permissionMode + const beforeConfigure = frames.length + await configure('to-stated', { permissionMode: stated ?? 'default' }) + scenarios['configure-stated'] = { start: beforeConfigure, end: frames.length } + + // Let the last announced lines land (or not) within their budget. + const lastAnnouncement = frames.filter((frame) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript').at(-1) + await sleep(Math.max(0, (lastAnnouncement?.t ?? 0) + LANDING_BUDGET_MS + 200 - (Date.now() - t0))) + polling = false + await poller + write({ type: 'shutdown' }) + sidecar.stdin.end() + await Promise.race([exited, sleep(10_000)]) +} catch (error) { + failure = String(error?.message ?? error).slice(0, 1000) + log('error', 'run.failed', { error: failure }) +} finally { + polling = false + await poller?.catch(() => {}) + if (sidecar && !sidecarExit) sidecar.kill('SIGKILL') + if (provider) await provider.close() + await fixture.cleanup() +} + +const fixtureRemoved = !(await fs.stat(fixture.root).catch(() => null)) +const announcements = frames.filter((frame) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript') +const receipt = { + sdkVersion: pinned.sdkVersion, + bundledCliVersion: pinned.cliVersion, + failure, + sidecarExit, + fixtureRemoved, + scenarios: Object.fromEntries(Object.entries(scenarios).map(([name, { start, end }]) => [name, frames.slice(start, end).map(slim)])), + landing: announcements.map((frame) => ({ + uuid: frame.uuid, + parentToolUseId: frame.parentToolUseId, + lagMs: seen.has(frame.uuid) ? seen.get(frame.uuid).at - frame.t : null, + file: seen.get(frame.uuid)?.file ?? null, + })), + logRecords: stderrLines.flatMap((line) => { try { return [JSON.parse(line)] } catch { return [] } }) + .filter((record) => ['api.retry', 'notice', 'permission_mode.changed'].includes(record.event)), + providerRequests: provider?.requests ?? [], +} +process.stdout.write(`${JSON.stringify(receipt)}\n`, () => process.exit(failure || !fixtureRemoved ? 1 : 0)) diff --git a/test/integration/providers/claude-live-frames.test.ts b/test/integration/providers/claude-live-frames.test.ts new file mode 100644 index 000000000..c557430eb --- /dev/null +++ b/test/integration/providers/claude-live-frames.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node +// +// Real-CLI contract for the Claude sidecar's live frames (transcript-live plan +// B6): the production sidecar (crates/freshell-claude-sidecar/index.mjs) over +// the pinned SDK 0.3.237 and its bundled CLI 2.1.237, against the local +// provider endpoint (never the real API). claude-live-frames.mjs runs one +// session through text, Write/Edit, a synchronous Agent, two 529 retries, a +// successful and a failed /compact and an ExitPlanMode approval, and reports +// every stdout frame plus when each transcript line appeared on disk. +// +// The same frames are pinned on recorded sequences (test/unit/claude-sidecar/ +// sdk-content-frames.test.ts) and through the simulator (live-frames.test.ts); +// this suite is what flags a pinned-CLI bump that changes them. When the +// pinned CLI is missing the harness fails (PinnedClaudeMissingError): nothing +// here is ever skipped. + +import { spawn, type ChildProcess } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { PINNED_CLI_VERSION, PINNED_SDK_VERSION } from './claude-local-provider.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(__dirname, '../../..') +const HARNESS = path.join(__dirname, 'claude-live-frames.mjs') +/** How long an announced uuid may take to reach the transcript (the CLI writes ~100 ms late, F7). */ +const LANDING_BUDGET_MS = 2_000 + +type Frame = Record +interface Receipt { + sdkVersion: string + bundledCliVersion: string + failure?: string + sidecarExit?: { code: number | null; signal: string | null } + fixtureRemoved: boolean + scenarios: Record + landing: Array<{ uuid: string; parentToolUseId: string | null; lagMs: number | null; file: 'main' | 'subagent' | null }> + logRecords: Frame[] +} + +let child: ChildProcess | undefined +let receipt: Receipt +let stderr = '' + +beforeAll(async () => { + child = spawn(process.execPath, [HARNESS, REPO_ROOT], { + cwd: REPO_ROOT, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk }) + child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child?.kill('SIGTERM') + reject(new Error(`the live-frames harness timed out; stderr=${stderr.slice(-2000)}`)) + }, 140_000) + timer.unref() + child!.once('error', (error) => { clearTimeout(timer); reject(error) }) + child!.once('exit', () => { clearTimeout(timer); resolve() }) + }) + const lines = stdout.trim().split('\n').filter(Boolean) + if (lines.length !== 1) throw new Error(`expected one receipt line; stdout=${stdout.slice(-1000)} stderr=${stderr.slice(-2000)}`) + receipt = JSON.parse(lines[0]!) +}, 150_000) + +afterAll(() => { + if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') +}) + +const frames = (scenario: string): Frame[] => { + const recorded = receipt.scenarios[scenario] + expect(recorded, `scenario ${scenario} ran`).toBeDefined() + return recorded! +} +const all = (): Frame[] => Object.values(receipt.scenarios).flat() +const ofType = (list: Frame[], type: string) => list.filter((frame) => frame.type === type) +const isAnnouncement = (frame: Frame) => frame.type === 'sdk.session.changed' && frame.reason === 'transcript' + +describe(`the Claude sidecar's live frames over the pinned CLI ${PINNED_CLI_VERSION} and a local provider`, () => { + it('runs every scenario on the pinned SDK and CLI, and the sidecar then exits cleanly', () => { + expect(receipt.failure, stderr.slice(-2000)).toBeUndefined() + expect(receipt).toMatchObject({ sdkVersion: PINNED_SDK_VERSION, bundledCliVersion: PINNED_CLI_VERSION, fixtureRemoved: true, sidecarExit: { code: 0, signal: null } }) + expect(Object.keys(receipt.scenarios)).toEqual(['text', 'edit', 'agent', 'retry', 'compact-ok', 'more', 'compact-fail', 'plan', 'configure-stated']) + }) + + it('streams only the main thread\'s message and block starts, block stops and text/thinking deltas', () => { + const streams = ofType(all(), 'sdk.stream') + expect(streams.length).toBeGreaterThan(20) + for (const frame of streams) { + expect(frame.parentToolUseId).toBeNull() + expect(['message_start', 'content_block_start', 'content_block_delta', 'content_block_stop']).toContain(frame.event.type) + if (frame.event.type === 'content_block_delta') expect(['text_delta', 'thinking_delta']).toContain(frame.event.delta.type) + } + // Every stream belongs to a main-thread assistant message: no subagent stream leaks. + const mainMessages = new Set(ofType(all(), 'sdk.assistant').filter((frame) => frame.parentToolUseId === null).map((frame) => frame.messageId)) + for (const frame of streams) expect(mainMessages).toContain(frame.messageId) + }) + + it('each streamed block carries the message id of the sdk.assistant it becomes, and its deltas add up to it', () => { + const list = frames('text') + const blocks: Array<{ messageId: string; kind: string; text: string }> = [] + for (const frame of ofType(list, 'sdk.stream')) { + if (frame.event.type === 'content_block_start') blocks.push({ messageId: frame.messageId, kind: frame.event.content_block.type, text: '' }) + if (frame.event.type === 'content_block_delta') blocks.at(-1)!.text += frame.event.delta.text ?? frame.event.delta.thinking + } + const assistant = ofType(list, 'sdk.assistant').map((frame) => ({ messageId: frame.messageId, kind: frame.content[0].type, text: frame.content[0].text ?? frame.content[0].thinking })) + expect(blocks).toEqual(assistant) + expect(assistant.map((block) => block.kind)).toEqual(['thinking', 'text']) + // One API message: both blocks share its id. + expect(new Set(assistant.map((block) => block.messageId)).size).toBe(1) + }) + + it('a tool call streams its block start with the tool name, never its input JSON', () => { + const starts = ofType(frames('edit'), 'sdk.stream').filter((frame) => frame.event.type === 'content_block_start' && frame.event.content_block.type === 'tool_use') + expect(starts.map((frame) => frame.event.content_block.name)).toEqual(['Write', 'Edit']) + const tools = ofType(frames('edit'), 'sdk.assistant').filter((frame) => frame.content[0].type === 'tool_use') + expect(tools.map((frame) => [frame.content[0].name, frame.messageId])).toEqual(starts.map((frame) => [frame.event.content_block.name, frame.messageId])) + }) + + it('sdk.assistant carries its uuid and message id; a subagent\'s messages name the Agent call that started it', () => { + for (const frame of ofType(all(), 'sdk.assistant')) expect(frame).toMatchObject({ uuid: expect.any(String), messageId: expect.any(String) }) + const list = frames('agent') + const agentCall = ofType(list, 'sdk.assistant').find((frame) => frame.parentToolUseId === null && frame.content[0].type === 'tool_use')! + expect(agentCall.content[0].id).toBe('toolu_live_agent') + const sidechain = list.filter((frame) => frame.parentToolUseId === 'toolu_live_agent') + // The subagent's prompt, its Read call and the Read result: each announced, the call also as sdk.assistant. + expect(sidechain.filter(isAnnouncement)).toHaveLength(3) + expect(ofType(sidechain, 'sdk.assistant').map((frame) => frame.content[0].name)).toEqual(['Read']) + }) + + it('announces each transcript message right after its frames, and every announced uuid is on disk within 2 s', () => { + const announcements = all().filter(isAnnouncement) + expect(announcements.length).toBeGreaterThan(20) + for (const assistant of ofType(all(), 'sdk.assistant')) { + const list = all() + expect(list[list.indexOf(assistant) + 1]).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript', uuid: assistant.uuid, parentToolUseId: assistant.parentToolUseId }) + } + expect(receipt.landing.map((row) => row.uuid)).toEqual(announcements.map((frame) => frame.uuid)) + for (const row of receipt.landing) { + expect(row.lagMs, `uuid ${row.uuid} never reached the transcript`).not.toBeNull() + expect(row.lagMs!).toBeLessThanOrEqual(LANDING_BUDGET_MS) + expect(row.file).toBe(row.parentToolUseId === null ? 'main' : 'subagent') + } + }) + + it('a provider that answers 529 twice: two sdk.retry frames (each logged), then the turn completes with no error', () => { + const list = frames('retry') + const retries = ofType(list, 'sdk.retry') + expect(retries).toEqual([ + expect.objectContaining({ attempt: 1, maxRetries: expect.any(Number), delayMs: expect.any(Number), errorStatus: 529, error: 'overloaded' }), + expect.objectContaining({ attempt: 2, errorStatus: 529, error: 'overloaded' }), + ]) + // Both before the reply streams. + expect(list.indexOf(retries[1]!)).toBeLessThan(list.findIndex((frame) => frame.type === 'sdk.stream')) + expect(ofType(list, 'sdk.result')).toEqual([expect.objectContaining({ result: 'success' })]) + expect(ofType(list, 'sdk.error')).toEqual([]) + expect(receipt.logRecords.filter((record) => record.event === 'api.retry').map((record) => [record.severity, record.attempt, record.errorStatus])) + .toEqual([['warn', 1, 529], ['warn', 2, 529]]) + }) + + it('/compact: compacting, running again, the manual boundary and its transcript lines, then the result, with no notice', () => { + const list = frames('compact-ok') + const order = list.filter((frame) => ['sdk.status', 'sdk.compact_boundary', 'sdk.result', 'sdk.notice'].includes(frame.type)).map((frame) => frame.status ?? frame.trigger ?? frame.type) + expect(order).toEqual(['compacting', 'running', 'manual', 'sdk.result']) + const boundary = list.findIndex((frame) => frame.type === 'sdk.compact_boundary') + expect(list[boundary + 1]).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript' }) + }) + + it('a /compact whose summary request fails: a COMPACTION_FAILED notice with the CLI\'s reason, running again, the result, no error frame', () => { + const list = frames('compact-fail') + const order = list.filter((frame) => ['sdk.status', 'sdk.notice', 'sdk.result', 'sdk.error'].includes(frame.type)).map((frame) => frame.status ?? frame.code ?? frame.type) + expect(order).toEqual(['compacting', 'COMPACTION_FAILED', 'running', 'sdk.result']) + expect(ofType(list, 'sdk.notice')[0]).toMatchObject({ level: 'error', message: expect.stringMatching(/^Error during compaction: API Error: 400/) }) + expect(receipt.logRecords.find((record) => record.code === 'COMPACTION_FAILED')).toMatchObject({ severity: 'error', error: expect.stringMatching(/^Error during compaction/) }) + }) + + it('approving ExitPlanMode: the CLI returns to the mode it had before plan mode, the sidecar states it once, and configuring it is accepted', () => { + const list = frames('plan') + expect(ofType(list, 'sdk.configured')[0]).toMatchObject({ ok: true, settings: { permissionMode: 'plan' } }) + expect(ofType(list, 'sdk.permission.request')[0]).toMatchObject({ tool: { name: 'ExitPlanMode' } }) + // The session started in bypassPermissions, so that is where plan mode ends. + expect(ofType(all(), 'sdk.session.metadata')).toEqual([expect.objectContaining({ permissionMode: 'bypassPermissions' })]) + expect(receipt.logRecords.filter((record) => record.event === 'permission_mode.changed').map((record) => record.permissionMode)).toEqual(['bypassPermissions']) + expect(ofType(frames('configure-stated'), 'sdk.configured')).toEqual([expect.objectContaining({ ok: true, settings: expect.objectContaining({ permissionMode: 'bypassPermissions' }) })]) + }) +}) diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index 752f5a01b..0842cde7e 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -12,12 +12,16 @@ // - claude-capture-fixtures.mjs (resolvePinnedClaude only: the manual // transcript-fixture capture keeps its own `ws` workspace, which the // committed fixtures' paths depend on) +// - claude-live-frames.mjs (the real sidecar's live frames; it brings its +// own model through the `respond` hook) // // The model's behavior is chosen by prompt markers (see // test/fixtures/claude-cli-sim/prompt-markers.mjs), the same language the CLI -// simulator understands. Run directly (`node claude-local-provider.mjs -// --serve`) it prints one JSON line {"port","url"} and serves until stdin -// closes, for lanes that are not written in JavaScript. +// simulator understands. A real-CLI-only test may bring its own model for the +// requests it scripts (startLocalProvider's `respond` hook); the markers answer +// the rest. Run directly (`node claude-local-provider.mjs --serve`) it prints +// one JSON line {"port","url"} and serves until stdin closes, for lanes that +// are not written in JavaScript. import fs from 'node:fs/promises' import http from 'node:http' @@ -215,11 +219,55 @@ function sseWriter(response) { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +/** The last tool_result block of a message, if it has one. */ +function lastToolResultOf(message) { + const content = Array.isArray(message?.content) ? message.content : [] + return content.filter((block) => block?.type === 'tool_result').at(-1) +} + +/** + * Stream `blocks` as the content of one message: text (in `chunk`-sized + * deltas), thinking (one delta plus its signature) and tool_use blocks (the + * input as one JSON delta). `nextToolId` names a tool_use without an id. + */ +function streamBlocks(send, blocks, { chunk = 8, nextToolId }) { + blocks.forEach((block, index) => { + if (block.type === 'text') { + send('content_block_start', { index, content_block: { type: 'text', text: '' } }) + for (const piece of block.text.match(new RegExp(`[\\s\\S]{1,${chunk}}`, 'g')) ?? []) { + send('content_block_delta', { index, delta: { type: 'text_delta', text: piece } }) + } + } else if (block.type === 'thinking') { + send('content_block_start', { index, content_block: { type: 'thinking', thinking: '', signature: '' } }) + send('content_block_delta', { index, delta: { type: 'thinking_delta', thinking: block.thinking } }) + send('content_block_delta', { index, delta: { type: 'signature_delta', signature: 'local-signature' } }) + } else if (block.type === 'tool_use') { + send('content_block_start', { index, content_block: { type: 'tool_use', id: block.id ?? nextToolId(), name: block.name, input: {} } }) + send('content_block_delta', { index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input ?? {}) } }) + } else { + throw new Error(`unknown scripted block type ${block.type}`) + } + send('content_block_stop', { index }) + }) + const stopReason = blocks.some((block) => block.type === 'tool_use') ? 'tool_use' : 'end_turn' + send('message_delta', { delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: 1 } }) +} + /** * Start the loopback endpoint. Returns { url, port, requests, close }. * `requests` records one summary row per model request (no bodies). + * + * `respond(request)` (optional) is a test's own model, asked first for every + * model request. `request` is { messages, promptText, afterTool, isSubagent, + * tools, toolResult } (toolResult: the last tool_result of the last message). + * It returns undefined to let the prompt markers answer, or one of: + * { status, errorType?, message? } the request fails with that HTTP status + * { blocks, chunk? } one streamed message of text, thinking + * and tool_use ({name, input, id?}) blocks + * The CLI simulator has no counterpart (its scripting is a program), so a test + * that uses it runs on the real CLI only. */ -export async function startLocalProvider({ replyText = 'ok' } = {}) { +export async function startLocalProvider({ replyText = 'ok', respond } = {}) { const requests = [] const hung = new Set() let toolSeq = 0 @@ -259,9 +307,16 @@ export async function startLocalProvider({ replyText = 'ok' } = {}) { const tools = Array.isArray(body.tools) ? body.tools.map((tool) => tool?.name).filter(Boolean) : [] const isSubagent = firstUserText.includes(SUBAGENT_MARKER) const marker = parsePromptMarkers(promptText) - requests.push({ n: requests.length + 1, marker: marker.kind, afterTool, isSubagent, tools: tools.length }) + const scripted = respond?.({ messages, promptText, afterTool, isSubagent, tools, toolResult: lastToolResultOf(last) }) + requests.push({ n: requests.length + 1, marker: scripted ? 'scripted' : marker.kind, afterTool, isSubagent, tools: tools.length }) + + if (scripted?.status) { + response.writeHead(scripted.status, { 'content-type': 'application/json', 'request-id': randomUUID() }) + .end(JSON.stringify({ type: 'error', error: { type: scripted.errorType ?? 'api_error', message: scripted.message ?? 'local provider error' } })) + return + } - if (marker.kind === 'api_error') { + if (marker.kind === 'api_error' && !scripted) { response.writeHead(marker.status, { 'content-type': 'application/json', 'request-id': randomUUID() }) .end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'local provider error' } })) return @@ -283,6 +338,13 @@ export async function startLocalProvider({ replyText = 'ok' } = {}) { }, }) + if (scripted) { + streamBlocks(send, scripted.blocks ?? [], { chunk: scripted.chunk, nextToolId: () => `toolu_local_${++toolSeq}` }) + send('message_stop', {}) + response.end() + return + } + if (marker.kind === 'hang') { hung.add(response) response.once('close', () => hung.delete(response)) From 726e2d1a87e8f457045fdeef8fd3ee45c382bba6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:18:21 -0700 Subject: [PATCH 07/45] feat(claude): API retries and notices reach every client (C1) The sidecar's sdk.retry and sdk.notice frames were dropped by the sdk.* -> freshAgent.* rename table. They now broadcast as freshAgent.retry and freshAgent.notice, stamped with the pane's id like every other sidecar event, and never end the turn or change its status. Each is also logged under the pane's id (freshagent.claude.api_retry at info, freshagent.claude.notice at warn or info by its level), with ids, numbers and codes only: a notice's text is logged by its size. A transcript announcement (sdk.session.changed reason 'transcript') already passed through; a test now pins that. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 9 + .../src/claude/live_frames_tests.rs | 336 ++++++++++++++++++ .../src/claude_live_events.rs | 94 +++++ crates/freshell-freshagent/src/lib.rs | 1 + 4 files changed, 440 insertions(+) create mode 100644 crates/freshell-freshagent/src/claude/live_frames_tests.rs create mode 100644 crates/freshell-freshagent/src/claude_live_events.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 91f7a9782..bc064e5f4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -9012,6 +9012,7 @@ impl FreshClaudeState { // map key) so an attach-by-durable rebind flips live event routing. let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); let line_type = value.get("type").and_then(Value::as_str).unwrap_or(""); + crate::claude_live_events::log_provider_event(&value, &stamp, &session_type); if let Some(edge) = AttentionEdge::from_sidecar_type(line_type) { // The sidecar's attention edges leave through the one edge // exit (logged there, G14). @@ -9816,6 +9817,11 @@ fn normalize_sdk_type(sdk_type: &str) -> Option<&'static str> { // provider-originated cancellation). "sdk.question.cancelled" => "freshAgent.question.cancelled", "sdk.status" => "freshAgent.status", + // Non-terminal provider events (transcript-live plan P4): an API + // retry and a notice (rate limit, informational banner, failed + // compaction). Neither ends the turn; the client shows them in place. + "sdk.retry" => "freshAgent.retry", + "sdk.notice" => "freshAgent.notice", // `sdk.turn.complete` / `sdk.turn.waiting` are deliberately absent: the // consumer routes them to `emit_attention_edge` (AttentionEdge), the one // logged edge exit, so no other path can broadcast an edge. @@ -10784,6 +10790,9 @@ mod teardown_tests; #[cfg(test)] mod end_of_output_tests; +#[cfg(test)] +mod live_frames_tests; + /// ISO-8601 / RFC-3339 millis-Z timestamp (`new Date().toISOString()`) for error frames. fn now_iso() -> String { let now = SystemTime::now() diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs new file mode 100644 index 000000000..45d73f451 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -0,0 +1,336 @@ +//! The live frames a claude sidecar sends mid-turn (transcript-live plan §4.2, +//! Phase C): API retries, notices, per-message transcript announcements and the +//! CLI's own permission-mode changes, as every client receives them. +//! +//! The sidecar is a small Node fake started through the production create +//! (`handle_create`), so the stdout consumer, its folds and the broadcast are +//! the real ones. A send's text is the list of `sdk.*` frames the fake writes +//! back (as a JSON array), each stamped with the sidecar's session id; a +//! configure is logged and answered the way index.mjs answers it. + +use super::*; + +use super::teardown_tests::capture; + +/// The durable id the fake's session reports. +const DURABLE: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + +/// The fake sidecar; `CONFIGURE_LOG` and `DURABLE` are prepended. +const ECHO_SIDECAR: &str = r#" +const fs = require('node:fs') +const out = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') +require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { + let msg + try { msg = JSON.parse(line) } catch { return } + if (msg.type === 'create') { + const sessionId = 'live-frames-session' + out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) + } else if (msg.type === 'send') { + for (const frame of JSON.parse(msg.text)) out({ ...frame, sessionId: msg.sessionId }) + } else if (msg.type === 'configure') { + fs.appendFileSync(CONFIGURE_LOG, JSON.stringify(msg) + '\n') + out({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: msg.settings }) + } else if (msg.type === 'shutdown') { + process.exit(0) + } +}) +"#; + +/// One session of the echo fake, created through the production create. +struct EchoSession { + st: FreshClaudeState, + rx: tokio::sync::broadcast::Receiver, + /// The pane's id for the session (its map key and envelope id). + pane: String, + /// Every frame read off the bus so far, in order. + frames: Vec, + _dir: tempfile::TempDir, + _env: tokio::sync::MutexGuard<'static, ()>, +} + +impl EchoSession { + async fn start(permission_mode: Option<&str>) -> Self { + let env = super::tests::CLAUDE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().expect("fake sidecar directory"); + let script = dir.path().join("echo-sidecar.cjs"); + let configure_log = dir.path().join("configure.log"); + std::fs::write(&configure_log, "").expect("init the configure log"); + let quoted = |text: &str| serde_json::to_string(text).expect("a JS string"); + std::fs::write( + &script, + format!( + "const CONFIGURE_LOG = {}\nconst DURABLE = {}\n{ECHO_SIDECAR}", + quoted(&configure_log.to_string_lossy()), + quoted(DURABLE), + ), + ) + .expect("write the fake sidecar"); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().join("claude")); + let (tx, rx) = tokio::sync::broadcast::channel::(256); + let st = FreshClaudeState::new(Arc::new(tx)); + let mut session = Self { + st, + rx, + pane: String::new(), + frames: Vec::new(), + _dir: dir, + _env: env, + }; + let mut create = create_msg("live-frames"); + create.permission_mode = permission_mode.map(str::to_string); + session.st.handle_create(create, None).await; + let created = session + .await_frame("the created answer", |frame| { + frame["requestId"] == "live-frames" + }) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + session.pane = created["sessionId"] + .as_str() + .expect("the created session id") + .to_string(); + session + } + + /// The fake writes `frames` back as the CLI side of a send. + async fn sidecar_writes(&mut self, frames: Value) { + self.st + .handle_send(send_msg(&self.pane, &frames.to_string())) + .await; + } + + /// Read frames until one matches `want` (returned) or 20 s pass. + async fn await_frame(&mut self, what: &str, want: impl Fn(&Value) -> bool) -> Value { + if let Some(hit) = self.frames.iter().find(|frame| want(frame)) { + return hit.clone(); + } + let found = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let frame = match self.rx.recv().await { + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(err) => panic!("broadcast recv failed: {err}"), + Ok(raw) => serde_json::from_str::(&raw).expect("a JSON frame"), + }; + self.frames.push(frame.clone()); + if want(&frame) { + return frame; + } + } + }) + .await; + found.unwrap_or_else(|_| panic!("no {what}; frames: {:?}", self.frames)) + } + + /// The first `freshAgent.event` whose inner event has this type. + async fn await_event(&mut self, event_type: &str) -> Value { + let what = format!("a {event_type} event"); + self.await_frame(&what, |frame| { + frame["type"] == "freshAgent.event" && frame["event"]["type"] == event_type + }) + .await + } + + /// The status the snapshot overlay states for the session. + async fn overlay_status(&self) -> Value { + let mut snapshot = json!({}); + self.st + .apply_snapshot_metadata(&self.pane, &mut snapshot) + .await; + snapshot["status"].clone() + } +} + +fn create_msg(request_id: &str) -> FreshAgentCreate { + FreshAgentCreate { + naming_handle: None, + observed_epoch: None, + observed_generation: None, + request_id: request_id.to_string(), + session_type: SessionType::Freshclaude, + provider: Some(freshell_protocol::AgentProvider::Claude), + cwd: None, + legacy_restore_context: None, + resume_session_id: None, + session_ref: None, + model: None, + model_selection: None, + permission_mode: None, + sandbox: None, + effort: None, + plugins: None, + tab_id: None, + } +} + +fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { + FreshAgentSend { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + text: text.to_string(), + cwd: None, + images: None, + request_id: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + } +} + +/// The captured records whose `event` field is `event`, in order. +fn records( + events: &Arc>>, + event: &str, +) -> Vec<(tracing::Level, capture::Fields)> { + events + .lock() + .expect("capture lock") + .iter() + .filter(|(_, fields)| fields.get("event").map(String::as_str) == Some(event)) + .cloned() + .collect() +} + +/// An API retry reaches every client as `freshAgent.retry`, stamped with the +/// pane's id, with the sidecar's fields intact. It is logged under the +/// pane's id, and it never ends the turn. +#[tokio::test] +async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_turn_running() { + let (events, _capture) = capture::install(); + let mut session = EchoSession::start(None).await; + session + .sidecar_writes(json!([ + { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, + { "type": "sdk.retry", "attempt": 1, "maxRetries": 10, "delayMs": 512, "errorStatus": 529, "error": "overloaded" }, + ])) + .await; + + let retry = session.await_event("freshAgent.retry").await; + assert_eq!(retry["sessionId"], json!(session.pane), "{retry}"); + assert_eq!(retry["provider"], "claude"); + assert_eq!(retry["sessionType"], "freshclaude"); + assert_eq!(retry["event"]["attempt"], 1); + assert_eq!(retry["event"]["maxRetries"], 10); + assert_eq!(retry["event"]["delayMs"], 512); + assert_eq!(retry["event"]["errorStatus"], 529); + assert_eq!(retry["event"]["error"], "overloaded"); + + assert_eq!(session.overlay_status().await, "running"); + let logged = records(&events, "freshagent.claude.api_retry"); + assert_eq!(logged.len(), 1, "{logged:?}"); + let (level, fields) = &logged[0]; + assert_eq!(*level, tracing::Level::INFO); + assert_eq!(fields.get("session_id"), Some(&session.pane)); + assert_eq!(fields.get("attempt").map(String::as_str), Some("1")); + assert_eq!(fields.get("error_status").map(String::as_str), Some("529")); + assert_eq!(fields.get("error").map(String::as_str), Some("overloaded")); +} + +/// A notice (rate limit, informational banner, failed compaction) reaches +/// every client as `freshAgent.notice` with its level, code, message and +/// rate limit. It is logged at a severity that follows its level, without +/// its text, and it never ends the turn. +#[tokio::test] +async fn a_notice_reaches_every_client_as_freshagent_notice_and_keeps_the_turn_running() { + let (events, _capture) = capture::install(); + let mut session = EchoSession::start(None).await; + let rate_limit = + json!({ "status": "rejected", "resetsAt": 1_792_656_000, "rateLimitType": "five_hour" }); + session + .sidecar_writes(json!([ + { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, + { "type": "sdk.notice", "level": "error", "code": "RATE_LIMITED", "message": "You have reached your Claude usage limit.", "rateLimit": rate_limit }, + { "type": "sdk.notice", "level": "info", "code": "INFORMATIONAL", "message": "a private hook said hello" }, + ])) + .await; + + let notice = session.await_event("freshAgent.notice").await; + assert_eq!(notice["sessionId"], json!(session.pane), "{notice}"); + assert_eq!(notice["event"]["level"], "error"); + assert_eq!(notice["event"]["code"], "RATE_LIMITED"); + assert_eq!( + notice["event"]["message"], + "You have reached your Claude usage limit." + ); + assert_eq!(notice["event"]["rateLimit"], rate_limit); + session + .await_frame("the informational notice", |frame| { + frame["event"]["type"] == "freshAgent.notice" + && frame["event"]["code"] == "INFORMATIONAL" + }) + .await; + + assert_eq!(session.overlay_status().await, "running"); + let logged = records(&events, "freshagent.claude.notice"); + let summary: Vec<(tracing::Level, Option<&str>)> = logged + .iter() + .map(|(level, fields)| (*level, fields.get("code").map(String::as_str))) + .collect(); + assert_eq!( + summary, + vec![ + (tracing::Level::WARN, Some("RATE_LIMITED")), + (tracing::Level::INFO, Some("INFORMATIONAL")), + ] + ); + let (_, rate_limited) = &logged[0]; + assert_eq!(rate_limited.get("session_id"), Some(&session.pane)); + assert_eq!( + rate_limited.get("rate_limit_status").map(String::as_str), + Some("rejected") + ); + let (_, informational) = &logged[1]; + assert_eq!( + informational.get("message_bytes").map(String::as_str), + Some("25") + ); + assert!( + informational + .values() + .all(|value| !value.contains("private hook")), + "a notice's text is never logged: {informational:?}" + ); +} + +/// A per-message transcript announcement passes through to every client +/// unchanged (only the `session-commands` reason is folded server-side). +#[tokio::test] +async fn a_transcript_announcement_reaches_every_client_as_session_changed() { + let mut session = EchoSession::start(None).await; + session + .sidecar_writes(json!([ + { "type": "sdk.session.changed", "reason": "transcript", "uuid": "11111111-1111-4111-8111-111111111111", "parentToolUseId": null }, + { "type": "sdk.session.changed", "reason": "transcript", "uuid": "22222222-2222-4222-8222-222222222222", "parentToolUseId": "toolu_agent1" }, + ])) + .await; + + let announced = session + .await_frame("the subagent's announcement", |frame| { + frame["event"]["type"] == "freshAgent.session.changed" + && frame["event"]["parentToolUseId"] == "toolu_agent1" + }) + .await; + assert_eq!(announced["sessionId"], json!(session.pane)); + assert_eq!(announced["event"]["reason"], "transcript"); + assert_eq!( + announced["event"]["uuid"], + "22222222-2222-4222-8222-222222222222" + ); + let main_thread: Vec<&Value> = session + .frames + .iter() + .filter(|frame| { + frame["event"]["type"] == "freshAgent.session.changed" + && frame["event"]["reason"] == "transcript" + }) + .collect(); + assert_eq!(main_thread.len(), 2, "{main_thread:?}"); + assert_eq!( + main_thread[0]["event"]["uuid"], + "11111111-1111-4111-8111-111111111111" + ); + assert_eq!(main_thread[0]["event"]["parentToolUseId"], Value::Null); +} diff --git a/crates/freshell-freshagent/src/claude_live_events.rs b/crates/freshell-freshagent/src/claude_live_events.rs new file mode 100644 index 000000000..7148f7a00 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_live_events.rs @@ -0,0 +1,94 @@ +//! The claude sidecar's non-terminal provider events (transcript-live plan +//! §4.2, principle P4): an API request the CLI retries (`sdk.retry`) and a +//! notice (`sdk.notice`: a rate limit, an informational banner, a failed +//! compaction). Both are broadcast to every client unchanged (as +//! `freshAgent.retry` / `freshAgent.notice`), and neither ends the turn or +//! touches the session's status. +//! +//! [`log_provider_event`] logs them under the id the pane knows the session +//! by. The sidecar logs the same events under its own id (its stderr records +//! are re-emitted as `freshagent.claude.sidecar_log`, G14); these records tie +//! them to the pane. Like the sidecar's records they carry ids, numbers and +//! codes only: a notice's text is logged by its size, because an +//! informational banner can carry user content. + +use serde_json::Value; + +const PROVIDER: &str = "claude"; + +/// Log one sidecar line if it is a retry or a notice; other lines are ignored. +/// `session_id` is the envelope stamp the broadcast carries. +pub(crate) fn log_provider_event(line: &Value, session_id: &str, session_type: &str) { + match line.get("type").and_then(Value::as_str) { + Some("sdk.retry") => log_retry(line, session_id, session_type), + Some("sdk.notice") => log_notice(line, session_id, session_type), + _ => {} + } +} + +fn log_retry(line: &Value, session_id: &str, session_type: &str) { + let attempt = line.get("attempt").and_then(Value::as_u64); + let max_retries = line.get("maxRetries").and_then(Value::as_u64); + let delay_ms = line.get("delayMs").and_then(Value::as_u64); + let error_status = line.get("errorStatus").and_then(Value::as_u64); + let error = line.get("error").and_then(Value::as_str).unwrap_or(""); + // Info, not warn: the sidecar's own record of the retry is the warning. + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.api_retry", + provider = PROVIDER, + session_id, + session_type, + attempt, + max_retries, + delay_ms, + error_status, + error, + "the claude CLI is retrying a failed API request" + ); +} + +fn log_notice(line: &Value, session_id: &str, session_type: &str) { + let level = line.get("level").and_then(Value::as_str).unwrap_or("info"); + let code = line.get("code").and_then(Value::as_str).unwrap_or(""); + let message_bytes = line + .get("message") + .and_then(Value::as_str) + .map_or(0, str::len); + let rate_limit = line.get("rateLimit"); + let rate_limit_field = |key: &str| { + rate_limit + .and_then(|limit| limit.get(key)) + .map(|value| match value { + Value::String(text) => text.clone(), + other => other.to_string(), + }) + }; + let rate_limit_status = rate_limit_field("status"); + let rate_limit_type = rate_limit_field("rateLimitType"); + let resets_at = rate_limit_field("resetsAt"); + // A provider notice is never a server fault: an error-level notice (a + // reached usage limit, a failed compaction) is a warning here. + macro_rules! notice { + ($macro:ident) => { + tracing::$macro!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.notice", + provider = PROVIDER, + session_id, + session_type, + level, + code, + message_bytes, + rate_limit_status = rate_limit_status.as_deref(), + rate_limit_type = rate_limit_type.as_deref(), + resets_at = resets_at.as_deref(), + "the claude CLI reported a notice" + ) + }; + } + match level { + "error" | "warning" => notice!(warn), + _ => notice!(info), + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 386ed76b9..13add9023 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -40,6 +40,7 @@ pub mod claude; pub(crate) mod claude_dead_sessions; pub(crate) mod claude_handshake; pub(crate) mod claude_launch; +pub(crate) mod claude_live_events; pub(crate) mod claude_projection; pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; From 9844a668da5a1e5ca5bf3a21b69021a3afde5d63 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:20:41 -0700 Subject: [PATCH 08/45] feat(claude): the CLI's own permission-mode change becomes the session's setting (C1) After an approved ExitPlanMode the CLI leaves plan mode by itself and the sidecar reports sdk.session.metadata {permissionMode}. The stdout consumer now folds that mode into the session's settings before the frame is broadcast, so a later configure or settings-bearing send that states the same mode writes nothing to the sidecar, and the snapshot's live settings overlay states the mode the CLI runs in. The change is logged as freshagent.claude.permission_mode_changed under the pane's id. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 13 +++ .../src/claude/live_frames_tests.rs | 83 +++++++++++++++++++ .../src/claude_live_events.rs | 28 +++++-- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index bc064e5f4..d63302b8d 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -8767,6 +8767,19 @@ impl FreshClaudeState { } } } + // The CLI changed its own permission mode (an approved + // ExitPlanMode returns it to the mode it had before plan + // mode). The sidecar already runs in it; the session's + // settings take it too, so a later configure stating the + // same mode applies nothing and the snapshot states it. + Some("sdk.session.metadata") => { + if let Some(mode) = value.get("permissionMode").and_then(Value::as_str) { + if let Some(session) = sessions.lock().await.get_mut(&session_id) { + session.configuration.settings.permission_mode = + Some(mode.to_string()); + } + } + } Some("sdk.configured") => { if let Some(session) = sessions.lock().await.get_mut(&session_id) { if session diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 45d73f451..5fc124341 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -43,6 +43,7 @@ struct EchoSession { rx: tokio::sync::broadcast::Receiver, /// The pane's id for the session (its map key and envelope id). pane: String, + configure_log: std::path::PathBuf, /// Every frame read off the bus so far, in order. frames: Vec, _dir: tempfile::TempDir, @@ -74,6 +75,7 @@ impl EchoSession { st, rx, pane: String::new(), + configure_log, frames: Vec::new(), _dir: dir, _env: env, @@ -132,6 +134,16 @@ impl EchoSession { .await } + /// The configure lines the fake received. + fn configures(&self) -> Vec { + std::fs::read_to_string(&self.configure_log) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("a configure line")) + .collect() + } + /// The status the snapshot overlay states for the session. async fn overlay_status(&self) -> Value { let mut snapshot = json!({}); @@ -180,6 +192,23 @@ fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { } } +fn configure_msg(session_id: &str, permission_mode: &str) -> FreshAgentConfigure { + FreshAgentConfigure { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + cwd: None, + request_id: Some("live-frames-configure".to_string()), + settings: freshell_protocol::FreshAgentSendSettings { + cwd: None, + model: None, + permission_mode: Some(permission_mode.to_string()), + sandbox: None, + effort: None, + }, + } +} + /// The captured records whose `event` field is `event`, in order. fn records( events: &Arc>>, @@ -334,3 +363,57 @@ async fn a_transcript_announcement_reaches_every_client_as_session_changed() { ); assert_eq!(main_thread[0]["event"]["parentToolUseId"], Value::Null); } + +/// The CLI changed its own permission mode (an approved ExitPlanMode): the +/// sidecar reports `sdk.session.metadata {permissionMode}`. Every client +/// receives it, and the session's settings take the new mode, so the next +/// configure (or settings-bearing send) that states the same mode applies +/// nothing, and the snapshot overlay states the new mode. +#[tokio::test] +async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { + let (events, _capture) = capture::install(); + let mut session = EchoSession::start(Some("plan")).await; + session + .sidecar_writes(json!([ + { "type": "sdk.session.metadata", "permissionMode": "default" }, + { "type": "sdk.result", "result": "success", "durationMs": 1, "costUsd": 0, "usage": {} }, + ])) + .await; + let metadata = session.await_event("freshAgent.session.metadata").await; + assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); + assert_eq!(metadata["event"]["permissionMode"], "default"); + session.await_event("freshAgent.result").await; + + let mut snapshot = json!({}); + session + .st + .apply_snapshot_metadata(&session.pane, &mut snapshot) + .await; + assert_eq!(snapshot["settings"]["permissionMode"], "default"); + let logged = records(&events, "freshagent.claude.permission_mode_changed"); + assert_eq!(logged.len(), 1, "{logged:?}"); + assert_eq!(logged[0].1.get("session_id"), Some(&session.pane)); + assert_eq!( + logged[0].1.get("permission_mode").map(String::as_str), + Some("default") + ); + + session + .st + .handle_configure(configure_msg(&session.pane, "default")) + .await; + assert_eq!( + session.configures(), + Vec::::new(), + "the session already runs in the mode the configure states" + ); + + // The configure lane itself still works: going back to plan applies. + session + .st + .handle_configure(configure_msg(&session.pane, "plan")) + .await; + let configures = session.configures(); + assert_eq!(configures.len(), 1, "{configures:?}"); + assert_eq!(configures[0]["settings"]["permissionMode"], "plan"); +} diff --git a/crates/freshell-freshagent/src/claude_live_events.rs b/crates/freshell-freshagent/src/claude_live_events.rs index 7148f7a00..17cb14b40 100644 --- a/crates/freshell-freshagent/src/claude_live_events.rs +++ b/crates/freshell-freshagent/src/claude_live_events.rs @@ -1,9 +1,11 @@ //! The claude sidecar's non-terminal provider events (transcript-live plan -//! §4.2, principle P4): an API request the CLI retries (`sdk.retry`) and a +//! §4.2, principle P4): an API request the CLI retries (`sdk.retry`), a //! notice (`sdk.notice`: a rate limit, an informational banner, a failed -//! compaction). Both are broadcast to every client unchanged (as -//! `freshAgent.retry` / `freshAgent.notice`), and neither ends the turn or -//! touches the session's status. +//! compaction) and the CLI's own change of permission mode +//! (`sdk.session.metadata {permissionMode}`). They are broadcast to every +//! client unchanged (as `freshAgent.retry` / `freshAgent.notice` / +//! `freshAgent.session.metadata`), and none ends the turn or touches the +//! session's status. //! //! [`log_provider_event`] logs them under the id the pane knows the session //! by. The sidecar logs the same events under its own id (its stderr records @@ -16,12 +18,26 @@ use serde_json::Value; const PROVIDER: &str = "claude"; -/// Log one sidecar line if it is a retry or a notice; other lines are ignored. -/// `session_id` is the envelope stamp the broadcast carries. +/// Log one sidecar line if it is a retry, a notice or a permission-mode +/// change; other lines are ignored. `session_id` is the envelope stamp the +/// broadcast carries. pub(crate) fn log_provider_event(line: &Value, session_id: &str, session_type: &str) { match line.get("type").and_then(Value::as_str) { Some("sdk.retry") => log_retry(line, session_id, session_type), Some("sdk.notice") => log_notice(line, session_id, session_type), + Some("sdk.session.metadata") => { + if let Some(permission_mode) = line.get("permissionMode").and_then(Value::as_str) { + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.permission_mode_changed", + provider = PROVIDER, + session_id, + session_type, + permission_mode, + "the claude CLI changed its own permission mode; the session's settings follow" + ); + } + } _ => {} } } From e7cb3934946a1734009403eae574f59341abfa32 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:25:51 -0700 Subject: [PATCH 09/45] feat(claude): settings metadata states the permission mode (C2) session_metadata_frame takes an optional permission mode, stated only when given; codex and opencode pass None, so their frames are unchanged. Claude's configure and settings-bearing send now diff the session's model, effort and permission mode around the apply, and broadcast a metadata frame stating all three when any changed. A configure that only switches into plan mode (or a send from a device that missed it) now converges every device's settings surfaces. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 92 +++++++++---------- .../src/claude/live_frames_tests.rs | 40 ++++++++ crates/freshell-freshagent/src/codex.rs | 2 + crates/freshell-freshagent/src/opencode_ws.rs | 2 + .../src/session_metadata.rs | 57 ++++++++++-- 5 files changed, 141 insertions(+), 52 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index d63302b8d..61dccb12a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -729,6 +729,15 @@ struct QuiesceVerdict { handed_compact_likely: bool, } +/// What a claude `freshAgent.session.metadata` frame states (see +/// [`FreshClaudeState::stated_settings`]). +#[derive(Debug, Default, PartialEq, Eq)] +struct StatedSettings { + model: Option, + effort: Option, + permission_mode: Option, +} + #[derive(Default)] struct ClaudeConfiguration { settings: crate::identity_sink::FreshAgentSettings, @@ -4626,15 +4635,7 @@ impl FreshClaudeState { // whose settings CHANGED the live session can converge every // device's model surfaces — the same frame `handle_configure` // emits (a device that missed the configure still lands here). - let settings_before = { - let guard = self.sessions.lock().await; - guard.get(&map_key).map(|s| { - ( - s.configuration.settings.model.clone(), - s.configuration.settings.effort.clone(), - ) - }) - }; + let settings_before = self.stated_settings(&map_key).await; if let Err(err) = self .configure_for_send(&map_key, settings, session_type) .await @@ -4642,24 +4643,13 @@ impl FreshClaudeState { self.send_error(&request_id, "CLAUDE_SETTINGS_FAILED", &err); return; } - let settings_after = { - let guard = self.sessions.lock().await; - guard.get(&map_key).map(|s| { - ( - s.configuration.settings.model.clone(), - s.configuration.settings.effort.clone(), - ) - }) - }; + let settings_after = self.stated_settings(&map_key).await; if settings_before != settings_after { - let (model, effort) = settings_after.unwrap_or((None, None)); - self.broadcast(&crate::session_metadata::session_metadata_frame( - PROVIDER, + self.broadcast_stated_settings( session_type, &session_id, - model.as_deref(), - effort.as_deref(), - )); + settings_after.unwrap_or_default(), + ); } } // Task 4 review (C1b): the destroy target comes from POST-lock session @@ -4962,15 +4952,7 @@ impl FreshClaudeState { ); } - let before = { - let guard = self.sessions.lock().await; - guard.get(&map_key).map(|s| { - ( - s.configuration.settings.model.clone(), - s.configuration.settings.effort.clone(), - ) - }) - }; + let before = self.stated_settings(&map_key).await; if let Err(err) = self .configure_for_send(&map_key, &msg.settings, session_type) .await @@ -4980,28 +4962,46 @@ impl FreshClaudeState { self.emit_fresh_agent_error(&session_id, session_type, "CLAUDE_SETTINGS_FAILED", &err); return; } - let after = { - let guard = self.sessions.lock().await; - guard.get(&map_key).map(|s| { - ( - s.configuration.settings.model.clone(), - s.configuration.settings.effort.clone(), - ) - }) - }; + let after = self.stated_settings(&map_key).await; if before == after { // Idempotent configure: nothing to converge. return; } - let (model, effort) = after.unwrap_or((None, None)); // Broadcast keyed at the CLIENT-ADDRESSED id (the id the configure // named): the client's fold resolves the sessions-map entry by it. + self.broadcast_stated_settings(session_type, &session_id, after.unwrap_or_default()); + } + + /// The settings a `freshAgent.session.metadata` frame states for the + /// session under `map_key` (`None` when it is gone): model, effort and + /// permission mode. The configure and send paths diff these around an + /// apply and broadcast only a change. + async fn stated_settings(&self, map_key: &str) -> Option { + let guard = self.sessions.lock().await; + guard.get(map_key).map(|session| { + let settings = &session.configuration.settings; + StatedSettings { + model: settings.model.clone(), + effort: settings.effort.clone(), + permission_mode: settings.permission_mode.clone(), + } + }) + } + + /// Converge every device's settings surfaces on `stated`. + fn broadcast_stated_settings( + &self, + session_type: &str, + session_id: &str, + stated: StatedSettings, + ) { self.broadcast(&crate::session_metadata::session_metadata_frame( PROVIDER, session_type, - &session_id, - model.as_deref(), - effort.as_deref(), + session_id, + stated.model.as_deref(), + stated.effort.as_deref(), + stated.permission_mode.as_deref(), )); } diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 5fc124341..5c08258b6 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -417,3 +417,43 @@ async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { assert_eq!(configures.len(), 1, "{configures:?}"); assert_eq!(configures[0]["settings"]["permissionMode"], "plan"); } + +/// A configure that changes only the permission mode converges every +/// device: the metadata broadcast states the new mode (with the unchanged +/// model and effort pair, as every metadata frame does). +#[tokio::test] +async fn a_configure_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { + let mut session = EchoSession::start(None).await; + session + .st + .handle_configure(configure_msg(&session.pane, "plan")) + .await; + assert_eq!(session.configures().len(), 1); + + let metadata = session.await_event("freshAgent.session.metadata").await; + assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); + assert_eq!(metadata["event"]["permissionMode"], "plan"); + assert_eq!(metadata["event"]["model"], Value::Null); + assert_eq!(metadata["event"]["effort"], Value::Null); +} + +/// The same convergence for a send whose settings change the mode (a device +/// that missed the configure still lands here). +#[tokio::test] +async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { + let mut session = EchoSession::start(Some("plan")).await; + let mut send = send_msg(&session.pane, "[]"); + send.settings = Some(freshell_protocol::FreshAgentSendSettings { + cwd: None, + model: None, + permission_mode: Some("acceptEdits".to_string()), + sandbox: None, + effort: None, + }); + session.st.handle_send(send).await; + assert_eq!(session.configures().len(), 1); + + let metadata = session.await_event("freshAgent.session.metadata").await; + assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); + assert_eq!(metadata["event"]["permissionMode"], "acceptEdits"); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e23d3802d..c1b43b274 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -3702,6 +3702,7 @@ impl FreshCodexState { &session_id, Some(model.as_str()), effort.as_deref(), + None, )); } @@ -4035,6 +4036,7 @@ impl FreshCodexState { &session_id, Some(model.as_str()), effort.as_deref(), + None, )); } diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 0c70cf52a..5b3b6c2b8 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -2721,6 +2721,7 @@ impl FreshOpencodeState { &busy_session_id, model.as_deref(), effort.as_deref(), + None, )); } @@ -3193,6 +3194,7 @@ impl FreshOpencodeState { &session_id, model.as_deref(), effort.as_deref(), + None, )); } diff --git a/crates/freshell-freshagent/src/session_metadata.rs b/crates/freshell-freshagent/src/session_metadata.rs index 97c838968..5e7a96ff7 100644 --- a/crates/freshell-freshagent/src/session_metadata.rs +++ b/crates/freshell-freshagent/src/session_metadata.rs @@ -28,6 +28,12 @@ //! client keeps whatever it has — which is exactly what an older server's //! silence degrades to. //! +//! Claude also states `permissionMode`, a live setting of its session: its +//! configure and send paths broadcast when the model, effort or permission +//! mode changed, and the stdout consumer relays the sidecar's own +//! `{permissionMode}` frame when the CLI changes mode by itself (an approved +//! ExitPlanMode). Codex and opencode never state it (the key is absent). +//! //! The envelope is stamped with the CLIENT-ADDRESSED session id (the id the //! configure/send named), never an internal alias: the client's fold resolves //! the sessions-map entry by that id, and alias-aware resolution (claude's @@ -41,20 +47,29 @@ use serde_json::json; /// `model` / `effort` are the post-apply EFFECTIVE values the event states /// (`None` serializes as JSON null — an explicit clear). Broadcast only on an /// actual change: the slices diff their session record around the apply. +/// +/// `permission_mode` is stated only when `Some` (claude, whose permission +/// mode is a live session setting); `None` leaves the key out — no +/// statement — which is what codex and opencode send. pub fn session_metadata_frame( provider: &str, session_type: &str, session_id: &str, model: Option<&str>, effort: Option<&str>, + permission_mode: Option<&str>, ) -> ServerMessage { + let mut event = json!({ + "type": "freshAgent.session.metadata", + "sessionId": session_id, + "model": model, + "effort": effort, + }); + if let Some(permission_mode) = permission_mode { + event["permissionMode"] = json!(permission_mode); + } ServerMessage::FreshAgentEvent(FreshAgentEvent { - event: json!({ - "type": "freshAgent.session.metadata", - "sessionId": session_id, - "model": model, - "effort": effort, - }), + event, provider: provider.to_string(), session_id: session_id.to_string(), session_type: session_type.to_string(), @@ -73,6 +88,7 @@ mod tests { "thread-1", Some("gpt-5.6-luna"), None, + None, ); let ServerMessage::FreshAgentEvent(inner) = &frame else { panic!("metadata rides the freshAgent.event envelope"); @@ -87,6 +103,34 @@ mod tests { assert_eq!(inner.event["effort"], serde_json::Value::Null); } + #[test] + fn metadata_frame_states_a_permission_mode_only_when_given() { + let stated = session_metadata_frame( + "claude", + "freshclaude", + "pane-1", + Some("opus"), + None, + Some("plan"), + ); + let ServerMessage::FreshAgentEvent(stated) = &stated else { + panic!("metadata rides the freshAgent.event envelope"); + }; + assert_eq!(stated.event["permissionMode"], "plan"); + + // No statement: the key is absent, so the client keeps what it has. + let silent = + session_metadata_frame("codex", "freshcodex", "thread-1", Some("m"), None, None); + let ServerMessage::FreshAgentEvent(silent) = &silent else { + panic!("metadata rides the freshAgent.event envelope"); + }; + assert!( + silent.event.get("permissionMode").is_none(), + "{:?}", + silent.event + ); + } + #[test] fn metadata_frame_survives_a_wire_roundtrip() { let frame = session_metadata_frame( @@ -95,6 +139,7 @@ mod tests { "ses_1", Some("prov/mdl"), Some("low"), + None, ); let wire = serde_json::to_string(&frame).expect("serializes"); let back: serde_json::Value = serde_json::from_str(&wire).expect("parses"); From 0d17b6a5b4ab4b062df0f7f14b8029ba790b370a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:30:55 -0700 Subject: [PATCH 10/45] test(claude-sim): replay recorded real-CLI turns; scripted API retries Two program steps for the CLI simulator, so rigs over the real sidecar can run rich turns without the real CLI: - replay plays the model's side of a turn the pinned CLI 2.1.237 really ran (the real-cli-2.1.237 recordings): its SDK messages with the recorded spacing, each persisted line into the session's transcript on the simulator's ~100 ms flush (F7), and each subagent's transcript and meta.json (written when its Agent call starts, F5). Every recorded id is minted afresh per replay and the recording's paths become the session's. holdBeforeMs keeps the turn before a recorded message (a slow tool), so a test can look at the session mid-turn. An interrupt during a replay fails the stream loudly (not modeled). - api_retry fails the model request N times with a status: one system/api_retry per attempt with the SDK's fields (F11), then the CLI's backoff, then the next step. Self-tests compare a replay with its recording (messages, transcript lines and subagent files, ids and paths masked), check a hold leaves the subagent's tool call on disk and running, and pin the api_retry shape. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/program.mjs | 8 +- test/fixtures/claude-cli-sim/query.mjs | 42 ++- test/fixtures/claude-cli-sim/replay.mjs | 292 ++++++++++++++++++ test/fixtures/claude-cli-sim/transcript.mjs | 10 + .../providers/claude-cli-sim.test.ts | 128 ++++++++ 5 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/claude-cli-sim/replay.mjs diff --git a/test/fixtures/claude-cli-sim/program.mjs b/test/fixtures/claude-cli-sim/program.mjs index 3e45e9f83..a213dbd54 100644 --- a/test/fixtures/claude-cli-sim/program.mjs +++ b/test/fixtures/claude-cli-sim/program.mjs @@ -25,6 +25,12 @@ // exit_plan { plan? } ExitPlanMode through canUseTool // completion { subtype?, text?, errors? } end the turn with this result subtype // api_error { status?, message? } the model request fails +// api_retry { status?, attempts? } the model request fails `attempts` times +// (default 1) with `status` (default 529) +// and the CLI retries: one system/api_retry +// per failure, after its backoff the next step +// replay { recording, holdBeforeMs?, pace? } the model's side of a turn the +// pinned CLI really ran (replay.mjs) // hang {} the model request never completes // crash { signal? | code? } the CLI process dies (default SIGKILL); // with code 0 the stream ends with no error @@ -50,7 +56,7 @@ export const PROGRAM_ALIAS_ENV = 'FRESHELL_CLAUDE_SDK_SCRIPT' export const EMISSION_KINDS = new Set([ 'text', 'activity', 'tool', 'approval', 'question', 'exit_plan', 'completion', - 'api_error', 'hang', 'crash', 'stall_stdin', 'cli_turn', 'marker', + 'api_error', 'api_retry', 'replay', 'hang', 'crash', 'stall_stdin', 'cli_turn', 'marker', ]) function parseProgram(text, source) { diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 01e1bd92b..9409b9ade 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -24,10 +24,12 @@ // // The model's side of each turn comes from prompt markers // (prompt-markers.mjs, shared with the real-CLI loopback provider) or from a -// scenario program (program.mjs). Layout: this module owns the session (boot, +// scenario program (program.mjs). A program can also retry failed API +// requests (api_retry, F11) and replay the model's side of a turn the pinned +// CLI really ran, with its transcript and subagent files (replay.mjs). Layout: this module owns the session (boot, // input, the command queue, turns, interrupts, results); tools.mjs runs tool -// calls; local-commands.mjs runs slash commands; transcript.mjs persists the -// JSONL; sdk-shapes.mjs holds the recorded catalog and fixed shapes. +// calls; local-commands.mjs runs slash commands; replay.mjs plays recorded +// turns; transcript.mjs persists the JSONL; sdk-shapes.mjs holds the recorded catalog and fixed shapes. // // Knobs (read from options.env, then process.env): // FRESHELL_CLAUDE_SIM_TIME_SCALE multiply every simulated delay (default 1); @@ -64,6 +66,7 @@ import { fileURLToPath } from 'node:url' import { HANG_MCP_QUALIFIED_TOOL, parsePromptMarkers, PLAN_TEXT, SLOW_DELAY_MS } from './prompt-markers.mjs' import { loadProgram, ProgramEngine } from './program.mjs' import { runLocalCommand } from './local-commands.mjs' +import { replayTurn } from './replay.mjs' import { CATALOG, CAVEAT, @@ -103,6 +106,15 @@ function parseSlash(text) { return match ? { name: match[1], args: (match[2] ?? '').trim() } : null } +/** The SDK's error kind for a failed API request's HTTP status (null: no response). */ +function apiErrorKind(status) { + if (status === 529) return 'overloaded' + if (status === 429) return 'rate_limit' + if (status === 401) return 'authentication_failed' + if (status !== null && status >= 500) return 'server_error' + return 'unknown' +} + /** The model steps a prompt's markers ask for (see prompt-markers.mjs). */ function markerSteps(text) { const marker = parsePromptMarkers(text) @@ -627,6 +639,12 @@ class SimulatedCli { return this.finishTurn(turn, { subtype: step.data.subtype ?? 'success', text: step.data.text, errors: step.data.errors }) case 'api_error': return this.apiError(turn, step.data) + case 'api_retry': + if (await this.apiRetries(turn, step.data)) return this.abortBeforeContent(turn) + break + case 'replay': + if (await replayTurn(this, turn, step.data) === 'dead') return undefined + break case 'hang': this.emitStream(this.messageStart()) await this.wait(Number.POSITIVE_INFINITY, turn) @@ -777,6 +795,24 @@ class SimulatedCli { return this.finishTurn(turn, { subtype: 'success', isError: true, terminalReason: 'api_error', stopReason: 'stop_sequence', text, apiErrorStatus: status, lifecycle: 'cancelled' }) } + /** + * The model request fails `attempts` times with `status` and the CLI + * retries each (F11; live only, never in the transcript): a + * system/api_retry, then the CLI's backoff. Returns true when the turn was + * aborted meanwhile. + */ + async apiRetries(turn, data) { + const status = data.status === null ? null : Number(data.status ?? 529) + const attempts = Math.max(1, Number(data.attempts ?? 1)) + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const delay = Math.min(500 * 2 ** (attempt - 1), 32_000) + this.emit({ type: 'system', subtype: 'api_retry', attempt, max_retries: 10, retry_delay_ms: delay, error_status: status, error: apiErrorKind(status) }) + if (await this.wait(delay, turn)) return true + turn.requests += 1 + } + return false + } + async abortBeforeContent(turn) { return this.abortStreaming(turn, '') } diff --git a/test/fixtures/claude-cli-sim/replay.mjs b/test/fixtures/claude-cli-sim/replay.mjs new file mode 100644 index 000000000..54c238595 --- /dev/null +++ b/test/fixtures/claude-cli-sim/replay.mjs @@ -0,0 +1,292 @@ +// The `replay` program step of the CLI simulator: the model's side of one +// turn the pinned CLI 2.1.237 really ran, played back into a simulated +// session. The recordings live in test/fixtures/fresh-agent/claude/ +// real-cli-2.1.237//, captured by +// test/integration/providers/claude-capture-fixtures.mjs against the local +// fake endpoint: +// +// sdk.jsonl the SDK messages, {t, msg}, in order +// session.jsonl the transcript the CLI wrote +// session/subagents/agent-.jsonl each subagent's transcript +// session/subagents/agent-.meta.json {agentType, description, toolUseId, spawnDepth} +// +// The step plays the recording's first turn: every SDK message after the +// turn's prompt replay up to (not including) its result, with the recorded +// spacing. Each message that the CLI persisted takes its transcript line +// (and the lines without a uuid before it) into the simulator's transcript, +// which lands about 100 ms later (F7); a subagent's lines go to its own file +// the same way, and its meta.json is written when its Agent call starts +// (F5). The simulator frames the turn as usual: system/init, the status, +// the prompt replay and its transcript line before the step, the result +// after it. So the session gets real Write/Edit results (diffs), a real +// synchronous subagent, real usage blocks and thinking, where the +// simulator's own steps have only text, Bash and a background agent. +// +// Every id of the recording is minted afresh for each replay (uuids, the +// tool_use, message and agent ids), so a session can replay a recording +// more than once and two sessions never share ids; the recorded session id +// becomes the session's own, and the recording's workspace (/fixture/ws) +// and config directory become the session's. +// +// data: { +// recording: '' (a directory under real-cli-2.1.237) or an absolute path, +// holdBeforeMs: { '': ms } extra time before that message +// (a slow tool, so a test can look mid-turn), +// pace: n multiply the recorded spacing (default 1), +// } +// Times scale with FRESHELL_CLAUDE_SIM_TIME_SCALE like every simulated delay. +// An interrupt during a replay is not modeled: it fails the stream loudly. + +import fs from 'node:fs' +import path from 'node:path' +import { randomBytes, randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { projectSlug } from './transcript.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +export const RECORDINGS = path.resolve(here, '../fresh-agent/claude/real-cli-2.1.237') + +/** The capture tool's placeholders for the recording machine's paths. */ +const RECORDED_CWD = '/fixture/ws' +const RECORDED_CONFIG_DIR = '/fixture/home/.claude' + +/** Transcript lines that belong to the prompt, which the simulator writes itself. */ +const PROMPT_BOOKKEEPING = new Set(['queue-operation', 'last-prompt']) + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g + +/** The API message ids and tool_use ids one recorded message or line names. */ +function apiIdsOf(value, out) { + const message = value.message ?? value.event?.message + if (typeof message?.id === 'string') out.add(message.id) + const blocks = [...(Array.isArray(value.message?.content) ? value.message.content : []), value.event?.content_block] + for (const block of blocks) if (block?.type === 'tool_use' && typeof block.id === 'string') out.add(block.id) +} + +/** A regex matching any of `ids` as a whole word (longest first). */ +const wordsRe = (ids) => new RegExp(`\\b(?:${[...ids].sort((a, b) => b.length - a.length).map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'g') + +const readJsonl = (file) => fs.readFileSync(file, 'utf8').split('\n').filter((text) => text.trim()) + +const recordings = new Map() + +/** A recording, parsed once per process. */ +export function loadRecording(name) { + const dir = path.isAbsolute(name) ? name : path.join(RECORDINGS, name) + const cached = recordings.get(dir) + if (cached) return cached + const rows = readJsonl(path.join(dir, 'sdk.jsonl')).map((text) => { + const row = JSON.parse(text) + return { t: row.t, msg: row.msg, text: JSON.stringify(row.msg) } + }) + const prompt = rows.findIndex((row) => row.msg.type === 'user' && row.msg.isReplay === true && !row.msg.parent_tool_use_id) + const result = rows.findIndex((row, index) => index > prompt && row.msg.type === 'result') + if (prompt < 0 || result < 0) throw new Error(`the recording ${dir} has no complete turn (a prompt replay, then a result)`) + const modelSide = rows.slice(prompt + 1, result) + const persisted = new Set(modelSide.map((row) => row.msg.uuid).filter(Boolean)) + const lines = (file) => readJsonl(file).map((text) => ({ line: JSON.parse(text), text })) + const main = lines(path.join(dir, 'session.jsonl')) + const first = main.findIndex(({ line }) => persisted.has(line.uuid)) + const subagentDir = path.join(dir, 'session', 'subagents') + const subagents = fs.existsSync(subagentDir) + ? fs.readdirSync(subagentDir) + .map((file) => /^agent-([A-Za-z0-9]+)\.meta\.json$/.exec(file)?.[1]) + .filter(Boolean) + .sort() + .map((agentId) => { + const metaText = fs.readFileSync(path.join(subagentDir, `agent-${agentId}.meta.json`), 'utf8') + return { agentId, metaText, toolUseId: JSON.parse(metaText).toolUseId, lines: lines(path.join(subagentDir, `agent-${agentId}.jsonl`)) } + }) + : [] + const apiIds = new Set() + for (const row of modelSide) apiIdsOf(row.msg, apiIds) + for (const { line } of [...main, ...subagents.flatMap((subagent) => subagent.lines)]) apiIdsOf(line, apiIds) + const recording = { + dir, + apiIds, + sessionId: modelSide.find((row) => row.msg.session_id)?.msg.session_id ?? rows[0]?.msg.session_id, + startT: rows[prompt].t, + modelSide, + // From the first line a replayed message persisted: what comes before it + // is the prompt and its context, which the simulator wrote itself. + main: first < 0 ? [] : main.slice(first).filter(({ line }) => !PROMPT_BOOKKEEPING.has(line.type)), + subagents, + } + recordings.set(dir, recording) + return recording +} + +/** Fresh ids (and the session's paths) for one replay of a recording. */ +class Remap { + constructor(cli, recording) { + this.uuids = new Map([[recording.sessionId, cli.sessionId]]) + const token = randomBytes(3).toString('hex') + this.apiIds = new Map([...recording.apiIds].map((id) => [id, `${id}r${token}`])) + this.apiIdsRe = recording.apiIds.size ? wordsRe(recording.apiIds) : null + this.agentIds = new Map(recording.subagents.map(({ agentId }) => [agentId, `a${randomBytes(8).toString('hex')}`])) + const jsonText = (value) => JSON.stringify(value).slice(1, -1) + this.paths = [ + [RECORDED_CONFIG_DIR, jsonText(cli.configDir)], + [RECORDED_CWD, jsonText(cli.cwd)], + [projectSlug(RECORDED_CWD), projectSlug(cli.cwd)], + ] + } + + uuid(recorded) { + if (!this.uuids.has(recorded)) this.uuids.set(recorded, randomUUID()) + return this.uuids.get(recorded) + } + + agentId(recorded) { + return this.agentIds.get(recorded) ?? recorded + } + + /** One recorded JSON text with every id and path replaced. */ + text(recorded) { + let text = recorded.replace(UUID_RE, (id) => this.uuid(id)) + if (this.apiIdsRe) text = text.replace(this.apiIdsRe, (id) => this.apiIds.get(id)) + for (const [from, to] of this.agentIds) text = text.split(from).join(to) + for (const [from, to] of this.paths) text = text.split(from).join(to) + return text + } + + json(recorded) { + return JSON.parse(this.text(recorded)) + } +} + +/** A JSONL file the CLI appends to, flushed about 100 ms after each write (F7). */ +class LaggedFile { + constructor(cli, file) { + this.cli = cli + this.file = file + this.pending = [] + this.timer = null + } + + append(texts) { + this.pending.push(...texts) + if (this.timer || this.pending.length === 0) return + this.timer = setTimeout(() => this.flush(), this.cli.ms(100)) + this.cli.timers.add(this.timer) + } + + flush() { + if (this.timer) { + clearTimeout(this.timer) + this.cli.timers.delete(this.timer) + this.timer = null + } + // A dead CLI writes nothing more. + if (this.cli.dead || this.pending.length === 0) return + const texts = this.pending.splice(0) + fs.mkdirSync(path.dirname(this.file), { recursive: true }) + fs.appendFileSync(this.file, texts.map((text) => `${text}\n`).join('')) + } +} + +/** + * One file's recorded lines, written in order: `through(uuid)` writes every + * line up to and including the one with that (recorded) uuid. + */ +class LinePlayer { + constructor(lines, write) { + this.lines = lines + this.next = 0 + this.write = write + } + + has(uuid) { + return this.lines.some(({ line }, index) => index >= this.next && line.uuid === uuid) + } + + through(uuid) { + const end = this.lines.findIndex(({ line }, index) => index >= this.next && line.uuid === uuid) + if (end < 0) return + this.write(this.lines.slice(this.next, end + 1)) + this.next = end + 1 + } + + rest() { + this.write(this.lines.slice(this.next)) + this.next = this.lines.length + } +} + +/** The tool_use ids a (recorded) assistant message starts. */ +const toolUseIdsOf = (msg) => (msg.type === 'assistant' && Array.isArray(msg.message?.content) + ? msg.message.content.filter((block) => block?.type === 'tool_use').map((block) => block.id) + : []) + +/** + * Play the recording's turn into `turn` (see the header). Returns 'done', or + * 'dead' when the CLI died meanwhile (a dead CLI says nothing more). + */ +export async function replayTurn(cli, turn, data = {}) { + if (!data.recording) throw new Error('a `replay` step needs data.recording') + const recording = loadRecording(data.recording) + const remap = new Remap(cli, recording) + const pace = Number(data.pace ?? 1) + const holds = data.holdBeforeMs ?? {} + + // The main transcript: each replayed line continues the session's chain + // (a recorded parent the replay did not write is the session's last line). + const written = new Set() + const main = new LinePlayer(recording.main, (lines) => { + for (const { text } of lines) { + const line = remap.json(text) + if (typeof line.parentUuid === 'string' && !written.has(line.parentUuid)) line.parentUuid = cli.transcript.lastUuid + if (typeof line.uuid === 'string') written.add(line.uuid) + cli.transcript.appendVerbatim(line) + } + }) + const subagentDir = path.join(cli.transcript.file.replace(/\.jsonl$/, ''), 'subagents') + const subagents = recording.subagents.map((subagent) => { + const name = `agent-${remap.agentId(subagent.agentId)}` + const file = new LaggedFile(cli, path.join(subagentDir, `${name}.jsonl`)) + const state = { subagent, file, metaWritten: false } + state.writeMeta = () => { + if (state.metaWritten || cli.dead) return + state.metaWritten = true + fs.mkdirSync(subagentDir, { recursive: true }) + fs.writeFileSync(path.join(subagentDir, `${name}.meta.json`), remap.text(subagent.metaText)) + } + state.player = new LinePlayer(subagent.lines, (lines) => { + state.writeMeta() + file.append(lines.map(({ text }) => remap.text(text))) + }) + return state + }) + + let previousT = recording.startT + for (const row of recording.modelSide) { + const gap = Math.max(0, row.t - previousT) * pace + Number(holds[row.msg.uuid] ?? 0) + previousT = row.t + if (gap > 0 && await cli.wait(gap, turn)) { + if (cli.dead) return 'dead' + throw new Error('an interrupt during a replayed turn is not modeled') + } + if (cli.dead) return 'dead' + // The Agent call starts its subagent: the CLI writes the meta file at spawn. + for (const id of toolUseIdsOf(row.msg)) subagents.find((s) => s.subagent.toolUseId === id)?.writeMeta() + if (row.msg.type !== 'stream_event' || cli.partial) cli.emit(remap.json(row.text)) + const uuid = row.msg.uuid + if (!uuid) continue + if (main.has(uuid)) main.through(uuid) + else subagents.find((s) => s.player.has(uuid))?.player.through(uuid) + } + main.rest() + for (const { player, file } of subagents) { + player.rest() + file.flush() + } + + const topLevel = recording.modelSide.map((row) => row.msg).filter((msg) => !msg.parent_tool_use_id) + turn.requests += topLevel.filter((msg) => msg.type === 'system' && msg.subtype === 'status' && msg.status === 'requesting').length + const lastText = topLevel.filter((msg) => msg.type === 'assistant') + .flatMap((msg) => msg.message?.content ?? []) + .filter((block) => block?.type === 'text') + .at(-1)?.text + if (lastText !== undefined) turn.lastText = lastText + return 'done' +} diff --git a/test/fixtures/claude-cli-sim/transcript.mjs b/test/fixtures/claude-cli-sim/transcript.mjs index 6f89ef983..ee60e9e21 100644 --- a/test/fixtures/claude-cli-sim/transcript.mjs +++ b/test/fixtures/claude-cli-sim/transcript.mjs @@ -93,6 +93,16 @@ export class Transcript { return uuid } + /** + * Append a line exactly as given (a replayed real-CLI line, replay.mjs). + * A line with a uuid extends the chain like any other. + */ + appendVerbatim(line) { + this.pending.push(line) + if (typeof line.uuid === 'string') this.lastUuid = line.uuid + this.schedule() + } + /** Append a bookkeeping line with no uuid (queue operations). */ appendRaw(fields) { this.pending.push({ ...fields, timestamp: new Date().toISOString(), sessionId: this.sessionId }) diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index ad61d4a6e..01de92137 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -385,6 +385,134 @@ describe('Claude CLI simulator programs', () => { }) }) +describe('Claude CLI simulator api_retry steps', () => { + it('each failed attempt is one system/api_retry with the SDK\'s fields, before the reply streams; the turn then succeeds', async () => { + const s = await open({ includePartialMessages: true }, program([{ on: 'msg:prompt', emit: [{ kind: 'api_retry', data: { status: 529, attempts: 2 } }, { kind: 'text', data: { text: 'recovered' } }] }])) + s.prompt.push('try') + await s.until(() => results(s.messages).length === 1) + const retries = s.messages.filter((m) => m.type === 'system' && m.subtype === 'api_retry') + // SDKAPIRetryMessage (sdk.d.ts) and F11: attempt, max_retries, retry_delay_ms, error_status, error. + expect(retries.map((m) => Object.keys(m).sort())).toEqual(Array(2).fill(['attempt', 'error', 'error_status', 'max_retries', 'retry_delay_ms', 'session_id', 'subtype', 'type', 'uuid'])) + expect(retries.map((m) => [m.attempt, m.max_retries, m.error_status, m.error])).toEqual([[1, 10, 529, 'overloaded'], [2, 10, 529, 'overloaded']]) + expect(retries[1].retry_delay_ms).toBeGreaterThan(retries[0].retry_delay_ms) + const firstStream = s.messages.findIndex((m) => m.type === 'stream_event') + expect(s.messages.indexOf(retries[1])).toBeLessThan(firstStream) + expect(results(s.messages)[0]).toMatchObject({ subtype: 'success', is_error: false, result: 'recovered' }) + }) +}) + +const TOOLS_SYNC = path.join(REPO_ROOT, 'test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync') +const UUID_TEXT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g + +const jsonlFile = async (file: string) => (await fs.readFile(file, 'utf8')).split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line)) + +/** A recorded or replayed value with its ids and the session's paths masked, for comparison. */ +function masked(value: unknown, home?: { cwd: string, env: { CLAUDE_CONFIG_DIR: string } }): unknown { + let text = JSON.stringify(value) + if (home) { + const inJson = (p: string) => JSON.stringify(p).slice(1, -1) + text = text.split(inJson(home.env.CLAUDE_CONFIG_DIR)).join('/fixture/home/.claude').split(inJson(home.cwd)).join('/fixture/ws') + // The CLI's project slug of the workspace (transcript.mjs projectSlug). + .split(home.cwd.replace(/[^A-Za-z0-9]/g, '-')).join('-fixture-ws') + } + return JSON.parse(text + .replace(UUID_TEXT, 'UUID') + .replace(/\b(toolu|msg)_[A-Za-z0-9]+/g, '$1_ID') + .replace(/\ba[0-9a-f]{16}\b/g, 'AGENT_ID')) +} + +/** The model's side of the first turn: the messages after its prompt replay, up to its result. */ +function modelSide(messages: any[]) { + const prompt = messages.findIndex((m) => m.type === 'user' && m.isReplay === true && !m.parent_tool_use_id) + const result = messages.findIndex((m, index) => index > prompt && m.type === 'result') + return messages.slice(prompt + 1, result) +} + +async function transcriptOf(home: { env: { CLAUDE_CONFIG_DIR: string } }) { + const tree = await snapshotTree(path.join(home.env.CLAUDE_CONFIG_DIR, 'projects')) + const main = Object.keys(tree).find((file) => file.endsWith('.jsonl') && !file.includes('/subagents/')) + return { tree, main } +} + +describe('Claude CLI simulator replay of a recorded real-CLI turn', () => { + it('replays the recording\'s messages, transcript lines and subagent files, under fresh ids and the session\'s paths', async () => { + const recorded = (await jsonlFile(path.join(TOOLS_SYNC, 'sdk.jsonl'))).map((row) => row.msg) + const s = await open({ includePartialMessages: true }, program([{ on: 'msg:prompt', emit: [{ kind: 'replay', data: { recording: 'tools-sync' } }] }])) + s.prompt.push('GO write then edit then delegate') + await s.until(() => results(s.messages).length === 1) + + const replayed = modelSide(s.messages) + expect(replayed.map((m) => masked(m, s.home))).toEqual(modelSide(recorded).map((m) => masked(m))) + // Fresh ids: none of the recording's, and as many distinct ones. + const uuidsOf = (value: unknown) => new Set(JSON.stringify(value).match(UUID_TEXT)) + const recordedUuids = uuidsOf(modelSide(recorded)) + const replayedUuids = uuidsOf(replayed) + expect([...replayedUuids].filter((id) => recordedUuids.has(id))).toEqual([]) + expect(replayedUuids.size).toBe(recordedUuids.size) + expect(replayed.every((m) => m.session_id === s.messages[0].session_id)).toBe(true) + expect(results(s.messages)[0]).toMatchObject({ subtype: 'success', result: 'All done.' }) + + // Every persisted message's line lands in the session's transcript, in the + // recorded order, after the simulator's own prompt lines. + const persisted = replayed.filter((m) => m.uuid && m.type !== 'stream_event' && (m.type === 'assistant' || m.type === 'user')) + let tree: Record = {} + let main: string | undefined + for (let i = 0; i < 100; i += 1) { + ({ tree, main } = await transcriptOf(s.home)) + const text = main ? tree[main] : '' + if (persisted.filter((m) => !m.parent_tool_use_id).every((m) => text.includes(m.uuid))) break + await new Promise((resolve) => setTimeout(resolve, 20)) + } + const lines = tree[main!].split('\n').filter(Boolean).map((line) => JSON.parse(line)) + const firstReplayed = lines.findIndex((line) => line.uuid === persisted[0].uuid) + expect(firstReplayed).toBeGreaterThan(0) + const recordedLines = (await jsonlFile(path.join(TOOLS_SYNC, 'session.jsonl'))) + const recordedFirst = recordedLines.findIndex((line) => line.uuid && modelSide(recorded).some((m) => m.uuid === line.uuid)) + expect(lines.slice(firstReplayed).map((line) => masked(line, s.home))) + .toEqual(recordedLines.slice(recordedFirst).filter((line) => line.type !== 'last-prompt').map((line) => masked(line))) + // The replayed lines continue the session's own chain. + expect(lines[firstReplayed].parentUuid).toBe(lines.slice(0, firstReplayed).filter((line) => line.uuid).at(-1).uuid) + + // The subagent's transcript and meta file, next to the session's transcript. + const sessionDir = main!.replace(/\.jsonl$/, '') + const subagentFiles = Object.keys(tree).filter((file) => file.startsWith(`${sessionDir}/subagents/`)).sort() + expect(subagentFiles.map((file) => path.basename(file).replace(/a[0-9a-f]{16}/, 'AGENT_ID'))).toEqual(['agent-AGENT_ID.jsonl', 'agent-AGENT_ID.meta.json']) + const agentCall = replayed.flatMap((m) => (m.type === 'assistant' ? m.message.content : [])).find((block: any) => block.type === 'tool_use' && block.name === 'Agent') + const meta = JSON.parse(tree[subagentFiles[1]]) + expect(meta.toolUseId).toBe(agentCall.id) + const recordedSubagent = path.join(TOOLS_SYNC, 'session/subagents/agent-a2645ac0e225b257e') + expect(masked(meta)).toEqual(masked(JSON.parse(await fs.readFile(`${recordedSubagent}.meta.json`, 'utf8')))) + expect(tree[subagentFiles[0]].split('\n').filter(Boolean).map((line) => masked(JSON.parse(line), s.home))) + .toEqual((await jsonlFile(`${recordedSubagent}.jsonl`)).map((line) => masked(line))) + }) + + it('a hold keeps the turn before that recorded message: the subagent\'s tool call is on disk and still running', async () => { + const SUBAGENT_TOOL_RESULT = '1ee90712-3476-4188-b310-22a881bf6e20' + const s = await open({ includePartialMessages: true }, { ...program([{ on: 'msg:prompt', emit: [{ kind: 'replay', data: { recording: 'tools-sync', holdBeforeMs: { [SUBAGENT_TOOL_RESULT]: 3000 } } }] }]), FRESHELL_CLAUDE_SIM_TIME_SCALE: '1' }) + s.prompt.push('GO') + const subagentToolCall = () => s.messages.find((m) => m.type === 'assistant' && m.parent_tool_use_id && m.message.content.some((b: any) => b.type === 'tool_use')) + await s.until(() => Boolean(subagentToolCall())) + const subagentFile = async () => { + const { tree } = await transcriptOf(s.home) + const file = Object.keys(tree).find((name) => name.includes('/subagents/') && name.endsWith('.jsonl')) + return file ? tree[file].split('\n').filter(Boolean).map((line) => JSON.parse(line)) : [] + } + let lines: any[] = [] + for (let i = 0; i < 100 && !lines.some((line) => line.uuid === subagentToolCall().uuid); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)) + lines = await subagentFile() + } + expect(lines.some((line) => line.uuid === subagentToolCall().uuid)).toBe(true) + const isToolResult = (line: any) => Array.isArray(line.message?.content) && line.message.content.some((b: any) => b.type === 'tool_result') + expect(lines.filter(isToolResult)).toEqual([]) + expect(results(s.messages)).toEqual([]) + + await s.until(() => results(s.messages).length === 1, 10_000) + for (let i = 0; i < 100 && !(await subagentFile()).some(isToolResult); i += 1) await new Promise((resolve) => setTimeout(resolve, 20)) + expect((await subagentFile()).filter(isToolResult)).toHaveLength(1) + }) +}) + describe('the real sidecar over the simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE)', () => { const children: ChildProcess[] = [] afterEach(() => { From 9c50b920cf8d5069bba6d3e5348e244687786972 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:40:43 -0700 Subject: [PATCH 11/45] fix(claude): snapshots read the transcript store the lane's sessions write The snapshot route and the session host's capture read transcripts from this process's CLAUDE_CONFIG_DIR/HOME, while a lane whose sidecars get an explicit environment (the RealSidecarSim/RealSidecarCli rigs) has its CLI write under that environment's store, so a rig's snapshot answered 404 for a live session. The lane now names its store (FreshClaudeState::transcript_store_roots: the sidecar environment's roots when one is configured, else this process's) and every snapshot read goes through FreshClaudeState::transcript_snapshot. Production, which configures no sidecar environment, reads the same store as before. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 70 ++++++++++++++----- .../src/claude/live_frames_tests.rs | 43 ++++++++++++ .../src/claude_snapshot.rs | 17 +++-- crates/freshell-freshagent/src/snapshot.rs | 24 +++---- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 61dccb12a..ef49eb771 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -862,6 +862,45 @@ impl FreshClaudeState { self } + /// The transcript store roots this lane's CLI writes to: the ones the + /// configured sidecar environment names (a test rig's isolated HOME / + /// CLAUDE_CONFIG_DIR), else this process's + /// ([`crate::claude_snapshot::claude_home_candidates`]). Snapshots read + /// from here, so they always read the store the sessions write. + pub(crate) fn transcript_store_roots(&self) -> Vec { + match self.sidecar_environment.as_deref() { + None => crate::claude_snapshot::claude_home_candidates(), + Some(environment) => { + let var = |key: &str| { + environment + .iter() + .rev() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }; + let home = var("HOME").map(std::path::PathBuf::from); + crate::claude_snapshot::claude_home_candidates_from(var, home) + } + } + } + + /// The snapshot built from `thread_id`'s transcript in + /// [`Self::transcript_store_roots`], without the live overlays. + pub(crate) async fn transcript_snapshot( + &self, + session_type: &str, + thread_id: &str, + rollback: Option<&crate::rollback_record::RollbackRecord>, + ) -> Result { + crate::claude_snapshot::get_claude_snapshot_in( + &self.transcript_store_roots(), + session_type, + thread_id, + rollback, + ) + .await + } + /// Test seam (see [`crate::claude_test_holds`]): every later session /// registration waits `hold` between starting the session's consumer and /// inserting its record, the way a production resume create awaits its @@ -5314,20 +5353,17 @@ impl FreshClaudeState { thread_id: &str, ) -> Result { let rollback = self.load_rollback_record(thread_id).await; - let mut snapshot = crate::claude_snapshot::get_claude_snapshot( - session_type_str(session_type), - thread_id, - rollback.as_ref(), - ) - .await - .map_err(|error| match error { - crate::claude_snapshot::ClaudeSnapshotError::NotFound => { - "claude session not found".to_string() - } - crate::claude_snapshot::ClaudeSnapshotError::Io(_) => { - "claude snapshot unavailable".to_string() - } - })?; + let mut snapshot = self + .transcript_snapshot(session_type_str(session_type), thread_id, rollback.as_ref()) + .await + .map_err(|error| match error { + crate::claude_snapshot::ClaudeSnapshotError::NotFound => { + "claude session not found".to_string() + } + crate::claude_snapshot::ClaudeSnapshotError::Io(_) => { + "claude snapshot unavailable".to_string() + } + })?; let (approvals, questions) = self.snapshot_pending_overlay(thread_id).await; crate::claude_snapshot::apply_pending_overlay(&mut snapshot, approvals, questions); self.apply_snapshot_metadata(thread_id, &mut snapshot).await; @@ -5335,9 +5371,9 @@ impl FreshClaudeState { } /// Test seam (`test-support`, C09 rigs): the live-state overlay the snapshot - /// route applies ([`Self::apply_snapshot_metadata`]), over an empty snapshot. A - /// rig cannot read a transcript through its server (the store root comes from - /// this process's environment), so it reads the overlay directly. + /// route applies ([`Self::apply_snapshot_metadata`]), over an empty snapshot, + /// for a check that needs no transcript (the overlay alone, before the + /// session wrote one). #[cfg(any(test, feature = "test-support"))] pub async fn live_snapshot_overlay(&self, any_id: &str) -> Value { let mut snapshot = json!({}); diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 5c08258b6..860857a93 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -457,3 +457,46 @@ async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it( assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); assert_eq!(metadata["event"]["permissionMode"], "acceptEdits"); } + +/// A lane whose sidecars get an explicit environment (a test rig) serves +/// snapshots from the transcript store that environment names, the store its +/// CLI writes, not from this process's. +#[tokio::test] +async fn snapshots_read_the_store_the_lanes_sidecars_write() { + let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; + let process_store = tempfile::tempdir().expect("this process's store"); + std::env::set_var("CLAUDE_CONFIG_DIR", process_store.path()); + let sidecar_store = tempfile::tempdir().expect("the sidecars' store"); + let thread = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let project = sidecar_store.path().join("projects").join("-ws"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join(format!("{thread}.jsonl")), + format!( + "{}\n", + json!({ + "type": "user", "uuid": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "sessionId": thread, "message": { "role": "user", "content": "hello" }, + }) + ), + ) + .unwrap(); + let (tx, _rx) = tokio::sync::broadcast::channel::(16); + let st = FreshClaudeState::new(Arc::new(tx)).with_sidecar_environment([ + ( + "CLAUDE_CONFIG_DIR".to_string(), + sidecar_store.path().to_string_lossy().into_owned(), + ), + ("HOME".to_string(), "/nonexistent-home".to_string()), + ]); + + let snapshot = st + .get_snapshot(SessionType::Freshclaude, thread) + .await + .expect("the snapshot reads the sidecars' store"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert_eq!( + snapshot["turns"][0]["items"][0]["text"], "hello", + "{snapshot}" + ); +} diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index 6773a7dd4..7b20eb25b 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -1742,9 +1742,9 @@ pub(crate) fn build_claude_snapshot_from_projection( snapshot } -/// Locate + read + build. `revision` = transcript mtime in ms (monotonic as the file -/// grows -- `mergeSnapshotForDisplay` DROPS revision regressions), fallback turn count; -/// kata 1wxv Task 5 floors it at the rollback record's `lastOpAtMs`. +/// [`get_claude_snapshot_in`] over this process's store roots (tests that +/// stage a store through `CLAUDE_CONFIG_DIR`). +#[cfg(test)] pub(crate) async fn get_claude_snapshot( session_type: &str, thread_id: &str, @@ -1753,9 +1753,14 @@ pub(crate) async fn get_claude_snapshot( get_claude_snapshot_in(&claude_home_candidates(), session_type, thread_id, rollback).await } -/// [`get_claude_snapshot`] over explicit store roots (the production caller -/// passes [`claude_home_candidates`]; tests pass temp roots, no env mutation). -async fn get_claude_snapshot_in( +/// Locate + read + build, over explicit store roots (the claude lane passes the +/// roots its sidecars write to, +/// [`crate::claude::FreshClaudeState::transcript_store_roots`]; tests pass +/// temp roots, no env mutation). `revision` = transcript mtime in ms +/// (monotonic as the file grows -- `mergeSnapshotForDisplay` DROPS revision +/// regressions), fallback turn count; kata 1wxv Task 5 floors it at the +/// rollback record's `lastOpAtMs`. +pub(crate) async fn get_claude_snapshot_in( roots: &[PathBuf], session_type: &str, thread_id: &str, diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 7f480846d..8e28d0893 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -38,7 +38,7 @@ //! the typed ownership refusals or the EMPTY-FROM-DISK snapshot and never //! spawn (cold resume only via the explicit lifecycle); while //! **freshclaude/claude** and **kilroy/claude** are a -//! disk+env adapter ([`crate::claude_snapshot::get_claude_snapshot`]) that reads the CLI's +//! disk adapter ([`crate::claude::FreshClaudeState::transcript_snapshot`]) that reads the CLI's //! own transcript store directly (`/projects/*/.jsonl`) — no sidecar //! required, so snapshots survive a server restart. When the session is LIVE, the route //! overlays its folded pending approvals/questions onto the disk-built JSON and flips the @@ -125,13 +125,11 @@ impl SnapshotState { .flatten()?, ("freshclaude" | "kilroy", "claude") => { let rollback = self.claude.load_rollback_record(native_id).await; - let saved = crate::claude_snapshot::get_claude_snapshot( - session_type, - native_id, - rollback.as_ref(), - ) - .await - .ok()?; + let saved = self + .claude + .transcript_snapshot(session_type, native_id, rollback.as_ref()) + .await + .ok()?; crate::native_history::readonly_snapshot(provider, saved).ok()? } ("freshopencode", "opencode") => { @@ -325,12 +323,10 @@ async fn get_snapshot( // floor). The record is ledger-sync (memory-fast) and resolves by the // DURABLE id even when no session is live (durable+multi-client truth). let rollback = state.claude.load_rollback_record(&thread_id).await; - match crate::claude_snapshot::get_claude_snapshot( - &session_type, - &thread_id, - rollback.as_ref(), - ) - .await + match state + .claude + .transcript_snapshot(&session_type, &thread_id, rollback.as_ref()) + .await { Ok(mut snapshot) => { // Task 3 (reload-while-pending): overlay the session's LIVE pending From 397f1a511a34a47e5a2bf8baa6facd61bf347352 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:40:43 -0700 Subject: [PATCH 12/45] test(claude): live rendering over the real sidecar, end to end (C3) New freshagent_claude_live_rendering suite on the RealSidecarSim rig, which now also serves the snapshot REST route (ClaudeRig::snapshot): - a replayed real-CLI turn (Write, Edits, Read, a synchronous subagent) streams frames that each name their API message, assistant frames that name their transcript line, and exactly one transcript announcement per persisted message, every one of which lands on disk; - mid-turn, the REST snapshot already shows the running delegation and its subagent's running Read; after the turn it shows the completed delegation, the recording's changed files and its token usage; - two overloaded attempts arrive as freshAgent.retry frames before the reply; an approved ExitPlanMode reaches the client as metadata (permissionMode default) and the snapshot's live settings follow; a failed /compact is a COMPACTION_FAILED notice with no error; a configure back to plan broadcasts metadata stating it. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-ws/tests/common/claude_rig.rs | 63 ++- .../tests/freshagent_claude_live_rendering.rs | 487 ++++++++++++++++++ 2 files changed, 544 insertions(+), 6 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_live_rendering.rs diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index ee0987bd6..063db2f81 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -13,7 +13,8 @@ //! key. It never contacts the real API. //! //! Each rig's server is wired like production: one ownership coordinator shared by -//! the WS state and the claude lane (main.rs does the same). +//! the WS state and the claude lane (main.rs does the same), and the fresh-agent +//! snapshot REST route ([`ClaudeRig::snapshot`]) beside the WS endpoint. //! //! Each rig gets a throwaway HOME / CLAUDE_CONFIG_DIR / workspace, and its server's //! [`freshell_freshagent::FreshClaudeState`] gives every sidecar exactly the rig's @@ -31,7 +32,7 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -61,6 +62,8 @@ pub struct ClaudeRig { pub kind: RigKind, /// `ws://127.0.0.1:/ws` of the rig's server (an ephemeral loopback port). pub url: String, + /// The same server's HTTP address (the snapshot REST route). + pub http: std::net::SocketAddr, /// The isolated workspace every session of this rig runs in. pub workspace: PathBuf, /// CLAUDE_CONFIG_DIR of the CLI (simulated or real): its transcripts land here. @@ -162,7 +165,7 @@ impl ClaudeRig { environment.push(((*key).into(), (*value).into())); } - let (url, fresh_claude) = spawn_rig_server(environment).await; + let (url, http, fresh_claude) = spawn_rig_server(environment).await; eprintln!( "{}", json!({ @@ -173,6 +176,7 @@ impl ClaudeRig { Self { kind, url, + http, workspace, claude_config_dir, ledger, @@ -244,6 +248,40 @@ impl ClaudeRig { } } + /// `GET /api/fresh-agent/threads/freshclaude/claude/` (the route the + /// browser's pane fetches its transcript from): the status and the JSON body. + pub async fn snapshot(&self, thread_id: &str) -> (u16, Value) { + let mut stream = tokio::net::TcpStream::connect(self.http) + .await + .expect("connect to the rig's HTTP address"); + let request = format!( + "GET /api/fresh-agent/threads/freshclaude/claude/{thread_id} HTTP/1.1\r\n\ + Host: 127.0.0.1\r\nx-auth-token: {AUTH_TOKEN}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .expect("send the snapshot request"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("read the snapshot response"); + let text = String::from_utf8(response).expect("a utf-8 response"); + let (head, body) = text + .split_once("\r\n\r\n") + .expect("an HTTP response with a body"); + let status = head + .split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .expect("an HTTP status code"); + ( + status, + serde_json::from_str(body.trim()).unwrap_or(Value::Null), + ) + } + /// The simulator's ledger rows of one kind (`query`, `boot`, `prompt`, /// `can_use_tool`, `control`, `emission`); see `test/fixtures/claude-cli-sim/query.mjs`. pub fn ledger_rows(&self, kind: &str) -> Vec { @@ -540,7 +578,11 @@ async fn start_local_provider() -> (tokio::process::Child, String) { /// agents enabled and a claude state whose sidecars get exactly `environment`. async fn spawn_rig_server( environment: Vec<(String, String)>, -) -> (String, freshell_freshagent::FreshClaudeState) { +) -> ( + String, + std::net::SocketAddr, + freshell_freshagent::FreshClaudeState, +) { let mut settings_value = test_settings_value(); settings_value["freshAgent"]["enabled"] = json!(true); let auth_token = Arc::new(AUTH_TOKEN.to_string()); @@ -613,7 +655,16 @@ async fn spawn_rig_server( ownership: Some(ownership), }; - let router = freshell_ws::router(state); + let snapshots = freshell_freshagent::SnapshotState::new( + Arc::clone(&auth_token), + state.fresh_codex.clone(), + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + lane.clone(), + ); + let router = freshell_ws::router(state).merge(freshell_freshagent::snapshot::router(snapshots)); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind an ephemeral loopback port"); @@ -621,5 +672,5 @@ async fn spawn_rig_server( tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); - (format!("ws://{addr}/ws"), lane) + (format!("ws://{addr}/ws"), addr, lane) } diff --git a/crates/freshell-ws/tests/freshagent_claude_live_rendering.rs b/crates/freshell-ws/tests/freshagent_claude_live_rendering.rs new file mode 100644 index 000000000..1c76d8132 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_live_rendering.rs @@ -0,0 +1,487 @@ +//! Live rendering of a freshclaude turn, end to end over the real sidecar +//! (transcript-live plan C3): the real `index.mjs` over the CLI simulator +//! (RealSidecarSim, `common::claude_rig`), a real freshell-ws server, its +//! snapshot REST route, and WS clients. +//! +//! The rich turn is a replay of a turn the pinned CLI 2.1.237 really ran +//! (the simulator's `replay` step over the `tools-sync` recording): thinking, +//! streamed text, Write, two Edits (one failing), a Read and a synchronous +//! subagent (`Agent`) that itself reads a file. The replay holds before the +//! subagent's tool result, which is how a test sees the turn mid-way. +//! +//! What a client must get, per the plan's wire contract (§4.2): +//! - every `freshAgent.stream` frame names the API message it belongs to +//! (`messageId`), and every `freshAgent.assistant` names its transcript +//! line (`uuid`); +//! - one `freshAgent.session.changed {reason:'transcript'}` per message the +//! CLI persists, and each such message does reach the transcript; +//! - mid-turn, the REST snapshot already shows the running delegation with +//! its subagent's running tool call; +//! - after the turn, the snapshot carries the changed files and the token +//! usage; +//! - API retries, notices and the CLI's own permission-mode change reach +//! the client, and a configure that changes the permission mode states it. + +mod common; + +use std::collections::{BTreeSet, HashMap}; +use std::path::Path; +use std::time::Duration; + +use serde_json::{json, Value}; + +use common::claude_rig::{repo_root, ClaudeRig, RigConnection, RigKind, RIG_FRAME_BUDGET}; + +/// The recording the rich turn replays. +const RECORDING: &str = "tools-sync"; +/// The recorded uuid of the subagent's tool result: the replay holds before +/// it, so the subagent's Read call stays running for a while. +const SUBAGENT_TOOL_RESULT: &str = "1ee90712-3476-4188-b310-22a881bf6e20"; +const REPLAY_PROMPT: &str = "replay the recorded tools turn"; +const RETRY_PROMPT: &str = "answer after two overloaded attempts"; + +async fn rig() -> ClaudeRig { + let program = json!({ "rules": [ + { "on": "msg:prompt", "match": { "text": REPLAY_PROMPT }, "emit": [ + { "kind": "replay", "data": { + "recording": RECORDING, + "holdBeforeMs": { SUBAGENT_TOOL_RESULT: 4000 }, + } }, + ] }, + { "on": "msg:prompt", "match": { "text": RETRY_PROMPT }, "emit": [ + { "kind": "api_retry", "data": { "status": 529, "attempts": 2 } }, + { "kind": "text", "data": { "text": "recovered" } }, + ] }, + ] }) + .to_string(); + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]).await +} + +/// The `freshAgent.event`s of one inner type this connection read for `session_id`. +fn events<'a>(conn: &'a RigConnection, session_id: &str, event_type: &str) -> Vec<&'a Value> { + conn.frames + .iter() + .filter(|frame| { + frame["type"] == "freshAgent.event" + && frame["sessionId"] == session_id + && frame["event"]["type"] == event_type + }) + .collect() +} + +/// The durable claude id the session's `session.init` named. +fn durable_id(conn: &RigConnection, session_id: &str) -> String { + events(conn, session_id, "freshAgent.session.init") + .last() + .and_then(|frame| frame["event"]["cliSessionId"].as_str()) + .expect("the session reported its durable id") + .to_string() +} + +/// Poll the snapshot route until `ready` holds (the transcript lands about +/// 100 ms after each message, F7). +async fn snapshot_when( + rig: &ClaudeRig, + thread_id: &str, + what: &str, + ready: impl Fn(&Value) -> bool, +) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let (status, snapshot) = rig.snapshot(thread_id).await; + if status == 200 && ready(&snapshot) { + return snapshot; + } + assert!( + tokio::time::Instant::now() < deadline, + "the snapshot never showed {what}; last answer {status}: {snapshot}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Every item of every turn of a snapshot. +fn items(snapshot: &Value) -> Vec<&Value> { + snapshot["turns"] + .as_array() + .into_iter() + .flatten() + .flat_map(|turn| turn["items"].as_array().into_iter().flatten()) + .collect() +} + +fn delegation(snapshot: &Value) -> Option<&Value> { + items(snapshot) + .into_iter() + .find(|item| item["kind"] == "task_delegation") +} + +/// The uuids of every line in the session's transcripts (main and subagents). +fn persisted_uuids(rig: &ClaudeRig) -> BTreeSet { + fn collect(dir: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if path.is_dir() { + collect(&path, out); + } else if path.extension().is_some_and(|ext| ext == "jsonl") { + let text = std::fs::read_to_string(&path).unwrap_or_default(); + for line in text.lines() { + if let Ok(value) = serde_json::from_str::(line) { + if let Some(uuid) = value["uuid"].as_str() { + out.insert(uuid.to_string()); + } + } + } + } + } + } + let mut out = BTreeSet::new(); + collect(&rig.claude_config_dir.join("projects"), &mut out); + out +} + +/// The replayed recording's changed-file rows, with the session's workspace. +fn recorded_diffs(workspace: &Path) -> Value { + let golden: Value = serde_json::from_str( + &std::fs::read_to_string( + repo_root() + .join("test/fixtures/fresh-agent/claude/real-cli-2.1.237") + .join(RECORDING) + .join("snapshot.golden.json"), + ) + .expect("the recording's golden snapshot"), + ) + .expect("the golden parses"); + let workspace = workspace.to_str().expect("a utf-8 workspace"); + serde_json::from_str( + &golden["diffs"] + .to_string() + .replace("/fixture/ws", workspace), + ) + .expect("the rewritten rows parse") +} + +fn recorded_token_usage() -> Value { + let golden: Value = serde_json::from_str( + &std::fs::read_to_string( + repo_root() + .join("test/fixtures/fresh-agent/claude/real-cli-2.1.237") + .join(RECORDING) + .join("snapshot.golden.json"), + ) + .expect("the recording's golden snapshot"), + ) + .expect("the golden parses"); + golden["tokenUsage"].clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_rich_turn_renders_live_and_its_snapshot_shows_the_delegation_the_diffs_and_the_usage() { + let rig = rig().await; + let mut conn = rig.connect().await; + let session_id = conn.create("live-rich").await; + + conn.send_text(&session_id, "send-rich", REPLAY_PROMPT) + .await; + + // Mid-turn: the subagent's tool call has been announced (its transcript + // line is on its way); the REST snapshot shows the running delegation + // with the running call, before the turn's result. + let subagent_call = conn + .wait_for("the subagent's tool call", |frame| { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.assistant" + && frame["event"]["parentToolUseId"].is_string() + && frame["event"]["content"] + .as_array() + .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use")) + }) + .await; + let agent_tool_use_id = subagent_call["event"]["parentToolUseId"] + .as_str() + .expect("the subagent's parent tool call") + .to_string(); + let durable = durable_id(&conn, &session_id); + let mid_turn = snapshot_when( + &rig, + &durable, + "the running delegation with its running tool call", + |snapshot| { + delegation(snapshot).is_some_and(|item| { + item["status"] == "running" + && item["activity"] + .as_array() + .is_some_and(|rows| rows.iter().any(|row| row["status"] == "running")) + }) + }, + ) + .await; + assert!( + events(&conn, &session_id, "freshAgent.result").is_empty(), + "the snapshot was taken mid-turn" + ); + let running = delegation(&mid_turn).expect("the delegation"); + assert_eq!(running["description"], "check file", "{running}"); + assert_eq!( + running["activity"], + json!([{ + "tool": "Read", + "status": "running", + "preview": rig.workspace.join("a.txt").to_str().unwrap(), + }]), + "{running}" + ); + + conn.wait_for("the rich turn's result", |frame| { + frame["sessionId"] == session_id.as_str() && frame["event"]["type"] == "freshAgent.result" + }) + .await; + + // Streams: only main-thread frames, each naming its API message; the + // message_start names the message every later frame of it carries. + let streams = events(&conn, &session_id, "freshAgent.stream"); + assert!(streams.len() > 10, "{} stream frames", streams.len()); + let mut started: Vec = Vec::new(); + for frame in &streams { + let message_id = frame["event"]["messageId"] + .as_str() + .unwrap_or_else(|| panic!("a stream frame without its messageId: {frame}")); + if frame["event"]["event"]["type"] == "message_start" { + assert_eq!( + frame["event"]["event"]["message"]["id"], message_id, + "{frame}" + ); + started.push(message_id.to_string()); + } else { + assert_eq!( + started.last().map(String::as_str), + Some(message_id), + "a stream frame of a message that did not start last: {frame}" + ); + } + assert_eq!(frame["event"]["parentToolUseId"], Value::Null, "{frame}"); + } + + // Assistant messages name their transcript line; the main thread's + // name the API message their stream started. + let assistants = events(&conn, &session_id, "freshAgent.assistant"); + assert!( + assistants.len() >= 8, + "{} assistant frames", + assistants.len() + ); + for frame in &assistants { + assert!(frame["event"]["uuid"].is_string(), "{frame}"); + if frame["event"]["parentToolUseId"].is_null() { + let message_id = frame["event"]["messageId"].as_str().expect("its messageId"); + assert!(started.iter().any(|id| id == message_id), "{frame}"); + } + } + + // One transcript announcement per persisted message: no repeats, every + // assistant message announced, and every announced message on disk. + let announced: Vec = events(&conn, &session_id, "freshAgent.session.changed") + .into_iter() + .filter(|frame| frame["event"]["reason"] == "transcript") + .map(|frame| { + frame["event"]["uuid"] + .as_str() + .expect("an announcement names its uuid") + .to_string() + }) + .collect(); + let distinct: BTreeSet = announced.iter().cloned().collect(); + assert_eq!(distinct.len(), announced.len(), "{announced:?}"); + for frame in &assistants { + let uuid = frame["event"]["uuid"].as_str().unwrap(); + assert!( + distinct.contains(uuid), + "assistant {uuid} was not announced" + ); + } + let subagent_announcements = events(&conn, &session_id, "freshAgent.session.changed") + .into_iter() + .filter(|frame| frame["event"]["parentToolUseId"] == agent_tool_use_id.as_str()) + .count(); + assert!(subagent_announcements >= 2, "{subagent_announcements}"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let persisted = persisted_uuids(&rig); + let missing: Vec<&String> = distinct.difference(&persisted).collect(); + if missing.is_empty() { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "announced but never persisted: {missing:?}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // After the turn: the completed delegation, the changed files and the usage. + let done = snapshot_when(&rig, &durable, "the completed delegation", |snapshot| { + delegation(snapshot).is_some_and(|item| item["status"] == "completed") + }) + .await; + let completed = delegation(&done).unwrap(); + assert_eq!(completed["result"], "SUBAGENT-DONE: file has two"); + assert_eq!(completed["activity"][0]["status"], "completed"); + assert_eq!( + done["diffs"], + recorded_diffs(&rig.workspace), + "{}", + done["diffs"] + ); + assert_eq!(done["capabilities"]["diffs"], true); + assert_eq!(done["tokenUsage"], recorded_token_usage()); + let announced_assistants: HashMap<&str, ()> = assistants + .iter() + .filter_map(|frame| frame["event"]["uuid"].as_str()) + .map(|uuid| (uuid, ())) + .collect(); + let line_uuids = done["extensions"]["claude"]["transcriptLineUuids"] + .as_array() + .expect("the snapshot lists its newest line uuids"); + assert!( + line_uuids + .iter() + .filter_map(Value::as_str) + .any(|uuid| announced_assistants.contains_key(uuid)), + "the landing check's uuids include the turn's messages" + ); + + conn.kill(&session_id).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn retries_notices_and_permission_mode_changes_reach_the_client() { + let rig = rig().await; + let mut conn = rig.connect().await; + let created = conn + .request_create("live-status", json!({ "permissionMode": "plan" })) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let session_id = created["sessionId"].as_str().unwrap().to_string(); + + // Two overloaded attempts, each a retry frame, before the reply streams. + send_and_wait(&mut conn, &session_id, "send-retry", RETRY_PROMPT).await; + let retries = events(&conn, &session_id, "freshAgent.retry"); + let attempts: Vec<(Value, Value, Value)> = retries + .iter() + .map(|frame| { + ( + frame["event"]["attempt"].clone(), + frame["event"]["errorStatus"].clone(), + frame["event"]["error"].clone(), + ) + }) + .collect(); + assert_eq!( + attempts, + vec![ + (json!(1), json!(529), json!("overloaded")), + (json!(2), json!(529), json!("overloaded")), + ] + ); + let position = |wanted: &Value| conn.frames.iter().position(|frame| frame == wanted); + let first_stream = events(&conn, &session_id, "freshAgent.stream")[0].clone(); + assert!(position(retries[1]) < position(&first_stream)); + assert!( + events(&conn, &session_id, "freshAgent.error").is_empty(), + "a retry is not an error" + ); + + // ExitPlanMode, approved: the CLI leaves plan mode by itself, every + // client hears it, and the snapshot's live settings follow. + conn.send_text(&session_id, "send-plan", "PLAN:exit").await; + let request = conn.wait_for_event("freshAgent.permission.request").await; + conn.send(json!({ + "type": "freshAgent.approval.respond", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": request["event"]["requestId"], + "decision": { "behavior": "allow" }, + })) + .await; + let metadata = conn + .wait_for("the CLI's permission-mode change", |frame| { + frame["sessionId"] == session_id.as_str() + && frame["event"]["type"] == "freshAgent.session.metadata" + && frame["event"]["permissionMode"] == "default" + }) + .await; + assert!(metadata["event"].get("model").is_none(), "{metadata}"); + wait_for_results(&mut conn, &session_id, 2).await; + let durable = durable_id(&conn, &session_id); + snapshot_when(&rig, &durable, "the default permission mode", |snapshot| { + snapshot["settings"]["permissionMode"] == "default" + }) + .await; + + // A failed compaction is a notice, not an error, and the turn ends normally. + send_and_wait( + &mut conn, + &session_id, + "send-compact", + "/compact APIERR:400", + ) + .await; + let notices = events(&conn, &session_id, "freshAgent.notice"); + assert_eq!(notices.len(), 1, "{notices:?}"); + assert_eq!(notices[0]["event"]["level"], "error"); + assert_eq!(notices[0]["event"]["code"], "COMPACTION_FAILED"); + assert!( + notices[0]["event"]["message"] + .as_str() + .is_some_and(|message| message.contains("400")), + "{}", + notices[0] + ); + assert!(events(&conn, &session_id, "freshAgent.error").is_empty()); + + // Back to plan mode through a configure: its metadata states the mode. + conn.send(json!({ + "type": "freshAgent.configure", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "configure-plan", + "settings": { "permissionMode": "plan" }, + })) + .await; + conn.wait_for("the configure's metadata", |frame| { + frame["sessionId"] == session_id.as_str() + && frame["event"]["type"] == "freshAgent.session.metadata" + && frame["event"]["permissionMode"] == "plan" + }) + .await; + let control = rig.ledger_rows("control"); + assert!( + control + .iter() + .any(|row| row["name"] == "setPermissionMode" && row["args"] == json!(["plan"])), + "the configure reached the CLI: {control:?}" + ); + + conn.kill(&session_id).await; +} + +/// Send `text` and wait for the turn's result. +async fn send_and_wait(conn: &mut RigConnection, session_id: &str, request_id: &str, text: &str) { + let before = events(conn, session_id, "freshAgent.result").len(); + conn.send_text(session_id, request_id, text).await; + wait_for_results(conn, session_id, before + 1).await; +} + +/// Wait until this connection has read `count` results for the session. +async fn wait_for_results(conn: &mut RigConnection, session_id: &str, count: usize) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while events(conn, session_id, "freshAgent.result").len() < count { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for result {count} of {session_id}" + ); + conn.read_for(Duration::from_millis(100)).await; + } +} From e105bbcea1d530ce3c033d421db939a9c42fc840 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:50:24 -0700 Subject: [PATCH 13/45] perf(claude): snapshots project only the transcript lines appended since the last read (C4) A live pane now refetches after every message the CLI persists, on every connected device, and each refetch re-read and re-projected the whole transcript. A process-wide cache (claude_projection_cache, 8 transcripts, least recently read dropped) keeps each transcript's projector, the offset it consumed (always after a newline) and the 4 KiB before it; a read parses only the complete lines after that offset. A file that is new to the cache, shorter than the offset, rewritten before it, or replaced by another file (inode) is parsed in full. An unterminated last line is never consumed: a tail that already parses counts for that read only, so every read equals a full parse of the file as it stands. Subagent activity is still attached per read, so a growing subagent transcript shows while the main one is unchanged. Reads stay on spawn_blocking; each is logged at debug with what it parsed. Co-Authored-By: Claude Opus 5.5 --- .../src/claude_projection_cache.rs | 308 ++++++++++++++++ .../src/claude_projection_cache_tests.rs | 328 ++++++++++++++++++ .../src/claude_snapshot.rs | 10 +- crates/freshell-freshagent/src/lib.rs | 1 + 4 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude_projection_cache.rs create mode 100644 crates/freshell-freshagent/src/claude_projection_cache_tests.rs diff --git a/crates/freshell-freshagent/src/claude_projection_cache.rs b/crates/freshell-freshagent/src/claude_projection_cache.rs new file mode 100644 index 000000000..f05cba8dd --- /dev/null +++ b/crates/freshell-freshagent/src/claude_projection_cache.rs @@ -0,0 +1,308 @@ +//! Incremental claude transcript projection (transcript-live plan §4.3, C4). +//! +//! A live pane refetches its snapshot after every message the CLI persists, +//! and every connected device does the same. Re-reading and re-projecting +//! the whole transcript each time costs O(transcript) per message; the CLI +//! only ever appends, so this cache keeps a projector per transcript and +//! feeds it only the complete lines added since the last read. +//! +//! Each entry holds the file's identity (its inode on unix), the byte offset +//! the projector has consumed (always just after a newline), the 4 KiB before +//! that offset, and the projector. A read: +//! - parses the complete lines after the offset into the cached projector +//! when the file still has those 4 KiB in place (it grew, or is unchanged); +//! - parses the whole file afresh when the file is new to the cache, shorter +//! than the offset, has different bytes before the offset (a rewrite), or +//! is a different file at the same path (a replace); +//! - never consumes an unterminated last line (a write in progress): a tail +//! that already parses as JSON is projected for this read only, so the +//! result always equals a full parse of the file as read. +//! +//! Subagent transcripts are attached per read through the caller's +//! [`SubagentSource`] (the filesystem one has its own per-file cache), so a +//! subagent that grows while the main transcript does not is still reflected. +//! Reads are blocking; callers run them off the async runtime. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex, PoisonError}; +use std::time::Instant; + +use crate::claude_projection::{ClaudeProjection, ClaudeTranscriptProjector, SubagentSource}; + +/// How many transcripts the process keeps projectors for (the open panes' +/// sessions, roughly). +const CACHED_TRANSCRIPTS: usize = 8; + +/// How many bytes before the consumed offset must be unchanged for an +/// incremental read. +const FINGERPRINT_BYTES: u64 = 4096; + +/// Why a read parsed the whole file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FullReason { + /// The cache had no projector for this transcript. + Uncached, + /// The file is shorter than what was consumed. + Shrunk, + /// The bytes before the consumed offset changed. + Rewritten, + /// Another file now sits at the path. + Replaced, +} + +impl FullReason { + fn label(self) -> &'static str { + match self { + Self::Uncached => "uncached", + Self::Shrunk => "shrunk", + Self::Rewritten => "rewritten", + Self::Replaced => "replaced", + } + } +} + +/// What one read parsed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReadOutcome { + /// No complete line was added since the last read. + Unchanged, + /// Only the complete lines added since the last read (`bytes` of them). + Appended { bytes: u64 }, + /// The whole file (its `bytes` of complete lines). + Full { reason: FullReason, bytes: u64 }, +} + +/// A read's projection and what it took. +pub(crate) struct TranscriptRead { + pub projection: ClaudeProjection, + pub outcome: ReadOutcome, +} + +/// What identifies a file beyond its path. +#[derive(Clone, Copy, PartialEq, Eq)] +struct FileId { + #[cfg(unix)] + inode: u64, +} + +impl FileId { + fn of(_metadata: &std::fs::Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + Self { + #[cfg(unix)] + inode: _metadata.ino(), + } + } +} + +struct Entry { + file: FileId, + /// Bytes consumed (just after a newline). + offset: u64, + /// The [`FINGERPRINT_BYTES`] before `offset`. + fingerprint: Vec, + projector: ClaudeTranscriptProjector, + last_used: u64, +} + +#[derive(Default)] +struct CacheState { + entries: HashMap<(PathBuf, String), Entry>, + /// Bumped on every store; an entry's `last_used` orders eviction. + clock: u64, +} + +/// Projectors by transcript, least recently used dropped past the capacity. +pub(crate) struct ProjectionCache { + capacity: usize, + state: Mutex, +} + +/// The process-wide cache every snapshot read shares. +static PROJECTIONS: LazyLock = + LazyLock::new(|| ProjectionCache::new(CACHED_TRANSCRIPTS)); + +/// Project the transcript at `path` through the process-wide cache, logging +/// what the read took. Blocking. +pub(crate) fn read_transcript( + path: &Path, + thread_id: &str, + subagents: &dyn SubagentSource, +) -> std::io::Result { + let started = Instant::now(); + let read = PROJECTIONS.read(path, thread_id, subagents)?; + let elapsed_ms = started.elapsed().as_millis() as u64; + match read.outcome { + ReadOutcome::Unchanged => tracing::debug!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.transcript_projection", + outcome = "unchanged", + thread_id, + elapsed_ms, + "projected a claude transcript from the cache" + ), + ReadOutcome::Appended { bytes } => tracing::debug!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.transcript_projection", + outcome = "appended", + thread_id, + bytes, + elapsed_ms, + "projected the lines appended to a claude transcript" + ), + ReadOutcome::Full { reason, bytes } => tracing::debug!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.transcript_projection", + outcome = "full", + reason = reason.label(), + thread_id, + bytes, + elapsed_ms, + "projected a whole claude transcript" + ), + } + Ok(read.projection) +} + +impl ProjectionCache { + pub(crate) fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + state: Mutex::new(CacheState::default()), + } + } + + /// The projection of the transcript at `path` (see the module docs). + pub(crate) fn read( + &self, + path: &Path, + thread_id: &str, + subagents: &dyn SubagentSource, + ) -> std::io::Result { + let key = (path.to_path_buf(), thread_id.to_string()); + let mut transcript = std::fs::File::open(path)?; + let metadata = transcript.metadata()?; + let file = FileId::of(&metadata); + // The entry leaves the cache while this read works on it: a concurrent + // read of the same transcript parses in full (correct, only slower). + let cached = self.take(&key); + let (mut projector, start, full) = match cached { + None => ( + ClaudeTranscriptProjector::new(thread_id), + 0, + Some(FullReason::Uncached), + ), + Some(entry) => match Self::continuation(&mut transcript, &metadata, file, &entry)? { + None => (entry.projector, entry.offset, None), + Some(reason) => (ClaudeTranscriptProjector::new(thread_id), 0, Some(reason)), + }, + }; + + transcript.seek(SeekFrom::Start(start))?; + let mut bytes = Vec::new(); + transcript.read_to_end(&mut bytes)?; + let complete = bytes + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |newline| newline + 1); + let lines = std::str::from_utf8(&bytes[..complete]) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + for line in lines.split('\n') { + projector.push_line(line); + } + let offset = start + complete as u64; + let fingerprint = Self::fingerprint(&mut transcript, offset)?; + + // An unterminated last line is a write in progress: a tail that + // already parses counts for this read only, never for the cache. + let mut result = projector.clone(); + if let Ok(tail) = std::str::from_utf8(&bytes[complete..]) { + result.push_line(tail); + } + self.store( + key, + Entry { + file, + offset, + fingerprint, + projector, + last_used: 0, + }, + ); + let parsed = complete as u64; + let outcome = match full { + Some(reason) => ReadOutcome::Full { + reason, + bytes: parsed, + }, + None if parsed == 0 => ReadOutcome::Unchanged, + None => ReadOutcome::Appended { bytes: parsed }, + }; + Ok(TranscriptRead { + projection: result.finish_with(subagents), + outcome, + }) + } + + /// Whether `entry` continues the file as it is now: `None` when it does, + /// else why the file must be parsed in full. + fn continuation( + transcript: &mut std::fs::File, + metadata: &std::fs::Metadata, + file: FileId, + entry: &Entry, + ) -> std::io::Result> { + if entry.file != file { + return Ok(Some(FullReason::Replaced)); + } + if metadata.len() < entry.offset { + return Ok(Some(FullReason::Shrunk)); + } + if Self::fingerprint(transcript, entry.offset)? != entry.fingerprint { + return Ok(Some(FullReason::Rewritten)); + } + Ok(None) + } + + /// The [`FINGERPRINT_BYTES`] before `offset`. + fn fingerprint(transcript: &mut std::fs::File, offset: u64) -> std::io::Result> { + let start = offset.saturating_sub(FINGERPRINT_BYTES); + transcript.seek(SeekFrom::Start(start))?; + let mut bytes = Vec::with_capacity((offset - start) as usize); + transcript + .by_ref() + .take(offset - start) + .read_to_end(&mut bytes)?; + Ok(bytes) + } + + fn take(&self, key: &(PathBuf, String)) -> Option { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.entries.remove(key) + } + + fn store(&self, key: (PathBuf, String), mut entry: Entry) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.clock += 1; + entry.last_used = state.clock; + state.entries.insert(key, entry); + while state.entries.len() > self.capacity { + let Some(oldest) = state + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| key.clone()) + else { + break; + }; + state.entries.remove(&oldest); + } + } +} + +#[cfg(test)] +#[path = "claude_projection_cache_tests.rs"] +mod tests; diff --git a/crates/freshell-freshagent/src/claude_projection_cache_tests.rs b/crates/freshell-freshagent/src/claude_projection_cache_tests.rs new file mode 100644 index 000000000..d9c3c3046 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_projection_cache_tests.rs @@ -0,0 +1,328 @@ +//! The incremental transcript projection (C4) over REAL pinned-CLI +//! transcripts (`test/fixtures/fresh-agent/claude/real-cli-2.1.237/`): every +//! read equals a full parse of the file as it stands, and appends are parsed +//! incrementally. + +use std::io::Write as _; + +use serde_json::{json, Value}; + +use super::*; +use crate::claude_projection::{FsSubagents, NoSubagents}; +use crate::claude_snapshot::real_cli_tests::{all_items, real_cli_fixture}; + +const THREAD: &str = "s"; + +/// A projection as one comparable value. +fn summary(projection: ClaudeProjection) -> Value { + json!({ + "turns": projection.turns, + "diffs": projection.diffs, + "tokenUsage": projection.token_usage, + "transcriptLineUuids": projection.transcript_line_uuids, + }) +} + +/// A full parse of `content`, the way the snapshot projected before the cache. +fn full_parse(content: &str, subagents: &dyn SubagentSource) -> Value { + summary(ClaudeTranscriptProjector::project_with( + THREAD, content, subagents, + )) +} + +fn read(cache: &ProjectionCache, path: &Path) -> (Value, ReadOutcome) { + let read = cache + .read(path, THREAD, &NoSubagents) + .expect("the transcript reads"); + (summary(read.projection), read.outcome) +} + +fn append(path: &Path, bytes: &str) { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open the transcript") + .write_all(bytes.as_bytes()) + .expect("append to the transcript"); +} + +/// Every split point: each line boundary and the middle of each line. +fn split_points(content: &str) -> Vec { + let mut points = Vec::new(); + let mut start = 0; + for (index, byte) in content.bytes().enumerate() { + if byte == b'\n' { + let mut middle = start + (index - start) / 2; + while !content.is_char_boundary(middle) { + middle -= 1; + } + points.push(middle); + points.push(index + 1); + start = index + 1; + } + } + points +} + +#[test] +fn a_transcript_read_as_it_grows_projects_like_a_full_parse_and_parses_only_what_was_appended() { + for content in [ + real_cli_fixture!("tools-sync"), + real_cli_fixture!("agent-async"), + real_cli_fixture!("compact-ok"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let cache = ProjectionCache::new(4); + let mut written = 0; + let mut consumed = 0; + for (step, point) in split_points(content).into_iter().enumerate() { + append(&path, &content[written..point]); + written = point; + let (projection, outcome) = read(&cache, &path); + assert_eq!( + projection, + full_parse(&content[..point], &NoSubagents), + "after {point} bytes" + ); + let complete = content[..point].rfind('\n').map_or(0, |at| at + 1); + let expected = if step == 0 { + ReadOutcome::Full { + reason: FullReason::Uncached, + bytes: complete as u64, + } + } else if complete == consumed { + ReadOutcome::Unchanged + } else { + ReadOutcome::Appended { + bytes: (complete - consumed) as u64, + } + }; + assert_eq!(outcome, expected, "after {point} bytes"); + consumed = complete; + } + } +} + +#[test] +fn an_unterminated_last_line_is_never_consumed_until_its_newline_lands() { + let content = real_cli_fixture!("tools-sync"); + let lines: Vec<&str> = content.lines().collect(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let cache = ProjectionCache::new(4); + + // A complete JSON line still missing its newline counts for this read... + let head = format!("{}\n{}", lines[..7].join("\n"), lines[7]); + append(&path, &head); + let (projection, _) = read(&cache, &path); + assert_eq!(projection, full_parse(&head, &NoSubagents)); + // ...but is not consumed: once its newline and the next line land, the + // read parses it with them, exactly once. + let more = format!("\n{}\n", lines[8]); + append(&path, &more); + let (projection, outcome) = read(&cache, &path); + let so_far = format!("{head}{more}"); + assert_eq!(projection, full_parse(&so_far, &NoSubagents)); + assert_eq!( + outcome, + ReadOutcome::Appended { + bytes: (lines[7].len() + more.len()) as u64, + } + ); + + // A torn line (a write in progress) is invisible until complete. + let torn = &lines[9][..lines[9].len() / 2]; + append(&path, torn); + let (projection, outcome) = read(&cache, &path); + assert_eq!(projection, full_parse(&so_far, &NoSubagents)); + assert_eq!(outcome, ReadOutcome::Unchanged); + let rest = format!("{}\n", &lines[9][lines[9].len() / 2..]); + append(&path, &rest); + let (projection, outcome) = read(&cache, &path); + assert_eq!( + projection, + full_parse(&format!("{so_far}{torn}{rest}"), &NoSubagents) + ); + assert_eq!( + outcome, + ReadOutcome::Appended { + bytes: (lines[9].len() + 1) as u64, + } + ); +} + +#[test] +fn a_rewritten_or_shrunk_transcript_is_parsed_in_full() { + let content = real_cli_fixture!("tools-sync"); + let lines: Vec<&str> = content.lines().collect(); + let joined = |lines: &[&str]| { + lines + .iter() + .map(|line| format!("{line}\n")) + .collect::() + }; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let cache = ProjectionCache::new(4); + std::fs::write(&path, joined(&lines[..20])).unwrap(); + read(&cache, &path); + + // Rewritten in place: the last consumed line changed and the file grew, + // so the new bytes alone would leave the old line in the projection. + let mut rewritten: Vec<&str> = lines[..19].to_vec(); + rewritten.push(lines[21]); + rewritten.push(lines[22]); + rewritten.push(lines[23]); + let rewritten = joined(&rewritten); + std::fs::write(&path, &rewritten).unwrap(); + let (projection, outcome) = read(&cache, &path); + assert_eq!(projection, full_parse(&rewritten, &NoSubagents)); + assert!( + matches!( + outcome, + ReadOutcome::Full { + reason: FullReason::Rewritten, + .. + } + ), + "{outcome:?}" + ); + + // Shrunk (a rollback truncation, say). + let shrunk = joined(&lines[..5]); + std::fs::write(&path, &shrunk).unwrap(); + let (projection, outcome) = read(&cache, &path); + assert_eq!(projection, full_parse(&shrunk, &NoSubagents)); + assert!( + matches!( + outcome, + ReadOutcome::Full { + reason: FullReason::Shrunk, + .. + } + ), + "{outcome:?}" + ); +} + +/// Another file renamed over the path is a different transcript, even when +/// it starts with the same bytes. +#[cfg(unix)] +#[test] +fn a_transcript_replaced_by_another_file_is_parsed_in_full() { + let content = real_cli_fixture!("tools-sync"); + let lines: Vec<&str> = content.lines().collect(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let cache = ProjectionCache::new(4); + std::fs::write(&path, format!("{}\n", lines[..10].join("\n"))).unwrap(); + read(&cache, &path); + + let replacement = dir.path().join("next.jsonl"); + std::fs::write(&replacement, content).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + let (projection, outcome) = read(&cache, &path); + assert_eq!(projection, full_parse(content, &NoSubagents)); + assert!( + matches!( + outcome, + ReadOutcome::Full { + reason: FullReason::Replaced, + .. + } + ), + "{outcome:?}" + ); +} + +/// The main transcript is unchanged while the subagent works: its file grows, +/// and the delegation's activity follows. +#[test] +fn a_growing_subagent_transcript_is_reflected_while_the_main_one_is_unchanged() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync"); + let main = real_cli_fixture!("tools-sync"); + let main_lines: Vec<&str> = main.lines().collect(); + // Up to the Agent call (the delegation is running). + let agent_call = main_lines + .iter() + .position(|line| line.contains("\"name\":\"Agent\"")) + .expect("the Agent call"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + std::fs::write(&path, format!("{}\n", main_lines[..=agent_call].join("\n"))).unwrap(); + let subagents_dir = dir.path().join("t").join("subagents"); + std::fs::create_dir_all(&subagents_dir).unwrap(); + let recorded = fixture.join("session").join("subagents"); + let agent = "agent-a2645ac0e225b257e"; + std::fs::copy( + recorded.join(format!("{agent}.meta.json")), + subagents_dir.join(format!("{agent}.meta.json")), + ) + .unwrap(); + let subagent = std::fs::read_to_string(recorded.join(format!("{agent}.jsonl"))).unwrap(); + let subagent_lines: Vec<&str> = subagent.lines().collect(); + let read_call = subagent_lines + .iter() + .position(|line| line.contains("\"type\":\"tool_use\"")) + .expect("the subagent's Read call"); + let subagent_path = subagents_dir.join(format!("{agent}.jsonl")); + std::fs::write( + &subagent_path, + format!("{}\n", subagent_lines[..=read_call].join("\n")), + ) + .unwrap(); + + let cache = ProjectionCache::new(4); + let activity = |cache: &ProjectionCache| { + let read = cache + .read(&path, THREAD, &FsSubagents::for_transcript(&path)) + .expect("the transcript reads"); + let delegation = all_items(&read.projection.turns) + .into_iter() + .find(|item| item["kind"] == "task_delegation") + .cloned() + .expect("the delegation"); + (delegation["activity"].clone(), read.outcome) + }; + let (running, _) = activity(&cache); + assert_eq!(running[0]["status"], "running", "{running}"); + + append( + &subagent_path, + &format!("{}\n", subagent_lines[read_call + 1..].join("\n")), + ); + let (completed, outcome) = activity(&cache); + assert_eq!(outcome, ReadOutcome::Unchanged); + assert_eq!(completed[0]["status"], "completed", "{completed}"); +} + +/// Reads of more transcripts than the capacity drop the least recently used +/// projector (its next read is a full one again), not the busy ones. +#[test] +fn past_its_capacity_the_cache_drops_the_least_recently_read_transcript() { + let content = real_cli_fixture!("tools-sync"); + let dir = tempfile::tempdir().unwrap(); + let paths: Vec = (0..3) + .map(|n| { + let path = dir.path().join(format!("t{n}.jsonl")); + std::fs::write(&path, content).unwrap(); + path + }) + .collect(); + let cache = ProjectionCache::new(2); + read(&cache, &paths[0]); + read(&cache, &paths[1]); + assert_eq!(read(&cache, &paths[0]).1, ReadOutcome::Unchanged); + read(&cache, &paths[2]); + assert_eq!(read(&cache, &paths[0]).1, ReadOutcome::Unchanged); + assert!(matches!( + read(&cache, &paths[1]).1, + ReadOutcome::Full { + reason: FullReason::Uncached, + .. + } + )); +} diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index 7b20eb25b..d40d27451 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -1780,7 +1780,9 @@ pub(crate) async fn get_claude_snapshot_in( } }; // Reading the transcript and its subagent files, and projecting them, is - // blocking work: keep it off the async runtime. + // blocking work: keep it off the async runtime. The projection is + // incremental (a live pane refetches after every message): only the lines + // appended since the last read are parsed. let thread = thread_id.to_string(); let (mtime_ms, projection) = tokio::task::spawn_blocking(move || { let mtime_ms = std::fs::metadata(&path) @@ -1788,11 +1790,9 @@ pub(crate) async fn get_claude_snapshot_in( .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64); - let content = std::fs::read_to_string(&path)?; let subagents = crate::claude_projection::FsSubagents::for_transcript(&path); - let projection = crate::claude_projection::ClaudeTranscriptProjector::project_with( - &thread, &content, &subagents, - ); + let projection = + crate::claude_projection_cache::read_transcript(&path, &thread, &subagents)?; Ok::<_, std::io::Error>((mtime_ms, projection)) }) .await diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 13add9023..70f79fb1f 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -42,6 +42,7 @@ pub(crate) mod claude_handshake; pub(crate) mod claude_launch; pub(crate) mod claude_live_events; pub(crate) mod claude_projection; +pub(crate) mod claude_projection_cache; pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; From 3659252d20fdfb4d3b76a411a658b3b7e2207057 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:24:19 -0700 Subject: [PATCH 14/45] refactor(store): name the app's reducer map in one module Tests can now build a store with the app's exact state shape (and so the exact selector inputs every component sees) without the production store's module-level side effects. Co-Authored-By: Claude Opus 5.5 --- src/store/appReducers.ts | 70 ++++++++++++++++++++++++++++++++++++++++ src/store/store.ts | 69 ++------------------------------------- 2 files changed, 72 insertions(+), 67 deletions(-) create mode 100644 src/store/appReducers.ts diff --git a/src/store/appReducers.ts b/src/store/appReducers.ts new file mode 100644 index 000000000..f955f5e5a --- /dev/null +++ b/src/store/appReducers.ts @@ -0,0 +1,70 @@ +// The app's root reducer map: one place that names every slice the store +// holds, so tests can build a store with the app's exact state shape (and +// therefore the exact selector inputs every component sees) without the +// production store's module-level side effects. +import tabsReducer from './tabsSlice' +import connectionReducer from './connectionSlice' +import sessionsReducer from './sessionsSlice' +import settingsReducer from './settingsSlice' +import panesReducer from './panesSlice' +import sessionActivityReducer from './sessionActivitySlice' +import terminalDirectoryReducer from './terminalDirectorySlice' +import tabRecencyReducer from './tabRecencySlice' +import turnCompletionReducer from './turnCompletionSlice' +import terminalLifecycleReducer from './terminalLifecycleSlice' +import terminalMetaReducer from './terminalMetaSlice' +import repoIconsReducer from './repoIconsSlice' +import codexActivityReducer from './codexActivitySlice' +import claudeActivityReducer from './claudeActivitySlice' +import amplifierActivityReducer from './amplifierActivitySlice' +import opencodeActivityReducer from './opencodeActivitySlice' +import freshAgentReducer from './freshAgentSlice' +import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' +import hostStatsReducer from './hostStatsSlice' +import { networkReducer } from './networkSlice' +import tabRegistryReducer from './tabRegistrySlice' +import machineIdentityReducer from './machineIdentitySlice' +import extensionsReducer from './extensionsSlice' +import deckReducer from './deckSlice' +import sessionNamesReducer from './sessionNamesSlice' +import managedRuntimeReducer from './managedRuntimeSlice' + +export const appReducers = { + tabs: tabsReducer, + connection: connectionReducer, + sessions: sessionsReducer, + settings: settingsReducer, + panes: panesReducer, + sessionActivity: sessionActivityReducer, + terminalDirectory: terminalDirectoryReducer, + tabRecency: tabRecencyReducer, + turnCompletion: turnCompletionReducer, + // Ephemeral crash/auto-resume presentation state — never persisted + // (persistence is an allowlist in persistMiddleware; do not add this). + terminalLifecycle: terminalLifecycleReducer, + terminalMeta: terminalMetaReducer, + repoIcons: repoIconsReducer, + codexActivity: codexActivityReducer, + claudeActivity: claudeActivityReducer, + amplifierActivity: amplifierActivityReducer, + opencodeActivity: opencodeActivityReducer, + freshAgent: freshAgentReducer, + paneRuntimeActivity: paneRuntimeActivityReducer, + // Ephemeral live host metrics — never persisted (allowlist rule) + hostStats: hostStatsReducer, + network: networkReducer, + tabRegistry: tabRegistryReducer, + // Server-owned workspace selection. This state gates renderer mounts and + // all tab-registry transport until a machine has been resolved. + machineIdentity: machineIdentityReducer, + extensions: extensionsReducer, + // Ephemeral device state — never persisted (allowlist rule) + deck: deckReducer, + // Unified agent names (Task 5): the revisioned canonical-name cache — + // last-known server projections only, rebuilt from bootstrap/pushes. + // Never persisted (allowlist rule). + sessionNames: sessionNamesReducer, + // Authoritative managed-runtime projection — reconstructed from the + // supervisor on every connection and deliberately never persisted. + managedRuntime: managedRuntimeReducer, +} diff --git a/src/store/store.ts b/src/store/store.ts index 0db35dbff..e04252a96 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,32 +1,6 @@ import { enableMapSet } from 'immer' import { configureStore } from '@reduxjs/toolkit' -import tabsReducer from './tabsSlice' -import connectionReducer from './connectionSlice' -import sessionsReducer from './sessionsSlice' -import settingsReducer from './settingsSlice' -import panesReducer from './panesSlice' -import sessionActivityReducer from './sessionActivitySlice' -import terminalDirectoryReducer from './terminalDirectorySlice' -import tabRecencyReducer from './tabRecencySlice' - -import turnCompletionReducer from './turnCompletionSlice' -import terminalLifecycleReducer from './terminalLifecycleSlice' -import terminalMetaReducer from './terminalMetaSlice' -import repoIconsReducer from './repoIconsSlice' -import codexActivityReducer from './codexActivitySlice' -import claudeActivityReducer from './claudeActivitySlice' -import amplifierActivityReducer from './amplifierActivitySlice' -import opencodeActivityReducer from './opencodeActivitySlice' -import freshAgentReducer from './freshAgentSlice' -import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' -import hostStatsReducer from './hostStatsSlice' -import { networkReducer } from './networkSlice' -import tabRegistryReducer from './tabRegistrySlice' -import machineIdentityReducer from './machineIdentitySlice' -import extensionsReducer from './extensionsSlice' -import deckReducer from './deckSlice' -import sessionNamesReducer from './sessionNamesSlice' -import managedRuntimeReducer from './managedRuntimeSlice' +import { appReducers } from './appReducers' import { perfMiddleware } from './perfMiddleware' import { persistMiddleware } from './persistMiddleware' import { sessionActivityPersistMiddleware } from './sessionActivityPersistence' @@ -62,46 +36,7 @@ enableMapSet() const log = createLogger('Store') export const store = configureStore({ - reducer: { - tabs: tabsReducer, - connection: connectionReducer, - sessions: sessionsReducer, - settings: settingsReducer, - panes: panesReducer, - sessionActivity: sessionActivityReducer, - terminalDirectory: terminalDirectoryReducer, - tabRecency: tabRecencyReducer, - - turnCompletion: turnCompletionReducer, - // Ephemeral crash/auto-resume presentation state — never persisted - // (persistence is an allowlist in persistMiddleware; do not add this). - terminalLifecycle: terminalLifecycleReducer, - terminalMeta: terminalMetaReducer, - repoIcons: repoIconsReducer, - codexActivity: codexActivityReducer, - claudeActivity: claudeActivityReducer, - amplifierActivity: amplifierActivityReducer, - opencodeActivity: opencodeActivityReducer, - freshAgent: freshAgentReducer, - paneRuntimeActivity: paneRuntimeActivityReducer, - // Ephemeral live host metrics — never persisted (allowlist rule) - hostStats: hostStatsReducer, - network: networkReducer, - tabRegistry: tabRegistryReducer, - // Server-owned workspace selection. This state gates renderer mounts and - // all tab-registry transport until a machine has been resolved. - machineIdentity: machineIdentityReducer, - extensions: extensionsReducer, - // Ephemeral device state — never persisted (allowlist rule) - deck: deckReducer, - // Unified agent names (Task 5): the revisioned canonical-name cache — - // last-known server projections only, rebuilt from bootstrap/pushes. - // Never persisted (allowlist rule). - sessionNames: sessionNamesReducer, - // Authoritative managed-runtime projection — reconstructed from the - // supervisor on every connection and deliberately never persisted. - managedRuntime: managedRuntimeReducer, - }, + reducer: appReducers, middleware: (getDefault) => getDefault({ serializableCheck: { From 5e9e539789f2627a77e92a053b508e435b47e639 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:24:26 -0700 Subject: [PATCH 15/45] feat(fresh-agent): stream deltas feed a live slice, never the sessions map (D1) Token-rate state leaves state.freshAgent.sessions (transcript-live P3). freshAgent.stream frames become typed ops (block start, text/thinking delta) that a 100 ms batcher applies to the new freshAgentLive slice; any other event of the session flushes its pending ops first, so the store sees them in arrival order. The first block of a turn marks the session streaming once (streamOpened); later blocks and tokens write nothing there. Removed as dead (nothing rendered them): streamingText, appendStreamDelta, addAssistantMessage, addUserMessage, clearStreaming and the per-block setStreaming(false). An assistant frame no longer invents a client-side turn; durable content comes from the snapshot (P1). Co-Authored-By: Claude Opus 5.5 --- src/lib/fresh-agent-stream-batcher.ts | 101 +++++++++++++++ src/lib/fresh-agent-ws.ts | 94 ++++++++++---- src/store/appReducers.ts | 5 + src/store/freshAgentLiveSlice.ts | 57 +++++++++ src/store/freshAgentLiveTypes.ts | 91 ++++++++++++++ src/store/freshAgentSlice.ts | 118 ++---------------- src/store/freshAgentTypes.ts | 2 +- .../e2e/pane-activity-indicator-flow.test.tsx | 1 - .../pane-header-runtime-meta-flow.test.tsx | 1 - test/unit/client/components/Sidebar.test.tsx | 1 - .../fresh-agent/FreshAgentView.test.tsx | 25 ++-- .../components/panes/PaneContainer.test.tsx | 1 - .../lib/fresh-agent-stream-batcher.test.ts | 102 +++++++++++++++ test/unit/client/lib/fresh-agent-ws.test.ts | 103 +++++++++++++-- test/unit/client/lib/pane-activity.test.ts | 1 - .../selectors/sessionStatusTiers.test.ts | 1 - .../client/store/sessionGreyTouch.test.ts | 1 - .../store/turnCompletionAttention.test.ts | 1 - 18 files changed, 544 insertions(+), 162 deletions(-) create mode 100644 src/lib/fresh-agent-stream-batcher.ts create mode 100644 src/store/freshAgentLiveSlice.ts create mode 100644 src/store/freshAgentLiveTypes.ts create mode 100644 test/unit/client/lib/fresh-agent-stream-batcher.test.ts diff --git a/src/lib/fresh-agent-stream-batcher.ts b/src/lib/fresh-agent-stream-batcher.ts new file mode 100644 index 000000000..3dbf6bc59 --- /dev/null +++ b/src/lib/fresh-agent-stream-batcher.ts @@ -0,0 +1,101 @@ +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import { streamOpsApplied } from '@/store/freshAgentLiveSlice' +import type { FreshAgentSessionLocator } from '@/store/freshAgentTypes' +import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' + +/** + * How long stream events wait before they reach the store. A model streams a + * token every few milliseconds; applying them every 100 ms bounds the live + * tail's re-renders (and its markdown re-parses) to ten a second while text + * still appears without a visible lag (transcript-live plan §4.4, risk 6). + */ +export const LIVE_STREAM_FLUSH_MS = 100 + +export type StreamBatcherScheduler = { + set: (fn: () => void, ms: number) => unknown + clear: (handle: unknown) => void +} + +// Read the timer functions at call time so fake timers installed after this +// module loads still drive the batcher. +const timerScheduler: StreamBatcherScheduler = { + set: (fn, ms) => globalThis.setTimeout(fn, ms), + clear: (handle) => globalThis.clearTimeout(handle as ReturnType), +} + +type Dispatch = (action: ReturnType) => unknown + +type PendingSession = { + locator: FreshAgentSessionLocator + ops: LiveStreamOp[] +} + +/** + * Buffers stream events per session and applies each session's batch as one + * `streamOpsApplied` action. The websocket handler flushes a session before it + * handles any other event of that session, so the store always sees one + * session's events in arrival order. + */ +export class FreshAgentStreamBatcher { + private readonly pending = new Map() + private timer: unknown = null + + constructor( + private readonly dispatch: Dispatch, + private readonly scheduler: StreamBatcherScheduler = timerScheduler, + ) {} + + enqueue(locator: FreshAgentSessionLocator, op: LiveStreamOp): void { + const key = makeFreshAgentSessionKey(locator) + let entry = this.pending.get(key) + if (!entry) { + entry = { locator: { sessionId: locator.sessionId, sessionType: locator.sessionType, provider: locator.provider }, ops: [] } + this.pending.set(key, entry) + } + entry.ops.push(op) + if (this.timer === null) { + this.timer = this.scheduler.set(() => { + this.timer = null + this.flush() + }, LIVE_STREAM_FLUSH_MS) + } + } + + /** Apply one session's pending events now, or every session's when none is named. */ + flush(locator?: FreshAgentSessionLocator): void { + if (locator) { + const key = makeFreshAgentSessionKey(locator) + const entry = this.pending.get(key) + if (!entry) return + this.pending.delete(key) + this.dispatchEntry(entry) + } else { + const entries = [...this.pending.values()] + this.pending.clear() + for (const entry of entries) this.dispatchEntry(entry) + } + if (this.pending.size === 0 && this.timer !== null) { + this.scheduler.clear(this.timer) + this.timer = null + } + } + + private dispatchEntry(entry: PendingSession): void { + this.dispatch(streamOpsApplied({ ...entry.locator, ops: entry.ops })) + } +} + +const batchers = new WeakMap() + +/** + * The batcher that feeds one store (keyed by its dispatch function, which is + * stable for a store's lifetime). + */ +export function streamBatcherFor(dispatch: Dispatch): FreshAgentStreamBatcher { + let batcher = batchers.get(dispatch) + if (!batcher) { + batcher = new FreshAgentStreamBatcher(dispatch) + batchers.set(dispatch, batcher) + } + return batcher +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index fecdb4630..ca197dd6f 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -10,11 +10,11 @@ import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from import { applyFreshAgentCompletion, applyFreshAgentWaiting } from '@/store/turnCompletionThunks' import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' +import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' +import type { LiveBlockKind, LiveStreamOp } from '@/store/freshAgentLiveTypes' import { - addAssistantMessage, addPermissionRequest, addQuestionRequest, - appendStreamDelta, applyRuntimeOwner, clearPendingCreateFailure, createFailed, @@ -32,7 +32,7 @@ import { sessionMetadataReceived, sessionSnapshotReceived, setSessionStatus, - setStreaming, + streamOpened, turnResult, } from '@/store/freshAgentSlice' @@ -230,6 +230,12 @@ export function handleFreshAgentMessage( } case 'freshAgent.session.materialized': { const materialized = msg as FreshAgentSessionMaterializedMessage + // Stream events queued under the old id land before the re-key moves them. + streamBatcherFor(dispatch).flush({ + sessionId: materialized.previousSessionId, + sessionType: materialized.sessionType, + provider: materialized.provider, + }) dispatch(materializeFreshAgentSessionState({ previousSessionId: materialized.previousSessionId, sessionId: materialized.sessionId, @@ -251,6 +257,11 @@ export function handleFreshAgentMessage( } case 'freshAgent.killed': { const killed = msg as FreshAgentKilledMessage + streamBatcherFor(dispatch).flush({ + sessionId: killed.sessionId, + sessionType: killed.sessionType, + provider: killed.provider, + }) foldFreshAgentKilled(dispatch, { sessionId: killed.sessionId, sessionType: killed.sessionType, @@ -344,6 +355,49 @@ export function foldReadyRuntimeOwners( } } +const LIVE_BLOCK_KINDS: ReadonlySet = new Set(['text', 'thinking', 'tool_use']) + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * The part of a `freshAgent.stream` frame the live tail renders: a text, + * thinking or tool block opening, or a text/thinking delta, of the main + * thread (the sidecar already drops subagent events; a frame that still + * names a parent tool use is ignored). Anything else maps to nothing. + */ +function parseLiveStreamOp(frame: Record): LiveStreamOp | undefined { + if (typeof frame.parentToolUseId === 'string' && frame.parentToolUseId) return undefined + const event = frame.event + if (!isObject(event) || typeof event.index !== 'number') return undefined + const messageId = typeof frame.messageId === 'string' && frame.messageId ? frame.messageId : null + const index = event.index + if (event.type === 'content_block_start') { + const block = event.content_block + if (!isObject(block) || typeof block.type !== 'string' || !LIVE_BLOCK_KINDS.has(block.type)) return undefined + const kind = block.type as LiveBlockKind + if (kind === 'tool_use') { + return { + op: 'start', + messageId, + index, + kind, + ...(typeof block.name === 'string' ? { name: block.name } : {}), + ...(typeof block.id === 'string' ? { toolUseId: block.id } : {}), + } + } + return { op: 'start', messageId, index, kind } + } + if (event.type === 'content_block_delta' && isObject(event.delta)) { + const delta = event.delta + const text = delta.type === 'text_delta' ? delta.text : delta.type === 'thinking_delta' ? delta.thinking : undefined + if (typeof text !== 'string' || text.length === 0) return undefined + return { op: 'delta', messageId, index, text } + } + return undefined +} + export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: FreshAgentEventMessage): boolean { const event = msg.event const sessionId = typeof msg.sessionId === 'string' @@ -357,6 +411,10 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh provider: msg.provider, } + // Stream events are batched; every other event of the session first + // applies the ones still waiting, so the store sees them in arrival order. + if (event.type !== 'freshAgent.stream') streamBatcherFor(dispatch).flush(locator) + switch (event.type) { case 'freshAgent.session.snapshot': dispatch(sessionSnapshotReceived({ @@ -366,7 +424,6 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh historySessionId: event.timelineSessionId as string | undefined, revision: event.revision as number | undefined, streamingActive: event.streamingActive as boolean | undefined, - streamingText: event.streamingText as string | undefined, })) return true case 'freshAgent.session.changed': @@ -425,29 +482,16 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh return true } case 'freshAgent.assistant': - dispatch(addAssistantMessage({ - ...locator, - content: Array.isArray(event.content) ? event.content as Record[] : [], - model: event.model as string | undefined, - })) + // Durable content comes from the snapshot (transcript-live P1); the + // pending stream events were applied above. return true case 'freshAgent.stream': { - const streamEvent = event.event as Record | undefined - if (streamEvent?.type === 'content_block_start') { - dispatch(setStreaming({ ...locator, active: true })) - } - if (streamEvent?.type === 'content_block_delta') { - const delta = streamEvent.delta as Record | undefined - if (delta?.type === 'text_delta') { - dispatch(appendStreamDelta({ - ...locator, - text: delta.text as string, - })) - } - } - if (streamEvent?.type === 'content_block_stop') { - dispatch(setStreaming({ ...locator, active: false })) - } + const op = parseLiveStreamOp(event) + if (!op) return true + // The first block of a turn marks the session streaming (busy) at once; + // the block's text follows with the next batch. + if (op.op === 'start') dispatch(streamOpened(locator)) + streamBatcherFor(dispatch).enqueue(locator, op) return true } case 'freshAgent.result': diff --git a/src/store/appReducers.ts b/src/store/appReducers.ts index f955f5e5a..54a5a4ee2 100644 --- a/src/store/appReducers.ts +++ b/src/store/appReducers.ts @@ -19,6 +19,7 @@ import claudeActivityReducer from './claudeActivitySlice' import amplifierActivityReducer from './amplifierActivitySlice' import opencodeActivityReducer from './opencodeActivitySlice' import freshAgentReducer from './freshAgentSlice' +import freshAgentLiveReducer from './freshAgentLiveSlice' import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' import hostStatsReducer from './hostStatsSlice' import { networkReducer } from './networkSlice' @@ -49,6 +50,10 @@ export const appReducers = { amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, freshAgent: freshAgentReducer, + // The live tail of running turns (streamed blocks, retries, notices): its + // own root key so token-rate updates never touch freshAgent.sessions. + // Ephemeral — never persisted (allowlist rule). + freshAgentLive: freshAgentLiveReducer, paneRuntimeActivity: paneRuntimeActivityReducer, // Ephemeral live host metrics — never persisted (allowlist rule) hostStats: hostStatsReducer, diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts new file mode 100644 index 000000000..b384d89a7 --- /dev/null +++ b/src/store/freshAgentLiveSlice.ts @@ -0,0 +1,57 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import type { FreshAgentSessionLocator } from './freshAgentTypes' +import type { + FreshAgentLiveSession, + FreshAgentLiveState, + LiveStreamOp, +} from './freshAgentLiveTypes' + +const initialState: FreshAgentLiveState = { + bySession: {}, + noticeSeq: 0, +} + +function emptySession(): FreshAgentLiveSession { + return { blocks: [], expectedTurnIds: [] } +} + +function applyStreamOp(session: FreshAgentLiveSession, op: LiveStreamOp): void { + const existing = session.blocks.find((block) => block.messageId === op.messageId && block.index === op.index) + if (op.op === 'start') { + if (existing) return + session.blocks.push({ + messageId: op.messageId, + index: op.index, + kind: op.kind, + text: '', + ...(op.name !== undefined ? { name: op.name } : {}), + ...(op.toolUseId !== undefined ? { toolUseId: op.toolUseId } : {}), + }) + return + } + // A delta whose block start this client never saw (it attached mid-block) + // has nothing to extend: the snapshot shows the block once it lands. + if (!existing) return + existing.text += op.text +} + +const freshAgentLiveSlice = createSlice({ + name: 'freshAgentLive', + initialState, + reducers: { + /** A batch of stream events for one session, in arrival order. */ + streamOpsApplied(state, action: PayloadAction) { + const { ops, ...locator } = action.payload + if (ops.length === 0) return + const key = makeFreshAgentSessionKey(locator) + const session = state.bySession[key] ?? emptySession() + for (const op of ops) applyStreamOp(session, op) + state.bySession[key] = session + }, + }, +}) + +export const { streamOpsApplied } = freshAgentLiveSlice.actions + +export default freshAgentLiveSlice.reducer diff --git a/src/store/freshAgentLiveTypes.ts b/src/store/freshAgentLiveTypes.ts new file mode 100644 index 000000000..c56c95a47 --- /dev/null +++ b/src/store/freshAgentLiveTypes.ts @@ -0,0 +1,91 @@ +/** + * The live tail of a fresh-agent session (transcript-live plan §4.4): what a + * running turn shows before its transcript lines land in a snapshot. Durable + * content always comes from the snapshot (principle P1); this state holds + * only what the transcript cannot hold yet — the blocks being streamed, the + * API retry in progress, and the provider notices that never end a turn. It + * lives under its own root key so token-rate updates never touch + * `state.freshAgent.sessions` (P3). + */ + +export type LiveBlockKind = 'text' | 'thinking' | 'tool_use' + +/** One streamed content block of the main thread's current API message. */ +export type LiveBlock = { + /** The API message id the block belongs to (`message_start`). */ + messageId: string | null + /** The block's index within its message. */ + index: number + kind: LiveBlockKind + /** Streamed text (text and thinking blocks); empty for a tool. */ + text: string + /** Tool name, for a tool_use block. */ + name?: string + /** Tool use id, for a tool_use block. */ + toolUseId?: string + /** The tool's input, once the block's assistant message carried it. */ + input?: unknown + /** The transcript line uuid the block becomes (its assistant message's uuid). */ + uuid?: string +} + +/** One stream event, reduced to what the tail renders. */ +export type LiveStreamOp = + | { + op: 'start' + messageId: string | null + index: number + kind: LiveBlockKind + name?: string + toolUseId?: string + } + | { + op: 'delta' + messageId: string | null + index: number + text: string + } + +/** The API request the CLI is retrying right now (live only; never persisted). */ +export type LiveRetry = { + attempt: number + maxRetries?: number + delayMs?: number + /** HTTP status of the failed request; null for a connection error. */ + errorStatus?: number | null + error?: string +} + +export type LiveNoticeLevel = 'info' | 'warning' | 'error' + +/** The account usage limit a rate-limit notice is about. */ +export type LiveRateLimit = { + status?: string + /** When the limit resets, as the CLI reports it (epoch seconds). */ + resetsAt?: number + rateLimitType?: string + utilization?: number +} + +/** A provider notice (rate limit, failed compaction, informational banner). */ +export type LiveNotice = { + /** Increases with every notice received, across sessions. */ + seq: number + level: LiveNoticeLevel + code: string + message: string + rateLimit?: LiveRateLimit +} + +export type FreshAgentLiveSession = { + blocks: LiveBlock[] + retry?: LiveRetry + /** Transcript line uuids announced by the CLI and not yet seen in a snapshot. */ + expectedTurnIds: string[] + notice?: LiveNotice +} + +export type FreshAgentLiveState = { + bySession: Record + noticeSeq: number +} diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 44302f5a7..6513e9997 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -7,7 +7,6 @@ import { import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' import type { SessionRuntimeOwnerMessage } from '@shared/ws-protocol' import type { - FreshAgentContentBlock, FreshAgentPermissionRequest, FreshAgentQuestionRequest, FreshAgentSessionState, @@ -70,7 +69,6 @@ function createSession(locator: FreshAgentSessionPayload, status: FreshAgentSess turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, @@ -121,7 +119,6 @@ function resetHydratedHistoryState(session: FreshAgentSessionState): void { session.historyError = undefined session.historyLoaded = false session.restoreFailureMessage = undefined - session.streamingText = '' session.streamingActive = false } @@ -129,45 +126,6 @@ function requestRestoreHydrationRestart(session: FreshAgentSessionState): void { session.restoreHydrationRequestId = (session.restoreHydrationRequestId ?? 0) + 1 } -function normalizeLegacyContentBlock(block: Record, index: number): FreshAgentContentBlock | undefined { - const id = typeof block.id === 'string' && block.id.length > 0 - ? block.id - : `legacy-${index}` - - switch (block.type) { - case 'text': - return { - id, - kind: 'text', - text: typeof block.text === 'string' ? block.text : '', - } - case 'thinking': - return { - id, - kind: 'thinking', - text: typeof block.thinking === 'string' ? block.thinking : String(block.text ?? ''), - } - case 'tool_use': - return { - id, - kind: 'tool_use', - toolUseId: typeof block.id === 'string' ? block.id : id, - name: typeof block.name === 'string' ? block.name : 'Tool', - input: block.input, - } - case 'tool_result': - return { - id, - kind: 'tool_result', - toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : id, - content: block.content ?? '', - isError: Boolean(block.is_error), - } - default: - return undefined - } -} - const freshAgentSlice = createSlice({ name: 'freshAgent', initialState, @@ -292,7 +250,6 @@ const freshAgentSlice = createSlice({ historySessionId?: string revision?: number streamingActive?: boolean - streamingText?: string }>) { const session = resolveOrEnsureSession(state, action.payload, action.payload.status) if (!session) return @@ -316,7 +273,6 @@ const freshAgentSlice = createSlice({ session.historySessionId = action.payload.historySessionId ?? session.historySessionId session.historyRevision = action.payload.revision ?? session.historyRevision session.streamingActive = action.payload.streamingActive ?? false - session.streamingText = action.payload.streamingText ?? '' session.restoreFailureCode = undefined session.restoreFailureMessage = undefined session.snapshotRefreshRequestId = undefined @@ -595,66 +551,18 @@ const freshAgentSlice = createSlice({ session.totalInputTokens += action.payload.usage?.input_tokens ?? 0 session.totalOutputTokens += action.payload.usage?.output_tokens ?? 0 }, - addUserMessage(state, action: PayloadAction) { - const session = resolveOrEnsureSession(state, action.payload, 'running') - if (!session) return - const turnId = `live-user-${session.turns.length + 1}` - session.turns.push({ - id: turnId, - turnId, - role: 'user', - summary: action.payload.text, - items: [{ id: `${turnId}-text`, kind: 'text', text: action.payload.text }], - }) - session.historyItems = session.turns - }, - addAssistantMessage(state, action: PayloadAction[] - model?: string - }>) { - const session = resolveOrEnsureSession(state, action.payload, 'idle') - if (!session) return - const items = action.payload.content - .map((block, index) => normalizeLegacyContentBlock(block, index)) - .filter((item): item is FreshAgentContentBlock => item !== undefined) - const turnId = `live-assistant-${session.turns.length + 1}` - session.turns.push({ - id: turnId, - turnId, - role: 'assistant', - model: action.payload.model, - // Server-authoritative provenance: the client no longer summarizes - // fresh-agent items (the write-only summarizer was deleted); the - // Rust server tags every turn's summaryKind itself. - summary: '', - items, - }) - session.historyItems = session.turns - session.streamingText = '' - session.streamingActive = false - }, - setStreaming(state, action: PayloadAction) { - const session = resolveOrEnsureSession(state, action.payload, action.payload.active ? 'running' : 'idle') - if (!session) return - session.streamingActive = action.payload.active - if (action.payload.active && session.status === 'idle') { - writeSessionStatus(session, 'running') - } - }, - appendStreamDelta(state, action: PayloadAction) { + /** + * The turn opened a streamed content block: the session is streaming + * (busy follows `streamingActive`) until its result, an idle/exited/stuck + * status or an error clears it. A turn opens several blocks; only the + * first changes anything, so the sessions map is written once per turn, + * never per block or token (transcript-live P3). + */ + streamOpened(state, action: PayloadAction) { const session = resolveOrEnsureSession(state, action.payload, 'running') if (!session) return - session.streamingActive = true - session.streamingText += action.payload.text - if (session.status === 'idle') { - writeSessionStatus(session, 'running') - } - }, - clearStreaming(state, action: PayloadAction) { - const session = resolveOrEnsureSession(state, action.payload) - if (!session) return - session.streamingText = '' - session.streamingActive = false + if (!session.streamingActive) session.streamingActive = true + if (session.status === 'idle') writeSessionStatus(session, 'running') }, clearPendingCreateFailureForSession() {}, sessionExited(state, action: PayloadAction) { @@ -760,11 +668,8 @@ const freshAgentSlice = createSlice({ }) export const { - addAssistantMessage, addPermissionRequest, addQuestionRequest, - addUserMessage, - appendStreamDelta, applyRuntimeOwner, applyRuntimeOwnerFenceRefresh, applyRefusalFence, @@ -774,7 +679,6 @@ export const { clearRestoreFailure, clearSessionError, clearSessionLost, - clearStreaming, createFailed, freshAgentSnapshotReceived, materializeSession, @@ -793,7 +697,7 @@ export const { sessionSnapshotReceived, setAvailableModels, setSessionStatus, - setStreaming, + streamOpened, historyLoadFailed, historyLoadStarted, historyPageReceived, diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index e830b3d40..23f8ad4d4 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -114,7 +114,7 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { nextHistoryCursor?: string | null historyLoading?: boolean historyError?: string - streamingText: string + /** A streamed content block opened this turn (cleared when the turn ends). */ streamingActive: boolean pendingPermissions: Record pendingQuestions: Record diff --git a/test/e2e/pane-activity-indicator-flow.test.tsx b/test/e2e/pane-activity-indicator-flow.test.tsx index 503f3d6a1..569ac3c0f 100644 --- a/test/e2e/pane-activity-indicator-flow.test.tsx +++ b/test/e2e/pane-activity-indicator-flow.test.tsx @@ -118,7 +118,6 @@ function createFreshAgentSession( turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/e2e/pane-header-runtime-meta-flow.test.tsx b/test/e2e/pane-header-runtime-meta-flow.test.tsx index cdbd8bc2f..77e60fa3a 100644 --- a/test/e2e/pane-header-runtime-meta-flow.test.tsx +++ b/test/e2e/pane-header-runtime-meta-flow.test.tsx @@ -162,7 +162,6 @@ function createFreshAgentSession( turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/components/Sidebar.test.tsx b/test/unit/client/components/Sidebar.test.tsx index 9323319b6..7a990a0e1 100644 --- a/test/unit/client/components/Sidebar.test.tsx +++ b/test/unit/client/components/Sidebar.test.tsx @@ -5217,7 +5217,6 @@ describe('Sidebar Component - Session-Centric Display', () => { turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: input.streamingActive ?? true, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 0e11434bf..cdb0fcfa0 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -10,6 +10,7 @@ import { configureStore, type Middleware } from '@reduxjs/toolkit' import panesReducer from '@/store/panesSlice' import settingsReducer, { previewServerSettingsPatch, updateSettingsLocal } from '@/store/settingsSlice' import sessionsReducer, { applySessionsPatch, applyContextUsageExtras } from '@/store/sessionsSlice' +import freshAgentLiveReducer from '@/store/freshAgentLiveSlice' import freshAgentReducer, { applyRuntimeOwner, historyPageReceived, sessionError, sessionExited, sessionInit, sessionMetadataReceived, sessionSnapshotReceived, setSessionStatus, markSessionLost } from '@/store/freshAgentSlice' import { selectPaneOwnerFence } from '@/store/selectors/runtimeOwner' import tabsReducer, { closeTab, closePaneWithCleanup } from '@/store/tabsSlice' @@ -347,6 +348,7 @@ function createStore(tabTitleSetByUser = false, extraMiddleware: Middleware[] = panes: panesReducer, settings: settingsReducer, freshAgent: freshAgentReducer, + freshAgentLive: freshAgentLiveReducer, tabs: tabsReducer, // FreshAgentView reads connection.status to gate the .lost recovery // driver; preload ready so tests keep the pre-gate behavior. @@ -2774,22 +2776,25 @@ describe('FreshAgentView', () => { deliverThroughAppAndMountedView({ type: 'freshAgent.stream', - event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial' } }, + messageId: 'msg_single_projection', + event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + }) + deliverThroughAppAndMountedView({ + type: 'freshAgent.stream', + messageId: 'msg_single_projection', + event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'partial' } }, }) - expect(store.getState().freshAgent.sessions[sessionKey].streamingText).toBe('partial') deliverThroughAppAndMountedView({ type: 'freshAgent.assistant', model: 'codex-5', - content: [{ type: 'text', text: 'Final answer' }], - }) - const assistantSession = store.getState().freshAgent.sessions[sessionKey] - expect(assistantSession.turns).toHaveLength(1) - expect(assistantSession.turns[0]).toMatchObject({ - role: 'assistant', - model: 'codex-5', - summary: '', + content: [{ type: 'text', text: 'partial' }], + messageId: 'msg_single_projection', }) + // One projection: the streamed text reached the live tail once, and the + // assistant frame added no client-side turn. + expect(store.getState().freshAgentLive.bySession[sessionKey].blocks.map((block) => block.text)).toEqual(['partial']) + expect(store.getState().freshAgent.sessions[sessionKey].turns).toEqual([]) deliverThroughAppAndMountedView({ type: 'freshAgent.result', diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 4dc0201d9..99acf2f04 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -330,7 +330,6 @@ function createFreshClaudeSession( turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/lib/fresh-agent-stream-batcher.test.ts b/test/unit/client/lib/fresh-agent-stream-batcher.test.ts new file mode 100644 index 000000000..03fc532e5 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-stream-batcher.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + FreshAgentStreamBatcher, + LIVE_STREAM_FLUSH_MS, + type StreamBatcherScheduler, +} from '@/lib/fresh-agent-stream-batcher' +import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' + +type Scheduled = { fn: () => void; ms: number; cancelled: boolean } + +function manualScheduler() { + const scheduled: Scheduled[] = [] + const scheduler: StreamBatcherScheduler = { + set(fn, ms) { + const entry = { fn, ms, cancelled: false } + scheduled.push(entry) + return entry + }, + clear(handle) { + (handle as Scheduled).cancelled = true + }, + } + const fire = () => { + const due = scheduled.splice(0).filter((entry) => !entry.cancelled) + for (const entry of due) entry.fn() + } + return { scheduler, scheduled, fire } +} + +const claude = { sessionId: 's-1', sessionType: 'freshclaude' as const, provider: 'claude' as const } +const kilroy = { sessionId: 's-2', sessionType: 'kilroy' as const, provider: 'claude' as const } + +const delta = (text: string): LiveStreamOp => ({ op: 'delta', messageId: 'msg_1', index: 0, text }) + +describe('FreshAgentStreamBatcher', () => { + it('holds stream ops until the flush interval, then applies each session\'s ops in one action, in order', () => { + const actions: Array<{ type: string; payload: any }> = [] + const { scheduler, scheduled, fire } = manualScheduler() + const batcher = new FreshAgentStreamBatcher((action) => { actions.push(action as never) }, scheduler) + + batcher.enqueue(claude, { op: 'start', messageId: 'msg_1', index: 0, kind: 'text' }) + batcher.enqueue(claude, delta('Hel')) + batcher.enqueue(kilroy, delta('other')) + batcher.enqueue(claude, delta('lo')) + + expect(actions).toEqual([]) + // One timer for the whole burst, at the flush interval. + expect(scheduled.map((entry) => entry.ms)).toEqual([LIVE_STREAM_FLUSH_MS]) + + fire() + + expect(actions.map((action) => action.type)).toEqual(['freshAgentLive/streamOpsApplied', 'freshAgentLive/streamOpsApplied']) + expect(actions[0].payload).toEqual({ + ...claude, + ops: [ + { op: 'start', messageId: 'msg_1', index: 0, kind: 'text' }, + delta('Hel'), + delta('lo'), + ], + }) + expect(actions[1].payload).toEqual({ ...kilroy, ops: [delta('other')] }) + }) + + it('flushes one session on demand and keeps the others waiting for the timer', () => { + const actions: Array<{ type: string; payload: any }> = [] + const { scheduler, fire } = manualScheduler() + const batcher = new FreshAgentStreamBatcher((action) => { actions.push(action as never) }, scheduler) + + batcher.enqueue(claude, delta('a')) + batcher.enqueue(kilroy, delta('b')) + batcher.flush(claude) + + expect(actions).toEqual([{ type: 'freshAgentLive/streamOpsApplied', payload: { ...claude, ops: [delta('a')] } }]) + + fire() + expect(actions.map((action) => action.payload.sessionId)).toEqual(['s-1', 's-2']) + // Nothing left: a later timer would dispatch nothing. + batcher.flush() + expect(actions).toHaveLength(2) + }) + + it('dispatches nothing for a session with no pending ops and re-arms the timer after a flush', () => { + const actions: unknown[] = [] + const { scheduler, scheduled, fire } = manualScheduler() + const batcher = new FreshAgentStreamBatcher((action) => { actions.push(action) }, scheduler) + + batcher.flush(claude) + expect(actions).toEqual([]) + expect(scheduled).toHaveLength(0) + + batcher.enqueue(claude, delta('x')) + batcher.flush() + expect(actions).toHaveLength(1) + // The emptied queue cancelled its timer; the next op arms a new one. + fire() + expect(actions).toHaveLength(1) + batcher.enqueue(claude, delta('y')) + expect(scheduled.filter((entry) => !entry.cancelled)).toHaveLength(1) + fire() + expect(actions).toHaveLength(2) + }) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 9971a1057..9ea42c068 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -1,6 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { configureStore } from '@reduxjs/toolkit' import freshAgentReducer, { materializeSession } from '@/store/freshAgentSlice' +import freshAgentLiveReducer from '@/store/freshAgentLiveSlice' +import { LIVE_STREAM_FLUSH_MS } from '@/lib/fresh-agent-stream-batcher' import panesReducer, { initLayout, materializeFreshAgentSession, type PanesState } from '@/store/panesSlice' import turnCompletionReducer, { markPaneAttention, markTabAttention } from '@/store/turnCompletionSlice' import { @@ -18,6 +20,7 @@ function createFreshAgentStore() { return configureStore({ reducer: { freshAgent: freshAgentReducer, + freshAgentLive: freshAgentLiveReducer, }, }) } @@ -427,14 +430,24 @@ describe('fresh-agent-ws', () => { expect(sendEvent({ type: 'freshAgent.session.init', cliSessionId: 'cli-1', model: 'claude-opus-4-6', cwd: '/repo' })).toBe(true) expect(sendEvent({ type: 'freshAgent.session.metadata', cliSessionId: 'cli-2', model: 'claude-sonnet-4-6', tools: [{ name: 'Bash' }] })).toBe(true) expect(sendEvent({ type: 'freshAgent.status', status: 'running' })).toBe(true) - expect(sendEvent({ type: 'freshAgent.stream', event: { type: 'content_block_start' } })).toBe(true) - expect(sendEvent({ type: 'freshAgent.stream', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial' } } })).toBe(true) - expect(sendEvent({ type: 'freshAgent.stream', event: { type: 'content_block_stop' } })).toBe(true) + const stream = (event: Record) => sendEvent({ + type: 'freshAgent.stream', messageId: 'msg_parity', parentToolUseId: null, event, + }) + expect(stream({ type: 'message_start', message: { id: 'msg_parity' } })).toBe(true) + expect(stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })).toBe(true) + expect(stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'partial' } })).toBe(true) + expect(stream({ type: 'content_block_stop', index: 0 })).toBe(true) expect(sendEvent({ type: 'freshAgent.assistant', model: 'claude-sonnet-4-6', - content: [{ type: 'text', text: 'final answer' }], + content: [{ type: 'text', text: 'partial' }], + uuid: 'line-uuid-parity', + messageId: 'msg_parity', + parentToolUseId: null, })).toBe(true) + // The streamed text lives in the live tail; the assistant frame flushed it + // there without waiting for the batch timer. + expect(store.getState().freshAgentLive.bySession[key].blocks.map((block) => block.text)).toEqual(['partial']) expect(sendEvent({ type: 'freshAgent.result', costUsd: 0.02, usage: { input_tokens: 10, output_tokens: 5 } })).toBe(true) expect(sendEvent({ type: 'freshAgent.permission.request', @@ -458,18 +471,15 @@ describe('fresh-agent-ws', () => { expect(session).toMatchObject({ cliSessionId: 'cli-2', model: 'claude-sonnet-4-6', - streamingText: '', streamingActive: false, totalCostUsd: 0.02, totalInputTokens: 10, totalOutputTokens: 5, lastError: 'recoverable', }) - expect(session.turns.at(-1)).toMatchObject({ - role: 'assistant', - model: 'claude-sonnet-4-6', - summary: '', - }) + // Durable content comes from the snapshot only (transcript-live P1): an + // assistant frame never invents a client-side turn. + expect(session.turns).toEqual([]) expect(session.pendingPermissions).toEqual({}) expect(session.pendingQuestions['question-1']).toMatchObject({ questions: [{ question: 'Continue?' }], @@ -481,6 +491,77 @@ describe('fresh-agent-ws', () => { expect(store.getState().freshAgent.sessions[key]).toBeUndefined() }) + describe('stream deltas (transcript-live D1)', () => { + const sessionId = 'claude-stream-d1' + const key = `freshclaude:claude:${sessionId}` + const frame = (event: Record) => ({ + type: 'freshAgent.event', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + event: { sessionId, ...event }, + }) + const stream = (event: Record) => frame({ + type: 'freshAgent.stream', messageId: 'msg_d1', parentToolUseId: null, event, + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('leaves the sessions map untouched by 200 text deltas after the first block start; the text accumulates in the live tail', () => { + vi.useFakeTimers() + const store = createFreshAgentStore() + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.status', status: 'running' })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'message_start', message: { id: 'msg_d1' } })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + const sessionsAfterStart = store.getState().freshAgent.sessions + // The block start marks the turn as streaming (busy follows it) at once. + expect(sessionsAfterStart[key]).toMatchObject({ status: 'running', streamingActive: true }) + + const tokens = Array.from({ length: 200 }, (_, i) => `t${i} `) + for (const text of tokens) { + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } })) + } + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_stop', index: 0 })) + // A second block of the same turn does not write the sessions map either. + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 1, content_block: { type: 'thinking', thinking: '' } })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_delta', index: 1, delta: { type: 'thinking_delta', thinking: 'hmm' } })) + + // Batched: nothing reaches the tail before the flush interval. + expect(store.getState().freshAgentLive.bySession[key]).toBeUndefined() + vi.advanceTimersByTime(LIVE_STREAM_FLUSH_MS) + + expect(store.getState().freshAgent.sessions).toBe(sessionsAfterStart) + expect(store.getState().freshAgentLive.bySession[key].blocks).toEqual([ + { messageId: 'msg_d1', index: 0, kind: 'text', text: tokens.join('') }, + { messageId: 'msg_d1', index: 1, kind: 'thinking', text: 'hmm' }, + ]) + }) + + it('applies pending deltas before any other event of the session', () => { + vi.useFakeTimers() + const store = createFreshAgentStore() + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.status', status: 'running' })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'done' } })) + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.result', costUsd: 0.01 })) + + expect(store.getState().freshAgentLive.bySession[key].blocks.map((block) => block.text)).toEqual(['done']) + expect(store.getState().freshAgent.sessions[key]).toMatchObject({ status: 'idle', streamingActive: false }) + }) + + it('marks the session streaming again when the next turn opens a block', () => { + const store = createFreshAgentStore() + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.result', costUsd: 0.01 })) + expect(store.getState().freshAgent.sessions[key].streamingActive).toBe(false) + + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + expect(store.getState().freshAgent.sessions[key]).toMatchObject({ status: 'running', streamingActive: true }) + }) + }) + it('folds freshAgent.status(stuck) with no attention action; a companion turn.complete edge folds attention independently', () => { // Wedged-sidecar deadman fold: the STATUS frame itself dispatches no // attention — it must land (and stop the busy-driving streaming flag) diff --git a/test/unit/client/lib/pane-activity.test.ts b/test/unit/client/lib/pane-activity.test.ts index bbbdab9a4..00a3189ec 100644 --- a/test/unit/client/lib/pane-activity.test.ts +++ b/test/unit/client/lib/pane-activity.test.ts @@ -28,7 +28,6 @@ function freshAgentSession(input: { turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: input.streamingActive ?? true, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/store/selectors/sessionStatusTiers.test.ts b/test/unit/client/store/selectors/sessionStatusTiers.test.ts index 358f3eeee..5a890c0e1 100644 --- a/test/unit/client/store/selectors/sessionStatusTiers.test.ts +++ b/test/unit/client/store/selectors/sessionStatusTiers.test.ts @@ -71,7 +71,6 @@ function makeLiveFreshSession(liveSessionId: string, busy: boolean) { turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/store/sessionGreyTouch.test.ts b/test/unit/client/store/sessionGreyTouch.test.ts index e1d50f853..9038a5f47 100644 --- a/test/unit/client/store/sessionGreyTouch.test.ts +++ b/test/unit/client/store/sessionGreyTouch.test.ts @@ -78,7 +78,6 @@ function makeLiveFreshSessionState(liveSessionId: string, status: 'idle' | 'runn turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, diff --git a/test/unit/client/store/turnCompletionAttention.test.ts b/test/unit/client/store/turnCompletionAttention.test.ts index 9d373aa4a..d9f787b7d 100644 --- a/test/unit/client/store/turnCompletionAttention.test.ts +++ b/test/unit/client/store/turnCompletionAttention.test.ts @@ -154,7 +154,6 @@ describe('selectPaneBySessionKey', () => { turns: [], historyItems: [], historyBodies: {}, - streamingText: '', streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, From 7681f4ec1b6704ab3d7ea56abb60625e4c57e9af Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:24:26 -0700 Subject: [PATCH 16/45] test(fresh-agent): app-wide chrome commits nothing across a token burst (D2) TabBar, PaneContainer, Sidebar, MobileTabStrip, TabSwitcher and ContextMenuProvider, rendered against the app's reducer map, are fed 200 real freshAgent.stream frames through the websocket handler, each in its own act. Before D1 each committed once per token (PaneContainer twice); now none commits. Co-Authored-By: Claude Opus 5.5 --- ...esh-agent-stream-render-isolation.test.tsx | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 test/unit/client/components/fresh-agent-stream-render-isolation.test.tsx diff --git a/test/unit/client/components/fresh-agent-stream-render-isolation.test.tsx b/test/unit/client/components/fresh-agent-stream-render-isolation.test.tsx new file mode 100644 index 000000000..96a51174c --- /dev/null +++ b/test/unit/client/components/fresh-agent-stream-render-isolation.test.tsx @@ -0,0 +1,178 @@ +import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appReducers } from '@/store/appReducers' +import { initLayout } from '@/store/panesSlice' +import { addTab } from '@/store/tabsSlice' +import { sessionInit, setSessionStatus } from '@/store/freshAgentSlice' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import TabBar from '@/components/TabBar' +import Sidebar from '@/components/Sidebar' +import PaneContainer from '@/components/panes/PaneContainer' +import { MobileTabStrip } from '@/components/MobileTabStrip' +import { TabSwitcher } from '@/components/TabSwitcher' +import { ContextMenuProvider } from '@/components/context-menu/ContextMenuProvider' + +// Transcript-live plan D2 (acceptance 10): a streamed token reaches only the +// pane that renders it. The app-wide chrome (tab bar, pane container, sidebar, +// mobile strip, tab switcher, context-menu provider) must not commit once per +// token. Each is rendered against the app's real reducer map and fed real +// `freshAgent.stream` frames through the real websocket handler. + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: vi.fn(), + close: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn(() => () => {}), + onReconnect: vi.fn(() => () => {}), + }), +})) + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + api: { + get: vi.fn().mockRejectedValue(new Error('no server in tests')), + post: vi.fn().mockRejectedValue(new Error('no server in tests')), + patch: vi.fn().mockRejectedValue(new Error('no server in tests')), + put: vi.fn().mockRejectedValue(new Error('no server in tests')), + delete: vi.fn().mockRejectedValue(new Error('no server in tests')), + }, + searchSessions: vi.fn().mockResolvedValue({ results: [] }), + getTerminalDirectoryPage: vi.fn().mockResolvedValue({ items: [], nextCursor: null, revision: 1 }), + updateManagedRuntimeViewVisibility: vi.fn().mockResolvedValue(undefined), + } +}) + +// The pane's own view is the one surface that should follow the stream; it +// is measured by its own test. Stubbing it here leaves every commit inside +// the PaneContainer profiler attributable to the container and its chrome. +vi.mock('@/components/fresh-agent/FreshAgentView', () => ({ + default: () =>
, +})) + +const SESSION_ID = 'claude-render-isolation-1' +const locator = { sessionId: SESSION_ID, sessionType: 'freshclaude' as const, provider: 'claude' as const } + +function createAppStore() { + const store = configureStore({ + reducer: appReducers, + middleware: (getDefault) => getDefault({ + serializableCheck: false, + immutableCheck: false, + }), + }) + store.dispatch(addTab({ id: 'tab-1', title: 'Claude', mode: 'shell' })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId: SESSION_ID, + createRequestId: 'req-render-isolation', + status: 'running', + }, + })) + store.dispatch(sessionInit({ ...locator, cliSessionId: SESSION_ID, model: 'claude-sonnet-4-6' })) + store.dispatch(setSessionStatus({ ...locator, status: 'running' })) + return store +} + +function streamFrame(event: Record) { + return { + type: 'freshAgent.event', + ...locator, + event: { + type: 'freshAgent.stream', + sessionId: SESSION_ID, + messageId: 'msg_render_isolation', + parentToolUseId: null, + event, + }, + } +} + +async function settle(ms: number) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)) + }) +} + +describe('stream deltas and app-wide chrome (transcript-live D2)', () => { + afterEach(() => { + cleanup() + }) + + it('commits none of the app-wide components across a burst of 200 text deltas', async () => { + const store = createAppStore() + const commits: Record = {} + const onRender: ProfilerOnRenderCallback = (id) => { + commits[id] = (commits[id] ?? 0) + 1 + } + const measured = (id: string, node: ReactNode) => ( + {node} + ) + const layout = store.getState().panes.layouts['tab-1'] + expect(layout).toBeDefined() + + render( + + {measured('ContextMenuProvider', ( + {}} onToggleSidebar={() => {}} sidebarCollapsed={false}> +
+ + ))} + {measured('TabBar', )} + {measured('MobileTabStrip', {}} />)} + {measured('TabSwitcher', {}} />)} + {measured('Sidebar', {}} />)} + {measured('PaneContainer', )} + , + ) + // Let mount-time effects and their follow-up commits finish. + await settle(50) + + // The turn's first block opens the stream: one write that marks the + // session as streaming is allowed (busy indicators follow it). + act(() => { + handleFreshAgentMessage(store.dispatch, streamFrame({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + })) + }) + await settle(200) + const sessionsAfterStart = store.getState().freshAgent.sessions + for (const id of Object.keys(commits)) commits[id] = 0 + + // One act per frame: each token arrives as its own websocket message, so + // React gets the chance to commit after every one of them. + for (let i = 0; i < 200; i += 1) { + act(() => { + handleFreshAgentMessage(store.dispatch, streamFrame({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: `token-${i} ` }, + })) + }) + } + // Long enough for any batching of the deltas to flush. + await settle(300) + + expect(commits).toEqual({ + ContextMenuProvider: 0, + TabBar: 0, + MobileTabStrip: 0, + TabSwitcher: 0, + Sidebar: 0, + PaneContainer: 0, + }) + expect(store.getState().freshAgent.sessions).toBe(sessionsAfterStart) + }) +}) From 5d4503ad00d6847be4bd18720f1af81972482812 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:27:26 -0700 Subject: [PATCH 17/45] fix(fresh-agent): an exit for a session this client never saw is ignored sessionExited resolved a key from the full locator and wrote the status of a session record that did not exist, so the reducer threw inside the websocket handler. Co-Authored-By: Claude Opus 5.5 --- src/store/freshAgentSlice.ts | 5 +++-- test/unit/client/store/freshAgentSlice.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 6513e9997..1675c227f 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -567,8 +567,9 @@ const freshAgentSlice = createSlice({ clearPendingCreateFailureForSession() {}, sessionExited(state, action: PayloadAction) { const key = resolveSessionKey(state, action.payload) - if (!key) return - writeSessionStatus(state.sessions[key], 'exited') + const session = key ? state.sessions[key] : undefined + if (!session) return + writeSessionStatus(session, 'exited') }, /** diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts index a25fd8f24..9934eb3bb 100644 --- a/test/unit/client/store/freshAgentSlice.test.ts +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -11,6 +11,7 @@ import reducer, { registerPendingCreate, sessionCreated, sessionError, + sessionExited, sessionInit, sessionSnapshotReceived, setSessionStatus, @@ -24,6 +25,11 @@ describe('freshAgentSlice busy/streaming clearing', () => { return reducer(undefined, sessionSnapshotReceived({ ...loc, latestTurnId: null, status: 'running', streamingActive: true })) } + it('sessionExited for a session this client never saw changes nothing', () => { + const before = reducer(undefined, { type: '@@init' }) + expect(reducer(before, sessionExited(loc))).toBe(before) + }) + it('setSessionStatus(idle) clears streamingActive so the pane does not stay blue', () => { let state = streaming() expect(state.sessions[key].streamingActive).toBe(true) From 8333e7e833064374b759647d8b20751b60ff2182 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:27:26 -0700 Subject: [PATCH 18/45] feat(fresh-agent): the live tail binds, lands and clears with its session (D3) - assistantBlockBound gives the first unbound streamed block of the same API message and kind its transcript line uuid (the message's full text or tool input wins over the stream); subagent messages bind nothing. - transcriptAnnounced records each main-thread line the CLI will write; transcriptLanded removes the blocks and expectations a snapshot holds; transcriptExpectationsDropped ends the re-poll for lines that never land. - liveRetrySet shows an API retry (freshAgent.retry) until the next block starts; it never touches the session (P4). - The tail moves with materializeSession and clears on exit, lost, kill, status exited/stuck and a non-restore error (plan N5); idle does not clear it, so the text stays until its lines land. Co-Authored-By: Claude Opus 5.5 --- src/lib/fresh-agent-ws.ts | 48 +++- src/store/freshAgentLiveSlice.ts | 176 ++++++++++++- .../client/store/freshAgentLiveSlice.test.ts | 236 ++++++++++++++++++ 3 files changed, 452 insertions(+), 8 deletions(-) create mode 100644 test/unit/client/store/freshAgentLiveSlice.test.ts diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index ca197dd6f..900ac7321 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -11,7 +11,8 @@ import { applyFreshAgentCompletion, applyFreshAgentWaiting } from '@/store/turnC import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' -import type { LiveBlockKind, LiveStreamOp } from '@/store/freshAgentLiveTypes' +import type { LiveBlockKind, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' +import { assistantBlockBound, liveRetrySet, transcriptAnnounced } from '@/store/freshAgentLiveSlice' import { addPermissionRequest, addQuestionRequest, @@ -367,8 +368,28 @@ function isObject(value: unknown): value is Record { * thread (the sidecar already drops subagent events; a frame that still * names a parent tool use is ignored). Anything else maps to nothing. */ +function hasParentToolUse(frame: Record): boolean { + return typeof frame.parentToolUseId === 'string' && frame.parentToolUseId.length > 0 +} + +/** A `freshAgent.retry` frame (the sidecar's `sdk.retry`), or nothing if it is malformed. */ +function parseLiveRetry(frame: Record): LiveRetry | undefined { + if (typeof frame.attempt !== 'number' || !Number.isInteger(frame.attempt) || frame.attempt < 1) return undefined + const optionalNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined) + const maxRetries = optionalNumber(frame.maxRetries) + const delayMs = optionalNumber(frame.delayMs) + const errorStatus = frame.errorStatus === null ? null : optionalNumber(frame.errorStatus) + return { + attempt: frame.attempt, + ...(maxRetries !== undefined ? { maxRetries } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + ...(errorStatus !== undefined ? { errorStatus } : {}), + ...(typeof frame.error === 'string' && frame.error ? { error: frame.error } : {}), + } +} + function parseLiveStreamOp(frame: Record): LiveStreamOp | undefined { - if (typeof frame.parentToolUseId === 'string' && frame.parentToolUseId) return undefined + if (hasParentToolUse(frame)) return undefined const event = frame.event if (!isObject(event) || typeof event.index !== 'number') return undefined const messageId = typeof frame.messageId === 'string' && frame.messageId ? frame.messageId : null @@ -427,6 +448,11 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh })) return true case 'freshAgent.session.changed': + // A main-thread transcript line is on its way: the pane's landing check + // expects it in a snapshot (a subagent's lines refresh its delegation). + if (event.reason === 'transcript' && typeof event.uuid === 'string' && event.uuid && !hasParentToolUse(event)) { + dispatch(transcriptAnnounced({ ...locator, uuid: event.uuid })) + } return true case 'freshAgent.session.init': dispatch(sessionInit({ @@ -482,9 +508,23 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh return true } case 'freshAgent.assistant': - // Durable content comes from the snapshot (transcript-live P1); the - // pending stream events were applied above. + // Durable content comes from the snapshot (transcript-live P1). The + // message only names the transcript line its streamed block becomes + // (the pending stream events were applied above). + if (typeof event.uuid === 'string' && event.uuid && !hasParentToolUse(event) && Array.isArray(event.content)) { + dispatch(assistantBlockBound({ + ...locator, + uuid: event.uuid, + ...(typeof event.messageId === 'string' && event.messageId ? { messageId: event.messageId } : {}), + content: event.content.filter(isObject), + })) + } return true + case 'freshAgent.retry': { + const retry = parseLiveRetry(event) + if (retry) dispatch(liveRetrySet({ ...locator, ...retry })) + return true + } case 'freshAgent.stream': { const op = parseLiveStreamOp(event) if (!op) return true diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index b384d89a7..91759494b 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -1,9 +1,20 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import { + markSessionLost, + materializeSession, + removeSession, + sessionError, + sessionExited, + setSessionStatus, +} from './freshAgentSlice' import type { FreshAgentSessionLocator } from './freshAgentTypes' import type { FreshAgentLiveSession, FreshAgentLiveState, + LiveBlock, + LiveBlockKind, + LiveRetry, LiveStreamOp, } from './freshAgentLiveTypes' @@ -12,13 +23,56 @@ const initialState: FreshAgentLiveState = { noticeSeq: 0, } +/** The payload shape of the freshAgent session actions this slice follows. */ +type SessionMutationPayload = { + sessionId: string + sessionType?: FreshAgentSessionLocator['sessionType'] + provider?: FreshAgentSessionLocator['provider'] +} + function emptySession(): FreshAgentLiveSession { return { blocks: [], expectedTurnIds: [] } } +function sessionFor(state: FreshAgentLiveState, locator: FreshAgentSessionLocator): FreshAgentLiveSession { + const key = makeFreshAgentSessionKey(locator) + state.bySession[key] ??= emptySession() + return state.bySession[key] +} + +/** Session keys are `${sessionType}:${provider}:${sessionId}`; the id may itself contain ':'. */ +function sessionIdOfKey(key: string): string { + return key.split(':').slice(2).join(':') +} + +/** + * The tails a freshAgent session action names: its exact key when it carries + * the full locator, else every tail of that session id (the sessions slice + * resolves such payloads the same way). + */ +function tailKeysFor(state: FreshAgentLiveState, payload: SessionMutationPayload): string[] { + if (payload.sessionType && payload.provider) { + const key = makeFreshAgentSessionKey({ sessionId: payload.sessionId, sessionType: payload.sessionType, provider: payload.provider }) + return state.bySession[key] ? [key] : [] + } + return Object.keys(state.bySession).filter((key) => sessionIdOfKey(key) === payload.sessionId) +} + +function clearTail(session: FreshAgentLiveSession): void { + if (session.blocks.length > 0) session.blocks = [] + if (session.expectedTurnIds.length > 0) session.expectedTurnIds = [] + if (session.retry) delete session.retry +} + +function clearTailsFor(state: FreshAgentLiveState, payload: SessionMutationPayload): void { + for (const key of tailKeysFor(state, payload)) clearTail(state.bySession[key]) +} + function applyStreamOp(session: FreshAgentLiveSession, op: LiveStreamOp): void { const existing = session.blocks.find((block) => block.messageId === op.messageId && block.index === op.index) if (op.op === 'start') { + // The retried request is answering: the retry is over. + if (session.retry) delete session.retry if (existing) return session.blocks.push({ messageId: op.messageId, @@ -36,6 +90,25 @@ function applyStreamOp(session: FreshAgentLiveSession, op: LiveStreamOp): void { existing.text += op.text } +const CONTENT_KINDS: ReadonlySet = new Set(['text', 'thinking', 'tool_use']) + +/** The completed block an assistant message carries (the CLI sends one per block, F2). */ +function boundContent(content: Array>): Record | undefined { + return content.find((block) => typeof block.type === 'string' && CONTENT_KINDS.has(block.type)) +} + +function bindBlock(block: LiveBlock, uuid: string, content: Record): void { + block.uuid = uuid + // The message carries the whole block: it wins over whatever the stream delivered. + if (block.kind === 'text' && typeof content.text === 'string') block.text = content.text + if (block.kind === 'thinking' && typeof content.thinking === 'string') block.text = content.thinking + if (block.kind === 'tool_use') { + if (typeof content.name === 'string') block.name = content.name + if (typeof content.id === 'string') block.toolUseId = content.id + if (content.input !== undefined) block.input = content.input + } +} + const freshAgentLiveSlice = createSlice({ name: 'freshAgentLive', initialState, @@ -44,14 +117,109 @@ const freshAgentLiveSlice = createSlice({ streamOpsApplied(state, action: PayloadAction) { const { ops, ...locator } = action.payload if (ops.length === 0) return - const key = makeFreshAgentSessionKey(locator) - const session = state.bySession[key] ?? emptySession() + const session = sessionFor(state, locator) for (const op of ops) applyStreamOp(session, op) - state.bySession[key] = session }, + + /** + * A main-thread assistant message: the transcript line `uuid` it becomes + * belongs to the first streamed block of the same API message and kind + * that has no line yet, so the tail can hide it once that line lands. + */ + assistantBlockBound(state, action: PayloadAction> + }>) { + const { uuid, messageId, content, ...locator } = action.payload + const tail = state.bySession[makeFreshAgentSessionKey(locator)] + const block = boundContent(content) + if (!tail || !block) return + const target = tail.blocks.find((candidate) => ( + candidate.uuid === undefined + && candidate.messageId === (messageId ?? null) + && candidate.kind === block.type + )) + if (target) bindBlock(target, uuid, block) + }, + + /** The CLI will write this main-thread line to the transcript. */ + transcriptAnnounced(state, action: PayloadAction) { + const { uuid, ...locator } = action.payload + const session = sessionFor(state, locator) + if (!session.expectedTurnIds.includes(uuid)) session.expectedTurnIds.push(uuid) + }, + + /** A committed snapshot holds these line uuids: their blocks and expectations are done. */ + transcriptLanded(state, action: PayloadAction<{ sessionKey: string; present: readonly string[] }>) { + const session = state.bySession[action.payload.sessionKey] + if (!session) return + const present = new Set(action.payload.present) + if (session.blocks.some((block) => block.uuid !== undefined && present.has(block.uuid))) { + session.blocks = session.blocks.filter((block) => block.uuid === undefined || !present.has(block.uuid)) + } + if (session.expectedTurnIds.some((uuid) => present.has(uuid))) { + session.expectedTurnIds = session.expectedTurnIds.filter((uuid) => !present.has(uuid)) + } + }, + + /** The pane stopped re-polling for these lines (they never landed in time). */ + transcriptExpectationsDropped(state, action: PayloadAction<{ sessionKey: string; uuids: readonly string[] }>) { + const session = state.bySession[action.payload.sessionKey] + if (!session) return + const dropped = new Set(action.payload.uuids) + if (session.expectedTurnIds.some((uuid) => dropped.has(uuid))) { + session.expectedTurnIds = session.expectedTurnIds.filter((uuid) => !dropped.has(uuid)) + } + }, + + /** The CLI is retrying a failed API request (shown until the next block starts). */ + liveRetrySet(state, action: PayloadAction) { + const { sessionId, sessionType, provider, ...retry } = action.payload + sessionFor(state, { sessionId, sessionType, provider }).retry = retry + }, + + /** The pane's turn has landed and ended: nothing live is left to show. */ + liveTailCleared(state, action: PayloadAction<{ sessionKey: string }>) { + const session = state.bySession[action.payload.sessionKey] + if (session) clearTail(session) + }, + }, + extraReducers: (builder) => { + builder + .addCase(materializeSession, (state, action) => { + const { previousSessionId, sessionId, sessionType, provider } = action.payload + const previousKey = makeFreshAgentSessionKey({ sessionId: previousSessionId, sessionType, provider }) + const nextKey = makeFreshAgentSessionKey({ sessionId, sessionType, provider }) + const previous = state.bySession[previousKey] + if (!previous || previousKey === nextKey) return + state.bySession[nextKey] = previous + delete state.bySession[previousKey] + }) + .addCase(removeSession, (state, action) => { + for (const key of tailKeysFor(state, action.payload)) delete state.bySession[key] + }) + .addCase(sessionExited, (state, action) => clearTailsFor(state, action.payload)) + .addCase(markSessionLost, (state, action) => clearTailsFor(state, action.payload)) + .addCase(setSessionStatus, (state, action) => { + // Idle is not here: the pane clears the tail once the turn's lines landed, + // so the text never blinks out between the result and the snapshot. + if (action.payload.status === 'exited' || action.payload.status === 'stuck') clearTailsFor(state, action.payload) + }) + .addCase(sessionError, (state, action) => { + if (!action.payload.code?.startsWith('RESTORE_')) clearTailsFor(state, action.payload) + }) }, }) -export const { streamOpsApplied } = freshAgentLiveSlice.actions +export const { + assistantBlockBound, + liveRetrySet, + liveTailCleared, + streamOpsApplied, + transcriptAnnounced, + transcriptExpectationsDropped, + transcriptLanded, +} = freshAgentLiveSlice.actions export default freshAgentLiveSlice.reducer diff --git a/test/unit/client/store/freshAgentLiveSlice.test.ts b/test/unit/client/store/freshAgentLiveSlice.test.ts new file mode 100644 index 000000000..21c785c19 --- /dev/null +++ b/test/unit/client/store/freshAgentLiveSlice.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' +import freshAgentReducer, { + markSessionLost, + materializeSession, + removeSession, + sessionError, + sessionExited, + setSessionStatus, +} from '@/store/freshAgentSlice' +import freshAgentLiveReducer, { + assistantBlockBound, + liveRetrySet, + liveTailCleared, + streamOpsApplied, + transcriptAnnounced, + transcriptExpectationsDropped, + transcriptLanded, +} from '@/store/freshAgentLiveSlice' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' +import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' + +const locator = { sessionId: 'live-1', sessionType: 'freshclaude' as const, provider: 'claude' as const } +const KEY = 'freshclaude:claude:live-1' + +function createStore() { + return configureStore({ + reducer: { freshAgent: freshAgentReducer, freshAgentLive: freshAgentLiveReducer }, + }) +} + +type Store = ReturnType + +const start = (messageId: string, index: number, kind: 'text' | 'thinking' | 'tool_use', extra: Partial = {}): LiveStreamOp => ( + { op: 'start', messageId, index, kind, ...extra } as LiveStreamOp +) +const delta = (messageId: string, index: number, text: string): LiveStreamOp => ({ op: 'delta', messageId, index, text }) + +function apply(store: Store, ...ops: LiveStreamOp[]) { + store.dispatch(streamOpsApplied({ ...locator, ops })) +} + +function tail(store: Store, key = KEY) { + return store.getState().freshAgentLive.bySession[key] +} + +/** One assistant message per content block, all sharing the API message id (real-CLI fact F2). */ +function streamTurn(store: Store) { + apply(store, + start('msg_1', 0, 'thinking'), delta('msg_1', 0, 'pondering'), + start('msg_1', 1, 'text'), delta('msg_1', 1, 'Hello'), + start('msg_1', 2, 'tool_use', { name: 'Read', toolUseId: 'toolu_1' } as Partial), + ) +} + +describe('freshAgentLive slice (transcript-live D3)', () => { + it('binds each assistant message to the first unbound streamed block of its message id and kind', () => { + const store = createStore() + streamTurn(store) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-text', messageId: 'msg_1', content: [{ type: 'text', text: 'Hello' }] })) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-think', messageId: 'msg_1', content: [{ type: 'thinking', thinking: 'pondering' }] })) + store.dispatch(assistantBlockBound({ + ...locator, + uuid: 'u-tool', + messageId: 'msg_1', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/repo/a.ts' } }], + })) + + expect(tail(store).blocks).toEqual([ + { messageId: 'msg_1', index: 0, kind: 'thinking', text: 'pondering', uuid: 'u-think' }, + { messageId: 'msg_1', index: 1, kind: 'text', text: 'Hello', uuid: 'u-text' }, + { messageId: 'msg_1', index: 2, kind: 'tool_use', text: '', name: 'Read', toolUseId: 'toolu_1', uuid: 'u-tool', input: { file_path: '/repo/a.ts' } }, + ]) + }) + + it('binds a block of another message id to nothing and binds no block twice', () => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'one'), start('msg_2', 0, 'text'), delta('msg_2', 0, 'two')) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-2', messageId: 'msg_2', content: [{ type: 'text', text: 'two' }] })) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-3', messageId: 'msg_2', content: [{ type: 'text', text: 'three' }] })) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-9', messageId: 'msg_9', content: [{ type: 'text', text: 'never streamed' }] })) + + expect(tail(store).blocks.map((block) => [block.messageId, block.text, block.uuid])).toEqual([ + ['msg_1', 'one', undefined], + ['msg_2', 'two', 'u-2'], + ]) + }) + + it('takes the bound message\'s text when the stream missed part of it', () => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'Hel')) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-1', messageId: 'msg_1', content: [{ type: 'text', text: 'Hello, world' }] })) + expect(tail(store).blocks[0]).toMatchObject({ text: 'Hello, world', uuid: 'u-1' }) + }) + + it('drops deltas for a block whose start it never saw', () => { + const store = createStore() + apply(store, delta('msg_1', 0, 'mid-stream'), start('msg_1', 1, 'text'), delta('msg_1', 1, 'seen')) + expect(tail(store).blocks).toEqual([{ messageId: 'msg_1', index: 1, kind: 'text', text: 'seen' }]) + }) + + it('removes the blocks and expectations a snapshot shows have landed', () => { + const store = createStore() + streamTurn(store) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-think', messageId: 'msg_1', content: [{ type: 'thinking', thinking: 'pondering' }] })) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-text', messageId: 'msg_1', content: [{ type: 'text', text: 'Hello' }] })) + for (const uuid of ['u-think', 'u-text', 'u-result']) store.dispatch(transcriptAnnounced({ ...locator, uuid })) + store.dispatch(transcriptAnnounced({ ...locator, uuid: 'u-text' })) + expect(tail(store).expectedTurnIds).toEqual(['u-think', 'u-text', 'u-result']) + + store.dispatch(transcriptLanded({ sessionKey: KEY, present: ['u-think', 'u-result', 'older-line'] })) + + expect(tail(store).blocks.map((block) => block.uuid ?? block.kind)).toEqual(['u-text', 'tool_use']) + expect(tail(store).expectedTurnIds).toEqual(['u-text']) + + store.dispatch(transcriptExpectationsDropped({ sessionKey: KEY, uuids: ['u-text'] })) + expect(tail(store).expectedTurnIds).toEqual([]) + // Giving up on the re-poll keeps the block: it still shows until it lands or the tail clears. + expect(tail(store).blocks.map((block) => block.uuid ?? block.kind)).toEqual(['u-text', 'tool_use']) + }) + + it('shows a retry until the next block starts', () => { + const store = createStore() + store.dispatch(liveRetrySet({ ...locator, attempt: 2, maxRetries: 10, delayMs: 1200, errorStatus: 529, error: 'overloaded' })) + expect(tail(store).retry).toEqual({ attempt: 2, maxRetries: 10, delayMs: 1200, errorStatus: 529, error: 'overloaded' }) + apply(store, delta('msg_1', 0, 'not a start')) + expect(tail(store).retry).toBeDefined() + apply(store, start('msg_1', 0, 'text')) + expect(tail(store).retry).toBeUndefined() + }) + + it('moves the tail to the new key when the session materializes', () => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'carried')) + store.dispatch(transcriptAnnounced({ ...locator, uuid: 'u-1' })) + store.dispatch(materializeSession({ ...locator, previousSessionId: 'live-1', sessionId: 'live-real' })) + + expect(tail(store)).toBeUndefined() + expect(tail(store, 'freshclaude:claude:live-real')).toMatchObject({ + blocks: [{ text: 'carried' }], + expectedTurnIds: ['u-1'], + }) + }) + + it.each([ + ['the session exits', (store: Store) => store.dispatch(sessionExited(locator))], + ['the session is lost', (store: Store) => { + store.dispatch(setSessionStatus({ ...locator, status: 'running' })) + store.dispatch(markSessionLost(locator)) + }], + ['the session is killed', (store: Store) => store.dispatch(removeSession(locator))], + ['the status turns exited', (store: Store) => store.dispatch(setSessionStatus({ ...locator, status: 'exited' }))], + ['the status turns stuck', (store: Store) => store.dispatch(setSessionStatus({ ...locator, status: 'stuck' }))], + ['a turn error ends it', (store: Store) => store.dispatch(sessionError({ ...locator, message: 'boom' }))], + ['the pane clears it', (store: Store) => store.dispatch(liveTailCleared({ sessionKey: KEY }))], + ])('clears the tail when %s', (_label, end) => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'gone')) + store.dispatch(liveRetrySet({ ...locator, attempt: 1 })) + store.dispatch(transcriptAnnounced({ ...locator, uuid: 'u-1' })) + end(store) + expect(tail(store)?.blocks ?? []).toEqual([]) + expect(tail(store)?.retry).toBeUndefined() + expect(tail(store)?.expectedTurnIds ?? []).toEqual([]) + }) + + it.each([ + ['the status turns idle (the pane clears it once the turn has landed)', (store: Store) => store.dispatch(setSessionStatus({ ...locator, status: 'idle' }))], + ['a restore failure is reported', (store: Store) => store.dispatch(sessionError({ ...locator, code: 'RESTORE_NOT_FOUND', message: 'gone' }))], + ])('keeps the tail when %s', (_label, event) => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'kept')) + event(store) + expect(tail(store).blocks.map((block) => block.text)).toEqual(['kept']) + }) + + it('never writes a session that has no tail', () => { + const store = createStore() + const before = store.getState().freshAgentLive + store.dispatch(sessionExited(locator)) + store.dispatch(liveTailCleared({ sessionKey: KEY })) + store.dispatch(transcriptLanded({ sessionKey: KEY, present: ['x'] })) + expect(store.getState().freshAgentLive).toBe(before) + }) +}) + +describe('websocket routing into the live tail (transcript-live D3)', () => { + const frame = (event: Record) => ({ type: 'freshAgent.event', ...locator, event: { sessionId: locator.sessionId, ...event } }) + const stream = (event: Record, extra: Record = {}) => frame({ + type: 'freshAgent.stream', messageId: 'msg_1', parentToolUseId: null, event, ...extra, + }) + + it('binds top-level assistant messages, expects announced lines, and ignores a subagent\'s', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hi' } })) + handleFreshAgentMessage(store.dispatch, frame({ + type: 'freshAgent.assistant', content: [{ type: 'text', text: 'Hi' }], uuid: 'u-main', messageId: 'msg_1', parentToolUseId: null, + })) + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-main', parentToolUseId: null })) + // A subagent's assistant message and its announcement belong to the delegation, not the tail. + handleFreshAgentMessage(store.dispatch, frame({ + type: 'freshAgent.assistant', content: [{ type: 'text', text: 'sub' }], uuid: 'u-sub', messageId: 'msg_1', parentToolUseId: 'toolu_agent', + })) + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-sub', parentToolUseId: 'toolu_agent' })) + // Other session.changed reasons are not transcript lines. + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.session.changed', reason: 'session-commands' })) + + expect(tail(store).blocks).toEqual([{ messageId: 'msg_1', index: 0, kind: 'text', text: 'Hi', uuid: 'u-main' }]) + expect(tail(store).expectedTurnIds).toEqual(['u-main']) + }) + + it('shows an API retry and clears it when the retried request streams', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.status', status: 'running' })) + const sessionsBefore = store.getState().freshAgent.sessions + handleFreshAgentMessage(store.dispatch, frame({ + type: 'freshAgent.retry', attempt: 1, maxRetries: 10, delayMs: 500, errorStatus: 529, error: 'overloaded', + })) + expect(tail(store).retry).toEqual({ attempt: 1, maxRetries: 10, delayMs: 500, errorStatus: 529, error: 'overloaded' }) + // A retry never ends the turn or touches the session (transcript-live P4). + expect(store.getState().freshAgent.sessions).toBe(sessionsBefore) + expect(store.getState().freshAgent.sessions[KEY].status).toBe('running') + + handleFreshAgentMessage(store.dispatch, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + streamBatcherFor(store.dispatch).flush() + expect(tail(store).retry).toBeUndefined() + }) + + it('drops a retry frame without a positive attempt', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.retry', attempt: 'soon' })) + expect(tail(store)).toBeUndefined() + }) +}) From 7dbabdaa55427316f483d9ec2fbc379ee249775d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:30:50 -0700 Subject: [PATCH 19/45] feat(fresh-agent): a live tail renders the running turn like the turn it becomes (D4) FreshAgentLiveTail selects only its own session's tail and builds one synthetic assistant turn from the blocks whose transcript line the rendered snapshot does not hold yet, plus the API retry in progress (last, so it is the strip's live row). It renders through the transcript's own layout and turn article: streaming markdown text, the live thinking strip, a tool row named at block start (its input preview once the message carries it) and the Retrying reel look exactly like durable content. FreshAgentTranscript gains a tail slot after the turns and a TranscriptFollowContext: the tail asks to follow on growth and the transcript pins to the bottom only while the reader is there. A live article has no turn actions, no turn index or gesture ownership, and is aria-busy. Co-Authored-By: Claude Opus 5.5 --- .../fresh-agent/FreshAgentLiveTail.tsx | 148 +++++++++++ .../fresh-agent/FreshAgentTranscript.tsx | 141 ++++++++-- .../fresh-agent/FreshAgentLiveTail.test.tsx | 241 ++++++++++++++++++ 3 files changed, 508 insertions(+), 22 deletions(-) create mode 100644 src/components/fresh-agent/FreshAgentLiveTail.tsx create mode 100644 test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx diff --git a/src/components/fresh-agent/FreshAgentLiveTail.tsx b/src/components/fresh-agent/FreshAgentLiveTail.tsx new file mode 100644 index 000000000..81bb60341 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentLiveTail.tsx @@ -0,0 +1,148 @@ +import { memo, useContext, useLayoutEffect, useMemo } from 'react' +import type { FreshAgentTranscriptItem, FreshAgentTurn } from '@shared/fresh-agent-contract' +import { useAppSelector } from '@/store/hooks' +import type { FreshAgentLiveSession, LiveBlock, LiveRetry } from '@/store/freshAgentLiveTypes' +import { + buildTranscriptLayout, + FreshAgentTurnArticle, + selectLiveActivityBlockIdFromLayout, + TranscriptFollowContext, + type TurnActionProps, +} from './FreshAgentTranscript' + +/** The live response is not a turn yet: it offers no turn actions. */ +const NO_TURN_ACTIONS: TurnActionProps = { canFork: false } + +const LIVE_TURN_ID = '__live-tail' + +function liveItemId(block: LiveBlock): string { + return `live:${block.messageId ?? 'unknown'}:${block.index}` +} + +/** "overloaded (529) · retrying in 1s" — what failed and when the CLI tries again. */ +export function describeRetry(retry: LiveRetry): string | undefined { + const cause = [ + retry.error, + typeof retry.errorStatus === 'number' ? `(${retry.errorStatus})` : undefined, + ].filter(Boolean).join(' ') + const wait = typeof retry.delayMs === 'number' + ? `retrying in ${Math.max(1, Math.round(retry.delayMs / 1000))}s` + : undefined + const text = [cause, wait].filter(Boolean).join(' · ') + return text || undefined +} + +function blockItem(block: LiveBlock): FreshAgentTranscriptItem { + const id = liveItemId(block) + switch (block.kind) { + case 'text': + return { id, kind: 'text', text: block.text } + case 'thinking': + return { id, kind: 'thinking', text: block.text } + case 'tool_use': + return { + id, + kind: 'tool_use', + toolUseId: block.toolUseId ?? id, + name: block.name ?? 'Tool', + ...(block.input !== undefined ? { input: block.input as never } : {}), + } + } +} + +/** + * The live tail's one synthetic turn: the blocks whose transcript line is not + * on screen yet (a block whose uuid the rendered snapshot holds is already + * shown there), then the API retry in progress — the newest thing happening, + * so it is the strip's live row. + */ +export function buildLiveTailTurn( + tail: FreshAgentLiveSession | undefined, + landedTurnIds: ReadonlySet, +): FreshAgentTurn | null { + if (!tail) return null + const items: FreshAgentTranscriptItem[] = tail.blocks + .filter((block) => block.uuid === undefined || !landedTurnIds.has(block.uuid)) + .map(blockItem) + if (tail.retry) { + const error = describeRetry(tail.retry) + items.push({ id: `${LIVE_TURN_ID}:retry`, kind: 'retry', attempt: tail.retry.attempt, ...(error ? { error } : {}) }) + } + if (items.length === 0) return null + return { id: LIVE_TURN_ID, turnId: LIVE_TURN_ID, role: 'assistant', source: 'live', summary: '', items } +} + +/** A cheap fingerprint of what the tail shows: it changes whenever the tail grows. */ +function tailSignature(turn: FreshAgentTurn | null): string { + if (!turn) return '' + return turn.items.map((item) => { + if (item.kind === 'text' || item.kind === 'thinking') return `${item.id}:${item.text.length}` + if (item.kind === 'retry') return `${item.id}:${item.attempt}` + if (item.kind === 'tool_use') return `${item.id}:${item.input === undefined ? 0 : 1}` + return item.id + }).join('|') +} + +/** + * What a running turn shows before its transcript lines land in a snapshot + * (transcript-live plan §4.4): streaming text and thinking, a tool that just + * started, and an API retry in progress. It selects only its own session's + * tail, so a token re-renders this component alone, and it renders through + * the transcript's own layout and turn article, so live content looks exactly + * like the durable turn that replaces it. + */ +function FreshAgentLiveTailImpl({ + sessionKey, + landedTurnIds, + agentLabel, + expandThinking = false, + expandTools = false, +}: { + sessionKey: string + /** Line uuids the rendered snapshot already shows. */ + landedTurnIds: ReadonlySet + agentLabel?: string + expandThinking?: boolean + expandTools?: boolean +}) { + const tail = useAppSelector((state) => state.freshAgentLive?.bySession[sessionKey]) + const follow = useContext(TranscriptFollowContext) + const turn = useMemo(() => buildLiveTailTurn(tail, landedTurnIds), [tail, landedTurnIds]) + const layout = useMemo(() => { + if (!turn) return null + const built = buildTranscriptLayout([turn]) + return { + blocks: built.layouts[0]?.blocks ?? [], + liveActivityBlockId: selectLiveActivityBlockIdFromLayout(built.layouts, [turn], true, built.tail), + } + }, [turn]) + const signature = tailSignature(turn) + const requestFollow = follow?.requestFollow + + useLayoutEffect(() => { + if (signature) requestFollow?.() + }, [requestFollow, signature]) + + if (!turn || !layout || layout.blocks.length === 0) return null + const continuesAssistant = follow?.lastTurnRole === 'assistant' + return ( + + ) +} + +export const FreshAgentLiveTail = memo(FreshAgentLiveTailImpl) diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx index 25375633a..612ffcfdd 100644 --- a/src/components/fresh-agent/FreshAgentTranscript.tsx +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createContext, forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { ChevronDown, ChevronRight, ChevronUp, Loader2, Redo2, X } from 'lucide-react' import SlotReel from '@/components/fresh-agent/shared/SlotReel' import { formatThoughtDuration } from '@/components/fresh-agent/shared/format-duration' @@ -256,7 +256,7 @@ type RenderBlock = | { kind: 'item'; item: FreshAgentTranscriptItem } | { kind: 'activity'; id: string; rows: ActivityRow[] } -type TurnLayout = { blocks: RenderBlock[] } +export type TurnLayout = { blocks: RenderBlock[] } /** Mirrors FreshAgentItemCard's null-render path for text items: a text item * that renders nothing must not close an open line (nothing visibly between). */ @@ -297,7 +297,7 @@ type LineCaption = { id: string; text: string; atItemIndex: number } /** A turn that materially contributes items to an activity line. */ type LineMember = { turnIndex: number; atItemIndex: number; caption: LineCaption | null } -function buildTranscriptLayout( +export function buildTranscriptLayout( turns: FreshAgentTurn[], ): { layouts: TurnLayout[] @@ -524,7 +524,7 @@ function normalizeActivityRows(rows: ActivityRow[], live: boolean): ActivityRow[ return changed ? settledRows : rows } -function selectLiveActivityBlockIdFromLayout( +export function selectLiveActivityBlockIdFromLayout( layouts: TurnLayout[], turns: FreshAgentTurn[], isStreaming: boolean, @@ -796,7 +796,7 @@ function FreshAgentActivityStrip({ ) } -type TurnActionProps = { +export type TurnActionProps = { canFork: boolean onForkFromTurn?: (turnId: string) => void onRewindToTurn?: (turn: FreshAgentTurn) => void @@ -809,6 +809,40 @@ type TurnActionProps = { onOpenActions?: (turn: FreshAgentTurn) => void } +/** A turn's rendered blocks: activity strips and item cards, in layout order. */ +function TurnBlocks({ + blocks, + isUser, + liveActivityBlockId, + expandThinking, + expandTools, +}: { + blocks: RenderBlock[] + isUser: boolean + liveActivityBlockId: string | null + expandThinking: boolean + expandTools: boolean +}) { + return ( + <> + {blocks.map((block) => { + if (block.kind === 'activity') { + return ( + + ) + } + return + })} + + ) +} + /** * Build the per-article long-press bundle: the raw `buildLongPressHandlers` * product with `notifyOverlayOpened` split out of the DOM spread (it is not a @@ -821,7 +855,7 @@ function buildTurnLongPress(onLongPress: () => void) { return { handlers, notifyOverlayOpened } } -function FreshAgentTurnArticle({ +export function FreshAgentTurnArticle({ turn, actionTurn, blocks, @@ -835,6 +869,7 @@ function FreshAgentTurnArticle({ liveActivityBlockId, isStreamingLastTurn, index, + live = false, }: { turn: FreshAgentTurn /** Turn the action affordances target — the line's LAST contributing turn @@ -851,6 +886,9 @@ function FreshAgentTurnArticle({ liveActivityBlockId: string | null isStreamingLastTurn: boolean index: number + /** The live tail's response in progress (not a turn yet): no turn actions, + * no context-menu/long-press ownership, no turn index, and busy until it lands. */ + live?: boolean }) { const isUser = turn.role === 'user' const turnLabel = getTurnLabel(turn, agentLabel) @@ -879,8 +917,37 @@ function FreshAgentTurnArticle({ // Spread + ownership marker key off the CURRENT render's availability: a // coarse→fine pointer flip stops advertising/handling long-press even though // the instance persists for the article's lifetime. - const longPress = actions.onOpenActions ? longPressRef.current : null - const rewindAvailability = actions.getRewindAvailability?.(actionTurn) + const longPress = !live && actions.onOpenActions ? longPressRef.current : null + const rewindAvailability = live ? undefined : actions.getRewindAvailability?.(actionTurn) + if (live) { + return ( +
+ {showHeader ? ( +
+ {turnLabel} +
+ ) : null} +
+ +
+
+ ) + } return (
) : null}
- {blocks.length > 0 ? blocks.map((block) => { - if (block.kind === 'activity') { - return ( - - ) - } - return - }) : isUser ? ( + {blocks.length > 0 ? ( + + ) : isUser ? (

{stripSystemReminders(turn.summary)}

) : ( // Summary-only agent turns went through the plain-text path and @@ -1011,6 +1073,20 @@ function FreshAgentTurnArticle({ ) } +/** + * What the transcript offers the content rendered in its `tail` slot (the + * live tail): `requestFollow` keeps the view pinned to the bottom as the tail + * grows, but only while the reader is already at the bottom; `lastTurnRole` + * is the role of the last rendered turn, so a tail continuing the same role + * shows no second header. + */ +export type TranscriptFollow = { + requestFollow: () => void + lastTurnRole?: FreshAgentTurn['role'] +} + +export const TranscriptFollowContext = createContext(null) + const AT_BOTTOM_THRESHOLD = 24 const TRANSCRIPT_LINE_HEIGHT = 40 const TRANSCRIPT_PAGE_OVERLAP = 40 @@ -1071,6 +1147,9 @@ export type FreshAgentTranscriptProps = { /** Keep the transcript mounted while a reveal refresh is pending, but pause * measurement and scroll bookkeeping until the new snapshot is committed. */ presentationPaused?: boolean + /** Rendered after the turns (the live tail of a running turn); it reaches + * the transcript's follow behavior through TranscriptFollowContext. */ + tail?: ReactNode } export const FreshAgentTranscript = forwardRef(function FreshAgentTranscript({ @@ -1096,9 +1175,14 @@ export const FreshAgentTranscript = forwardRef(null) const [atBottom, setAtBottom] = useState(true) + const atBottomRef = useRef(atBottom) + atBottomRef.current = atBottom + const presentationPausedRef = useRef(presentationPaused) + presentationPausedRef.current = presentationPaused const [newMessages, setNewMessages] = useState(0) const [sheetTurn, setSheetTurn] = useState(null) const [transcriptMeasurement, setTranscriptMeasurement] = useState(null) @@ -1273,6 +1357,14 @@ export const FreshAgentTranscript = forwardRef { + if (presentationPausedRef.current || !atBottomRef.current) return + const node = scrollerRef.current + if (node) node.scrollTop = node.scrollHeight + }, []) + const lastTurnRole = displayTurns.at(-1)?.role + const follow = useMemo(() => ({ requestFollow, lastTurnRole }), [requestFollow, lastTurnRole]) + // Shared row markup for BOTH rolled-back presentations (the e2e locates rows // via div.flex.items-start). The redo button branch is gated on the row's // restorable group IN ADDITION to the unchanged server-authored redo gate — @@ -1352,6 +1444,11 @@ export const FreshAgentTranscript = forwardRef {tailCaption.text}
) : null} + {liveTail ? ( + + {liveTail} + + ) : null} {rolledBackTurns.length > 0 ? (
{restorableMarkers.length > 0 ? ( diff --git a/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx new file mode 100644 index 000000000..5950775a4 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import freshAgentReducer from '@/store/freshAgentSlice' +import freshAgentLiveReducer, { + assistantBlockBound, + liveRetrySet, + streamOpsApplied, +} from '@/store/freshAgentLiveSlice' +import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' +import { FreshAgentLiveTail } from '@/components/fresh-agent/FreshAgentLiveTail' +import { FreshAgentTranscript, TranscriptFollowContext } from '@/components/fresh-agent/FreshAgentTranscript' + +// Render markdown synchronously (the transcript tests' established mock): the +// real LazyMarkdown swaps a plain fallback for the renderer chunk once loaded. +vi.mock('@/components/markdown/LazyMarkdown', async () => { + const { MarkdownRenderer } = await import('@/components/markdown/MarkdownRenderer') + return { + LazyMarkdown: ({ content }: { content: string }) => , + } +}) + +const locator = { sessionId: 'tail-1', sessionType: 'freshclaude' as const, provider: 'claude' as const } +const KEY = 'freshclaude:claude:tail-1' +const NONE: ReadonlySet = new Set() + +function createStore() { + return configureStore({ reducer: { freshAgent: freshAgentReducer, freshAgentLive: freshAgentLiveReducer } }) +} + +type Store = ReturnType + +function apply(store: Store, ...ops: LiveStreamOp[]) { + act(() => { + store.dispatch(streamOpsApplied({ ...locator, ops })) + }) +} + +const start = (index: number, kind: 'text' | 'thinking' | 'tool_use', extra: Record = {}) => ( + { op: 'start', messageId: 'msg_1', index, kind, ...extra } as LiveStreamOp +) +const delta = (index: number, text: string): LiveStreamOp => ({ op: 'delta', messageId: 'msg_1', index, text }) + +function renderTail(store: Store, landed: ReadonlySet = NONE) { + return render( + + + , + ) +} + +function activityStatus() { + return within(screen.getByRole('region', { name: 'Activity strip' })).getByRole('status') +} + +afterEach(() => { + cleanup() +}) + +describe('FreshAgentLiveTail (transcript-live D4)', () => { + it('renders nothing while the session has nothing live', () => { + const store = createStore() + const { container } = renderTail(store) + expect(container).toBeEmptyDOMElement() + }) + + it('renders streamed text as markdown and grows with each batch', async () => { + const store = createStore() + apply(store, start(0, 'text'), delta(0, 'Hello **bold')) + renderTail(store) + apply(store, delta(0, ' world** and more')) + + const strong = await screen.findByText('bold world', { selector: 'strong' }) + expect(strong).toBeInTheDocument() + expect(screen.getByRole('article', { name: 'Claude live response' })).toHaveTextContent('Hello bold world and more') + }) + + it('shows a live thinking strip while the model is thinking', () => { + const store = createStore() + apply(store, start(0, 'thinking'), delta(0, 'considering the files')) + renderTail(store) + expect(activityStatus()).toHaveTextContent('Thinking') + expect(screen.getByLabelText('running')).toBeInTheDocument() + }) + + it('shows a tool by name as soon as its block starts, then its input once the message carries it', () => { + const store = createStore() + apply(store, start(0, 'tool_use', { name: 'Read', toolUseId: 'toolu_1' })) + renderTail(store) + expect(activityStatus()).toHaveTextContent('Read') + + act(() => { + store.dispatch(assistantBlockBound({ + ...locator, + uuid: 'u-tool', + messageId: 'msg_1', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/repo/src/app.ts' } }], + })) + }) + expect(activityStatus()).toHaveTextContent('/repo/src/app.ts') + }) + + it('shows the Retrying reel while the CLI retries an API request', () => { + const store = createStore() + act(() => { + store.dispatch(liveRetrySet({ ...locator, attempt: 2, maxRetries: 10, delayMs: 1000, errorStatus: 529, error: 'overloaded' })) + }) + renderTail(store) + expect(activityStatus()).toHaveTextContent('Retrying') + expect(activityStatus()).toHaveTextContent('attempt 2') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' })) + expect(screen.getByTestId('fresh-agent-retry-row')).toHaveTextContent('Retrying (attempt 2) — overloaded (529) · retrying in 1s') + + // The retried request answers: the reel goes away. + apply(store, start(0, 'text'), delta(0, 'Back')) + expect(screen.queryByTestId('fresh-agent-retry-row')).not.toBeInTheDocument() + expect(screen.queryByText('Retrying')).not.toBeInTheDocument() + }) + + it('hides a block as soon as the snapshot holding its line is rendered', async () => { + const store = createStore() + apply(store, start(0, 'thinking'), delta(0, 'planning'), start(1, 'text'), delta(1, 'Final words')) + act(() => { + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-text', messageId: 'msg_1', content: [{ type: 'text', text: 'Final words' }] })) + }) + const { rerender, container } = renderTail(store) + await screen.findByText('Final words') + + rerender( + + + , + ) + expect(screen.queryByText('Final words')).not.toBeInTheDocument() + expect(activityStatus()).toHaveTextContent('Thinking') + + act(() => { + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-think', messageId: 'msg_1', content: [{ type: 'thinking', thinking: 'planning' }] })) + }) + rerender( + + + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('asks the transcript to follow when it grows', () => { + const store = createStore() + const requestFollow = vi.fn() + apply(store, start(0, 'text'), delta(0, 'one')) + render( + + + + + , + ) + const afterMount = requestFollow.mock.calls.length + expect(afterMount).toBeGreaterThan(0) + + apply(store, delta(0, ' two')) + expect(requestFollow.mock.calls.length).toBeGreaterThan(afterMount) + }) + + it('continues the previous assistant turn without a second header', () => { + const store = createStore() + apply(store, start(0, 'text'), delta(0, 'more')) + render( + + {}, lastTurnRole: 'assistant' }}> + + + , + ) + const article = screen.getByRole('article', { name: 'Claude live response' }) + expect(within(article).queryByText('Claude')).not.toBeInTheDocument() + // A live response offers no turn actions (it is not a turn yet). + expect(within(article).queryByRole('button', { name: /copy|fork|undo/i })).not.toBeInTheDocument() + expect(article).toHaveAttribute('aria-busy', 'true') + }) +}) + +describe('FreshAgentTranscript live tail slot (transcript-live D4)', () => { + function setup() { + const store = createStore() + apply(store, start(0, 'text'), delta(0, 'line one')) + const turns = [{ + id: 'turn-1', + turnId: 'turn-1', + role: 'user' as const, + summary: 'question', + items: [{ id: 'item-1', kind: 'text' as const, text: 'question' }], + }] + const view = render( + + } + /> + , + ) + const scroller = view.container.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement + let scrollHeight = 1000 + Object.defineProperty(scroller, 'clientHeight', { configurable: true, get: () => 200 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => scrollHeight }) + return { + store, + scroller, + grow: (height: number, text: string) => { + scrollHeight = height + apply(store, delta(0, text)) + }, + } + } + + it('renders the tail after the turns', async () => { + setup() + const articles = screen.getAllByRole('article') + expect(articles.at(-1)).toHaveAccessibleName('Claude live response') + await waitFor(() => expect(articles.at(-1)).toHaveTextContent('line one')) + }) + + it('pins to the bottom as the tail grows while the reader is at the bottom', () => { + const { scroller, grow } = setup() + scroller.scrollTop = 800 + fireEvent.scroll(scroller) + grow(1400, ' and line two') + expect(scroller.scrollTop).toBe(1400) + }) + + it('leaves the scroll position alone when the reader has scrolled up', () => { + const { scroller, grow } = setup() + scroller.scrollTop = 100 + fireEvent.scroll(scroller) + grow(1400, ' and line two') + expect(scroller.scrollTop).toBe(100) + }) +}) From 4e29337a84a1559062fd6ff500b0f35f71d4219f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:37:22 -0700 Subject: [PATCH 20/45] feat(fresh-agent): panes show the live tail until its lines land (D5) FreshAgentView mounts the live tail in the transcript's tail slot and never subscribes to its text, only to whether it shows anything: while it does, the transcript's own live strip yields, so one live reel shows. useTranscriptLandings runs on every committed snapshot. It lands the blocks and announced lines the snapshot holds (its turn ids plus the server's transcriptLineUuids); an announced line still missing earns a re-poll, at most LANDING_RECHECK_MAX = 4 per line, after which it is dropped with a warn log. Once nothing is awaited and the session is not busy, the tail clears: after its lines are on screen, so the text never blinks out between the result and the snapshot (the plan cleared on any idle commit). A turn that ends with live content left asks for one more snapshot. Re-polls skip hidden panes. Tests: a transcript announcement refetches within the debounce; the bounded re-poll; streamed text shown exactly once while streaming, after landing and after the turn; an unlanded block outlives the result until its line lands; one live reel; and a profiler proves the view commits nothing across 200 tokens (the tail stubbed out). Co-Authored-By: Claude Opus 5.5 --- .../fresh-agent/FreshAgentLiveTail.tsx | 17 + src/components/fresh-agent/FreshAgentView.tsx | 41 ++- .../fresh-agent/useTranscriptLandings.ts | 124 +++++++ .../FreshAgentView.live-tail.test.tsx | 335 ++++++++++++++++++ .../FreshAgentView.stream-render.test.tsx | 203 +++++++++++ 5 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 src/components/fresh-agent/useTranscriptLandings.ts create mode 100644 test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx create mode 100644 test/unit/client/components/fresh-agent/FreshAgentView.stream-render.test.tsx diff --git a/src/components/fresh-agent/FreshAgentLiveTail.tsx b/src/components/fresh-agent/FreshAgentLiveTail.tsx index 81bb60341..fb5de4afe 100644 --- a/src/components/fresh-agent/FreshAgentLiveTail.tsx +++ b/src/components/fresh-agent/FreshAgentLiveTail.tsx @@ -72,6 +72,23 @@ export function buildLiveTailTurn( return { id: LIVE_TURN_ID, turnId: LIVE_TURN_ID, role: 'assistant', source: 'live', summary: '', items } } +/** + * Whether the tail renders anything: an unlanded block with visible content + * (a tool shows from its start; text and thinking once they have text) or a + * retry. A cheap store-selector predicate — it avoids building the layout. + */ +export function liveTailShowsContent( + tail: FreshAgentLiveSession | undefined, + landedTurnIds: ReadonlySet, +): boolean { + if (!tail) return false + if (tail.retry) return true + return tail.blocks.some((block) => ( + (block.uuid === undefined || !landedTurnIds.has(block.uuid)) + && (block.kind === 'tool_use' || block.text.trim().length > 0) + )) +} + /** A cheap fingerprint of what the tail shows: it changes whenever the tail grows. */ function tailSignature(turn: FreshAgentTurn | null): string { if (!turn) return '' diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 643fdddfc..800b22363 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -121,6 +121,8 @@ import { FreshAgentApprovalCard } from './FreshAgentApprovalCard' import { FreshAgentOpenSessionContext } from './FreshAgentItemCard' import FreshAgentQuestionBanner from './FreshAgentQuestionBanner' import { FreshAgentTranscript, type FreshAgentTranscriptHandle } from './FreshAgentTranscript' +import { FreshAgentLiveTail, liveTailShowsContent } from './FreshAgentLiveTail' +import { presentTranscriptLineIds, useTranscriptLandings } from './useTranscriptLandings' import type { FreshAgentTurnRewindAvailability } from './FreshAgentTurnActions' import { FreshAgentComposer, type FreshAgentComposerHandle } from './FreshAgentComposer' import { FreshAgentDiffPanel } from './FreshAgentDiffPanel' @@ -3942,6 +3944,34 @@ export function FreshAgentView({ : (agentSession?.status ?? paneContent.status) const isBusy = BUSY_STATES.has(effectiveStatus) const sessionEnded = effectiveStatus === 'exited' || effectiveStatus === 'create-failed' + // The live tail (transcript-live plan §4.4): what the running turn shows + // before its transcript lines land. This view never subscribes to the + // tail's text — only to whether it shows anything, so the transcript's own + // live strip yields to it and one live reel shows. + const liveSessionKey = paneContent.sessionId + ? makeFreshAgentSessionKey({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider }) + : undefined + const landedTurnIds = useMemo>( + () => new Set(snapshot ? presentTranscriptLineIds(snapshot) : []), + [snapshot], + ) + const liveTailActive = useAppSelector((state) => ( + liveSessionKey ? liveTailShowsContent(state.freshAgentLive?.bySession[liveSessionKey], landedTurnIds) : false + )) + const isLiveSessionBusyNow = useCallback(() => { + const status = liveSessionKey ? appStore.getState().freshAgent.sessions[liveSessionKey]?.status : undefined + return BUSY_STATES.has(status ?? paneContentRef.current.status) + }, [appStore, liveSessionKey]) + const requestLandingRecheck = useCallback(() => { + if (!hiddenRef.current) requestSnapshotRefresh('event') + }, [requestSnapshotRefresh]) + useTranscriptLandings({ + sessionKey: liveSessionKey, + snapshot, + busy: isBusy, + isBusyNow: isLiveSessionBusyNow, + requestRefresh: requestLandingRecheck, + }) // The durable conversation an ended session's Resume reopens (triggerRecovery // resolves the same id): codex threads, and claude/kilroy sessions whose // sidecar died (G05). @@ -4735,7 +4765,16 @@ export function FreshAgentView({ expandTools={globalExpandTools} showTimecodes={effectiveShowTimecodes} showTranscriptMinimap={showTranscriptMinimap} - isStreaming={isBusy} + isStreaming={isBusy && !liveTailActive} + tail={liveSessionKey ? ( + + ) : undefined} onForkFromTurn={(turnId) => sendFork(turnId)} getRewindAvailability={getRewindAvailability} onRollbackToTurn={(turnId) => { diff --git a/src/components/fresh-agent/useTranscriptLandings.ts b/src/components/fresh-agent/useTranscriptLandings.ts new file mode 100644 index 000000000..a5ac2fa64 --- /dev/null +++ b/src/components/fresh-agent/useTranscriptLandings.ts @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react' +import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import { createLogger } from '@/lib/client-logger' +import { useAppDispatch, useAppStore } from '@/store/hooks' +import { + liveTailCleared, + transcriptExpectationsDropped, + transcriptLanded, +} from '@/store/freshAgentLiveSlice' +import type { FreshAgentLiveSession } from '@/store/freshAgentLiveTypes' + +const log = createLogger('useTranscriptLandings') + +/** + * How many extra snapshot reads a pane spends on one announced transcript + * line that is still missing. The CLI writes a line about 100 ms after it + * announces it (real-CLI fact F7) and the refetch is debounced 250 ms, so a + * miss is rare; past this bound the busy poll and the result's refetch still + * bring the line in (transcript-live plan §4.4). + */ +export const LANDING_RECHECK_MAX = 4 + +/** + * The transcript lines a snapshot proves were written: its turns' ids (each + * claude turn is keyed by its line uuid) and the newest line uuids the server + * lists separately, which include lines that produce no turn of their own. + */ +export function presentTranscriptLineIds(snapshot: FreshAgentSnapshot): string[] { + const ids = new Set() + for (const turn of snapshot.turns) { + ids.add(turn.turnId) + ids.add(turn.id) + } + const claude = snapshot.extensions?.claude + const lineUuids = claude && typeof claude === 'object' ? (claude as { transcriptLineUuids?: unknown }).transcriptLineUuids : undefined + if (Array.isArray(lineUuids)) { + for (const uuid of lineUuids) if (typeof uuid === 'string') ids.add(uuid) + } + return [...ids] +} + +function hasLiveContent(tail: FreshAgentLiveSession | undefined): boolean { + return Boolean(tail && (tail.blocks.length > 0 || tail.retry || tail.expectedTurnIds.length > 0)) +} + +/** + * Keeps a pane's live tail in step with the snapshots it commits. On every + * committed snapshot: the blocks and announced lines it holds are landed; an + * announced line still missing earns a bounded re-poll; and once nothing is + * awaited and the turn is over, the tail is cleared — after its lines are on + * screen, so the text never blinks out between the result and the snapshot. + * A turn that ends with live content left asks for one more snapshot, so the + * clear never waits on an unrelated refresh. + */ +export function useTranscriptLandings({ + sessionKey, + snapshot, + busy, + isBusyNow, + requestRefresh, +}: { + sessionKey: string | undefined + snapshot: FreshAgentSnapshot | null + busy: boolean + /** The pane's busy state at the moment of the check (not of the last render). */ + isBusyNow: () => boolean + requestRefresh: () => void +}): void { + const dispatch = useAppDispatch() + const store = useAppStore() + const attemptsRef = useRef(new Map()) + const isBusyNowRef = useRef(isBusyNow) + isBusyNowRef.current = isBusyNow + const requestRefreshRef = useRef(requestRefresh) + requestRefreshRef.current = requestRefresh + + useEffect(() => { + attemptsRef.current.clear() + }, [sessionKey]) + + useEffect(() => { + if (!sessionKey || !snapshot) return + dispatch(transcriptLanded({ sessionKey, present: presentTranscriptLineIds(snapshot) })) + const expected = store.getState().freshAgentLive?.bySession[sessionKey]?.expectedTurnIds ?? [] + const attempts = attemptsRef.current + for (const uuid of [...attempts.keys()]) { + if (!expected.includes(uuid)) attempts.delete(uuid) + } + const exhausted: string[] = [] + let recheck = false + for (const uuid of expected) { + const attempt = (attempts.get(uuid) ?? 0) + 1 + if (attempt > LANDING_RECHECK_MAX) { + exhausted.push(uuid) + attempts.delete(uuid) + } else { + attempts.set(uuid, attempt) + recheck = true + } + } + if (exhausted.length > 0) { + log.warn('announced transcript lines did not land; the live tail stops waiting for them', { + event: 'fresh_agent.transcript_landing_abandoned', + sessionKey, + lines: exhausted.length, + rechecks: LANDING_RECHECK_MAX, + }) + dispatch(transcriptExpectationsDropped({ sessionKey, uuids: exhausted })) + } + if (recheck) { + requestRefreshRef.current() + return + } + if (!isBusyNowRef.current()) dispatch(liveTailCleared({ sessionKey })) + }, [dispatch, sessionKey, snapshot, store]) + + const wasBusyRef = useRef(busy) + useEffect(() => { + const ended = wasBusyRef.current && !busy + wasBusyRef.current = busy + if (!ended || !sessionKey) return + if (hasLiveContent(store.getState().freshAgentLive?.bySession[sessionKey])) requestRefreshRef.current() + }, [busy, sessionKey, store]) +} diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx new file mode 100644 index 000000000..3ee30e309 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, within } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { appReducers } from '@/store/appReducers' +import { initLayout } from '@/store/panesSlice' +import { setStatus } from '@/store/connectionSlice' +import { useAppSelector } from '@/store/hooks' +import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' +import { LANDING_RECHECK_MAX } from '@/components/fresh-agent/useTranscriptLandings' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { resetSnapshotSchedulerForTests, SNAPSHOT_DEBOUNCE_MS } from '@/lib/fresh-agent-snapshot-scheduler' +import { LIVE_STREAM_FLUSH_MS } from '@/lib/fresh-agent-stream-batcher' + +// Transcript-live plan D5: a claude pane follows its transcript per message, +// re-polls (boundedly) for announced lines the CLI has not written yet, and +// drops its live tail once the turn's lines landed and the turn ended. + +const wsMock = vi.hoisted(() => ({ + handlers: new Set<(message: unknown) => void>(), + send: vi.fn(), +})) + +const apiMock = vi.hoisted(() => ({ + getFreshAgentThreadSnapshot: vi.fn(), +})) + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: wsMock.send, + onMessage: (handler: (message: unknown) => void) => { + wsMock.handlers.add(handler) + return () => { wsMock.handlers.delete(handler) } + }, + onReconnect: () => () => {}, + }), +})) + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + api: { + ...actual.api, + get: vi.fn().mockRejectedValue(new Error('no server in tests')), + post: vi.fn().mockRejectedValue(new Error('no server in tests')), + }, + getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, + getFreshAgentCheckpointOutcomePage: vi.fn().mockResolvedValue({ outcomes: [], nextCursor: null }), + getFreshAgentModelCapabilities: vi.fn().mockRejectedValue(new Error('no server in tests')), + setSessionMetadata: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock('@/store/settingsThunks', () => ({ + saveServerSettingsPatch: (patch: unknown) => ({ type: 'settings/saveServerSettingsPatch', payload: patch }), +})) + +vi.mock('@/components/markdown/LazyMarkdown', async () => { + const { MarkdownRenderer } = await import('@/components/markdown/MarkdownRenderer') + return { + LazyMarkdown: ({ content }: { content: string }) => , + } +}) + +const SESSION_ID = '550e8400-e29b-41d4-a716-44665544d005' +const locator = { sessionId: SESSION_ID, sessionType: 'freshclaude' as const, provider: 'claude' as const } +const KEY = `freshclaude:claude:${SESSION_ID}` + +type Turn = { id: string; turnId: string; role: 'user' | 'assistant'; summary: string; items: unknown[] } + +function userTurn(id: string, text: string): Turn { + return { id, turnId: id, role: 'user', summary: text, items: [{ id: `${id}-text`, kind: 'text', text }] } +} + +function assistantText(id: string, text: string): Turn { + return { id, turnId: id, role: 'assistant', summary: text, items: [{ id: `${id}-text`, kind: 'text', text }] } +} + +function assistantTool(id: string, name: string): Turn { + return { + id, + turnId: id, + role: 'assistant', + summary: '', + items: [{ id: `${id}-tool`, kind: 'tool_use', toolUseId: `toolu_${id}`, name, input: { command: 'ls' } }], + } +} + +function claudeSnapshot(turns: Turn[], lineUuids: string[] = turns.map((turn) => turn.turnId)) { + return { + sessionType: 'freshclaude', + provider: 'claude', + threadId: SESSION_ID, + sessionId: SESSION_ID, + revision: 1, + latestTurnId: turns.at(-1)?.turnId ?? null, + status: 'idle', + summary: '', + capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: false }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + pendingApprovals: [], + pendingQuestions: [], + worktrees: [], + diffs: [], + childThreads: [], + turns, + extensions: { claude: { transcriptLineUuids: lineUuids } }, + } +} + +/** Every read parses fresh JSON in production: hand out a new object per call. */ +function serve(...snapshots: Array<() => ReturnType>) { + const queue = [...snapshots] + apiMock.getFreshAgentThreadSnapshot.mockImplementation(async () => (queue.length > 1 ? queue.shift()! : queue[0])()) +} + +function createStore() { + const store = configureStore({ + reducer: appReducers, + middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }), + }) + store.dispatch(setStatus('ready')) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + ...locator, + createRequestId: 'req-live-tail', + status: 'idle', + }, + })) + return store +} + +type Store = ReturnType + +function StoreBackedView() { + const content = useAppSelector((state) => { + const layout = state.panes.layouts['tab-1'] + if (!layout || layout.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('missing pane') + return layout.content + }) + return +} + +/** What App and the mounted view both receive for one server frame. */ +function deliver(store: Store, event: Record) { + const message = { type: 'freshAgent.event', ...locator, event: { sessionId: SESSION_ID, ...event } } + act(() => { + handleFreshAgentMessage(store.dispatch, message) + for (const handler of [...wsMock.handlers]) handler(message) + }) +} + +const stream = (event: Record) => ({ + type: 'freshAgent.stream', messageId: 'msg_live', parentToolUseId: null, event, +}) + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +/** + * Lets `rounds` debounced refetches run to completion, one act scope each: a + * committed snapshot's effects (and the re-poll they ask for) run when its + * act scope ends. + */ +async function settleRefetches(rounds: number) { + for (let i = 0; i < rounds; i += 1) await advance(SNAPSHOT_DEBOUNCE_MS) +} + +async function mount(store: Store) { + render() + await advance(0) + await advance(SNAPSHOT_DEBOUNCE_MS) +} + +const fetchCount = () => apiMock.getFreshAgentThreadSnapshot.mock.calls.length + +beforeEach(() => { + vi.useFakeTimers() + resetSnapshotSchedulerForTests() + wsMock.handlers.clear() + wsMock.send.mockReset() + apiMock.getFreshAgentThreadSnapshot.mockReset() +}) + +afterEach(() => { + cleanup() + resetSnapshotSchedulerForTests() + vi.useRealTimers() +}) + +describe('FreshAgentView live transcript (transcript-live D5)', () => { + it('refetches the snapshot within the debounce of a transcript announcement', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'hi')])) + const store = createStore() + await mount(store) + const before = fetchCount() + expect(before).toBeGreaterThan(0) + + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-user', parentToolUseId: null }) + await advance(SNAPSHOT_DEBOUNCE_MS - 1) + expect(fetchCount()).toBe(before) + await advance(1) + expect(fetchCount()).toBe(before + 1) + }) + + it('re-polls for an announced line the snapshot lacks, at most LANDING_RECHECK_MAX times', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'hi')])) + const store = createStore() + await mount(store) + const before = fetchCount() + + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-late', parentToolUseId: null }) + await settleRefetches(LANDING_RECHECK_MAX + 6) + await advance(10_000) + + // The announcement's own refetch, then one re-poll per miss up to the bound. + expect(fetchCount()).toBe(before + 1 + LANDING_RECHECK_MAX) + expect(store.getState().freshAgentLive.bySession[KEY]?.expectedTurnIds ?? []).toEqual([]) + }) + + it('stops re-polling as soon as the line lands (as a turn or a bookkeeping line)', async () => { + serve( + () => claudeSnapshot([userTurn('u-user', 'hi')]), + () => claudeSnapshot([userTurn('u-user', 'hi')]), + () => claudeSnapshot([userTurn('u-user', 'hi')], ['u-user', 'u-absorbed']), + ) + const store = createStore() + await mount(store) + const before = fetchCount() + + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-absorbed', parentToolUseId: null }) + await settleRefetches(10) + + expect(fetchCount()).toBe(before + 2) + expect(store.getState().freshAgentLive.bySession[KEY].expectedTurnIds).toEqual([]) + }) + + it('shows streamed text once while it streams and after its line lands, then drops the tail when the turn ends', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'question')])) + const store = createStore() + await mount(store) + + deliver(store, { type: 'freshAgent.status', status: 'running' }) + deliver(store, stream({ type: 'message_start', message: { id: 'msg_live' } })) + deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'The streamed answer' } })) + await advance(LIVE_STREAM_FLUSH_MS) + expect(screen.getAllByText('The streamed answer')).toHaveLength(1) + expect(screen.getByRole('article', { name: /live response/ })).toHaveTextContent('The streamed answer') + + serve(() => claudeSnapshot([userTurn('u-user', 'question'), assistantText('u-answer', 'The streamed answer')])) + deliver(store, { + type: 'freshAgent.assistant', content: [{ type: 'text', text: 'The streamed answer' }], uuid: 'u-answer', messageId: 'msg_live', parentToolUseId: null, + }) + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-answer', parentToolUseId: null }) + // A second block of the same turn is still streaming. + deliver(store, stream({ type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } })) + deliver(store, stream({ type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'Still going' } })) + await advance(SNAPSHOT_DEBOUNCE_MS + LIVE_STREAM_FLUSH_MS) + + expect(screen.getAllByText('The streamed answer')).toHaveLength(1) + expect(screen.getByRole('article', { name: /live response/ })).not.toHaveTextContent('The streamed answer') + // Busy: the unlanded block stays. + expect(screen.getByRole('article', { name: /live response/ })).toHaveTextContent('Still going') + expect(store.getState().freshAgentLive.bySession[KEY].blocks.map((block) => block.text)).toEqual(['Still going']) + + // The turn ends; its last line lands with the refetch the result triggers. + serve(() => claudeSnapshot([ + userTurn('u-user', 'question'), assistantText('u-answer', 'The streamed answer'), assistantText('u-more', 'Still going'), + ])) + deliver(store, { + type: 'freshAgent.assistant', content: [{ type: 'text', text: 'Still going' }], uuid: 'u-more', messageId: 'msg_live', parentToolUseId: null, + }) + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-more', parentToolUseId: null }) + deliver(store, { type: 'freshAgent.result', costUsd: 0.01 }) + await settleRefetches(4) + + expect(screen.queryByRole('article', { name: /live response/ })).not.toBeInTheDocument() + expect(screen.getAllByText('Still going')).toHaveLength(1) + expect(store.getState().freshAgentLive.bySession[KEY]).toMatchObject({ blocks: [], expectedTurnIds: [] }) + }) + + it('keeps an unlanded block after the turn ends until its line lands', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'question')])) + const store = createStore() + await mount(store) + + deliver(store, { type: 'freshAgent.status', status: 'running' }) + deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Slow to land' } })) + deliver(store, { + type: 'freshAgent.assistant', content: [{ type: 'text', text: 'Slow to land' }], uuid: 'u-slow', messageId: 'msg_live', parentToolUseId: null, + }) + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-slow', parentToolUseId: null }) + deliver(store, { type: 'freshAgent.result', costUsd: 0.01 }) + // The first refetch after the result misses the line: the text stays on screen. + await advance(SNAPSHOT_DEBOUNCE_MS) + expect(screen.getByRole('article', { name: /live response/ })).toHaveTextContent('Slow to land') + + serve(() => claudeSnapshot([ + userTurn('u-user', 'question'), assistantText('u-slow', 'Slow to land'), + ])) + await settleRefetches(2) + expect(screen.queryByRole('article', { name: /live response/ })).not.toBeInTheDocument() + expect(screen.getAllByText('Slow to land')).toHaveLength(1) + }) + + it('shows one live reel: the transcript\'s own live strip yields to the live tail', async () => { + serve(() => claudeSnapshot([ + userTurn('u-user', 'question'), assistantTool('u-tool', 'Bash'), + ])) + const store = createStore() + await mount(store) + deliver(store, { type: 'freshAgent.status', status: 'running' }) + await advance(0) + // Busy with no live content: the last durable activity line is the live one. + expect(screen.getAllByLabelText('running')).toHaveLength(1) + + deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } })) + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'next step' } })) + await advance(LIVE_STREAM_FLUSH_MS) + + const running = screen.getAllByLabelText('running') + expect(running).toHaveLength(1) + const liveArticle = screen.getByRole('article', { name: /live response/ }) + expect(within(liveArticle).getByLabelText('running')).toBe(running[0]) + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.stream-render.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.stream-render.test.tsx new file mode 100644 index 000000000..dd78a060f --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentView.stream-render.test.tsx @@ -0,0 +1,203 @@ +import { Profiler, type ProfilerOnRenderCallback } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { appReducers } from '@/store/appReducers' +import { initLayout } from '@/store/panesSlice' +import { setStatus } from '@/store/connectionSlice' +import { useAppSelector } from '@/store/hooks' +import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { resetSnapshotSchedulerForTests, SNAPSHOT_DEBOUNCE_MS } from '@/lib/fresh-agent-snapshot-scheduler' +import { LIVE_STREAM_FLUSH_MS } from '@/lib/fresh-agent-stream-batcher' + +// Transcript-live plan D5 (acceptance 10): a token re-renders only the live +// tail. The pane's view — header, status strip, composer, transcript — does +// not commit per delta. The tail itself is replaced by a stub that subscribes +// to nothing, so every commit the profiler sees belongs to the view. + +const wsMock = vi.hoisted(() => ({ + handlers: new Set<(message: unknown) => void>(), + send: vi.fn(), +})) + +const apiMock = vi.hoisted(() => ({ + getFreshAgentThreadSnapshot: vi.fn(), +})) + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: wsMock.send, + onMessage: (handler: (message: unknown) => void) => { + wsMock.handlers.add(handler) + return () => { wsMock.handlers.delete(handler) } + }, + onReconnect: () => () => {}, + }), +})) + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + api: { + ...actual.api, + get: vi.fn().mockRejectedValue(new Error('no server in tests')), + post: vi.fn().mockRejectedValue(new Error('no server in tests')), + }, + getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, + getFreshAgentCheckpointOutcomePage: vi.fn().mockResolvedValue({ outcomes: [], nextCursor: null }), + getFreshAgentModelCapabilities: vi.fn().mockRejectedValue(new Error('no server in tests')), + setSessionMetadata: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock('@/store/settingsThunks', () => ({ + saveServerSettingsPatch: (patch: unknown) => ({ type: 'settings/saveServerSettingsPatch', payload: patch }), +})) + +const tailMock = vi.hoisted(() => ({ sessionKeys: [] as string[] })) + +vi.mock('@/components/fresh-agent/FreshAgentLiveTail', async () => { + const actual = await vi.importActual('@/components/fresh-agent/FreshAgentLiveTail') + return { + ...actual, + FreshAgentLiveTail: ({ sessionKey }: { sessionKey: string }) => { + tailMock.sessionKeys.push(sessionKey) + return
+ }, + } +}) + +vi.mock('@/components/markdown/LazyMarkdown', async () => { + const { MarkdownRenderer } = await import('@/components/markdown/MarkdownRenderer') + return { + LazyMarkdown: ({ content }: { content: string }) => , + } +}) + +const SESSION_ID = '550e8400-e29b-41d4-a716-44665544d0a5' +const locator = { sessionId: SESSION_ID, sessionType: 'freshclaude' as const, provider: 'claude' as const } +const KEY = `freshclaude:claude:${SESSION_ID}` + +function claudeSnapshot() { + return { + sessionType: 'freshclaude', + provider: 'claude', + threadId: SESSION_ID, + sessionId: SESSION_ID, + revision: 1, + latestTurnId: 'u-user', + status: 'idle', + summary: '', + capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: false }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + pendingApprovals: [], + pendingQuestions: [], + worktrees: [], + diffs: [], + childThreads: [], + turns: [{ id: 'u-user', turnId: 'u-user', role: 'user', summary: 'go', items: [{ id: 'u-user-text', kind: 'text', text: 'go' }] }], + extensions: { claude: { transcriptLineUuids: ['u-user'] } }, + } +} + +function createStore() { + const store = configureStore({ + reducer: appReducers, + middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }), + }) + store.dispatch(setStatus('ready')) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { kind: 'fresh-agent', ...locator, createRequestId: 'req-stream-render', status: 'idle' }, + })) + return store +} + +type Store = ReturnType + +function StoreBackedView() { + const content = useAppSelector((state) => { + const layout = state.panes.layouts['tab-1'] + if (!layout || layout.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('missing pane') + return layout.content + }) + return +} + +function deliver(store: Store, event: Record) { + const message = { type: 'freshAgent.event', ...locator, event: { sessionId: SESSION_ID, ...event } } + act(() => { + handleFreshAgentMessage(store.dispatch, message) + for (const handler of [...wsMock.handlers]) handler(message) + }) +} + +const stream = (event: Record) => ({ + type: 'freshAgent.stream', messageId: 'msg_profiled', parentToolUseId: null, event, +}) + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + resetSnapshotSchedulerForTests() + wsMock.handlers.clear() + wsMock.send.mockReset() + tailMock.sessionKeys = [] + apiMock.getFreshAgentThreadSnapshot.mockReset() + apiMock.getFreshAgentThreadSnapshot.mockImplementation(async () => claudeSnapshot()) +}) + +afterEach(() => { + cleanup() + resetSnapshotSchedulerForTests() + vi.useRealTimers() +}) + +describe('FreshAgentView under a token stream (transcript-live D5)', () => { + it('does not commit once per delta; only the live tail follows the stream', async () => { + const store = createStore() + let commits = 0 + const onRender: ProfilerOnRenderCallback = () => { commits += 1 } + render( + + + + + , + ) + await advance(0) + await advance(SNAPSHOT_DEBOUNCE_MS) + expect(tailMock.sessionKeys).toContain(KEY) + + deliver(store, { type: 'freshAgent.status', status: 'running' }) + deliver(store, stream({ type: 'message_start', message: { id: 'msg_profiled' } })) + deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'first ' } })) + // The tail starts showing content: the transcript's own live strip yields + // to it (one commit per turn, not per token). + await advance(LIVE_STREAM_FLUSH_MS) + await advance(500) + commits = 0 + const fetchesBefore = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + + // 200 tokens in five flush windows: well inside the 3 s busy poll, so no + // snapshot read lands in the window and every commit would be the stream's. + for (let i = 0; i < 200; i += 1) { + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: `t${i} ` } })) + if (i % 40 === 39) await advance(LIVE_STREAM_FLUSH_MS) + } + + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBe(fetchesBefore) + expect(store.getState().freshAgentLive.bySession[KEY].blocks[0].text).toContain('t199') + expect(commits).toBe(0) + }) +}) From e60c712eebc6d4527a9ff23d4b628ac8e1075450 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:41:08 -0700 Subject: [PATCH 21/45] fix(fresh-agent): the transcript hands its live reel to the tail without waiting for another render The pane's memoized content did not list the live-tail values among its dependencies, so when the tail started showing content with nothing else changing, the transcript kept its own live strip and two reels showed. The one-reel test now lets every unrelated refresh settle first, so the tail must take over on its own; it fails without the dependencies. Co-Authored-By: Claude Opus 5.5 --- src/components/fresh-agent/FreshAgentView.tsx | 3 +++ .../fresh-agent/FreshAgentView.live-tail.test.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 800b22363..6a24b9390 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -4931,6 +4931,9 @@ export function FreshAgentView({ getRewindAvailability, isBusy, isRestoring, + landedTurnIds, + liveSessionKey, + liveTailActive, loadError, managedRecoveryPending, managedRecoveryActive, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx index 3ee30e309..635c8ea37 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx @@ -319,11 +319,20 @@ describe('FreshAgentView live transcript (transcript-live D5)', () => { const store = createStore() await mount(store) deliver(store, { type: 'freshAgent.status', status: 'running' }) - await advance(0) + // Let every refresh the status change causes settle: the tail must take + // over on its own, not ride on an unrelated re-render. + await settleRefetches(3) // Busy with no live content: the last durable activity line is the live one. expect(screen.getAllByLabelText('running')).toHaveLength(1) + // The block opens empty (nothing live to show yet); the session record's + // streaming flag changes with it, so let that settle too. deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } })) + await settleRefetches(3) + expect(screen.getAllByLabelText('running')).toHaveLength(1) + expect(screen.queryByRole('article', { name: /live response/ })).not.toBeInTheDocument() + + // Its first text arrives: the tail takes over the live reel. deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'next step' } })) await advance(LIVE_STREAM_FLUSH_MS) From 0d0f429c77da8b63c551a97e4508d5baed555686 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:43:02 -0700 Subject: [PATCH 22/45] feat(fresh-agent): provider notices show in the pane banner without ending the turn (D6) freshAgent.notice (a rate limit near or reached, a failed compaction, an informational CLI banner) folds into the live slice with a sequence number and never touches the session (P4). The pane shows each new notice once in its notice banner (role alert); a rate-limit notice adds when the limit resets, in the viewer's local time ('3:45 PM', or 'Fri 3:45 PM' on another day; the CLI's epoch seconds, or milliseconds). A malformed notice is dropped with a warn log. Co-Authored-By: Claude Opus 5.5 --- src/components/fresh-agent/FreshAgentView.tsx | 13 +++++ src/lib/fresh-agent-notice.ts | 28 +++++++++ src/lib/fresh-agent-ws.ts | 31 +++++++++- src/store/freshAgentLiveSlice.ts | 13 +++++ .../FreshAgentView.live-tail.test.tsx | 57 +++++++++++++++++++ .../client/lib/fresh-agent-notice.test.ts | 39 +++++++++++++ .../client/store/freshAgentLiveSlice.test.ts | 18 ++++++ 7 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/lib/fresh-agent-notice.ts create mode 100644 test/unit/client/lib/fresh-agent-notice.test.ts diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 6a24b9390..e9a3949f2 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -123,6 +123,7 @@ import FreshAgentQuestionBanner from './FreshAgentQuestionBanner' import { FreshAgentTranscript, type FreshAgentTranscriptHandle } from './FreshAgentTranscript' import { FreshAgentLiveTail, liveTailShowsContent } from './FreshAgentLiveTail' import { presentTranscriptLineIds, useTranscriptLandings } from './useTranscriptLandings' +import { formatFreshAgentNotice } from '@/lib/fresh-agent-notice' import type { FreshAgentTurnRewindAvailability } from './FreshAgentTurnActions' import { FreshAgentComposer, type FreshAgentComposerHandle } from './FreshAgentComposer' import { FreshAgentDiffPanel } from './FreshAgentDiffPanel' @@ -3958,6 +3959,18 @@ export function FreshAgentView({ const liveTailActive = useAppSelector((state) => ( liveSessionKey ? liveTailShowsContent(state.freshAgentLive?.bySession[liveSessionKey], landedTurnIds) : false )) + // Provider notices (rate limits, a failed compaction, CLI banners) show in + // the pane's notice banner; they never end the turn (transcript-live P4). + // `seq` shows each notice once; one received before this mount is not replayed. + const liveNotice = useAppSelector((state) => ( + liveSessionKey ? state.freshAgentLive?.bySession[liveSessionKey]?.notice : undefined + )) + const shownNoticeSeqRef = useRef(liveNotice?.seq ?? 0) + useEffect(() => { + if (!liveNotice || liveNotice.seq <= shownNoticeSeqRef.current) return + shownNoticeSeqRef.current = liveNotice.seq + setNotice(formatFreshAgentNotice(liveNotice)) + }, [liveNotice]) const isLiveSessionBusyNow = useCallback(() => { const status = liveSessionKey ? appStore.getState().freshAgent.sessions[liveSessionKey]?.status : undefined return BUSY_STATES.has(status ?? paneContentRef.current.status) diff --git a/src/lib/fresh-agent-notice.ts b/src/lib/fresh-agent-notice.ts new file mode 100644 index 000000000..24e40c70d --- /dev/null +++ b/src/lib/fresh-agent-notice.ts @@ -0,0 +1,28 @@ +import type { LiveNotice } from '@/store/freshAgentLiveTypes' + +/** + * When a usage limit resets, as a Date. The CLI reports `resetsAt` in epoch + * seconds; a value already in milliseconds (13 digits) is taken as is. + */ +function resetDate(resetsAt: number): Date { + return new Date(resetsAt < 1e11 ? resetsAt * 1000 : resetsAt) +} + +function sameLocalDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() +} + +/** "3:45 PM" today, "Fri 3:45 PM" on another day — in the viewer's locale and time zone. */ +export function formatResetTime(resetsAt: number, now: Date = new Date()): string { + const date = resetDate(resetsAt) + const time = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + if (sameLocalDay(date, now)) return time + return `${date.toLocaleDateString(undefined, { weekday: 'short' })} ${time}` +} + +/** The banner text for a provider notice: its message, plus when a rate limit resets. */ +export function formatFreshAgentNotice(notice: Pick, now: Date = new Date()): string { + const resetsAt = notice.rateLimit?.resetsAt + if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt)) return notice.message + return `${notice.message} It resets at ${formatResetTime(resetsAt, now)}.` +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 900ac7321..eba8456b6 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -11,8 +11,8 @@ import { applyFreshAgentCompletion, applyFreshAgentWaiting } from '@/store/turnC import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' -import type { LiveBlockKind, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' -import { assistantBlockBound, liveRetrySet, transcriptAnnounced } from '@/store/freshAgentLiveSlice' +import type { LiveBlockKind, LiveNotice, LiveNoticeLevel, LiveRateLimit, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' +import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced } from '@/store/freshAgentLiveSlice' import { addPermissionRequest, addQuestionRequest, @@ -388,6 +388,26 @@ function parseLiveRetry(frame: Record): LiveRetry | undefined { } } +const NOTICE_LEVELS: ReadonlySet = new Set(['info', 'warning', 'error']) + +/** A `freshAgent.notice` frame (the sidecar's `sdk.notice`), or nothing if it is malformed. */ +function parseLiveNotice(frame: Record): Omit | undefined { + if (typeof frame.code !== 'string' || !frame.code) return undefined + if (typeof frame.message !== 'string' || !frame.message.trim()) return undefined + const level = typeof frame.level === 'string' && NOTICE_LEVELS.has(frame.level) ? frame.level as LiveNoticeLevel : 'info' + let rateLimit: LiveRateLimit | undefined + if (isObject(frame.rateLimit)) { + const limit = frame.rateLimit + rateLimit = { + ...(typeof limit.status === 'string' ? { status: limit.status } : {}), + ...(typeof limit.resetsAt === 'number' && Number.isFinite(limit.resetsAt) ? { resetsAt: limit.resetsAt } : {}), + ...(typeof limit.rateLimitType === 'string' ? { rateLimitType: limit.rateLimitType } : {}), + ...(typeof limit.utilization === 'number' && Number.isFinite(limit.utilization) ? { utilization: limit.utilization } : {}), + } + } + return { level, code: frame.code, message: frame.message.trim(), ...(rateLimit ? { rateLimit } : {}) } +} + function parseLiveStreamOp(frame: Record): LiveStreamOp | undefined { if (hasParentToolUse(frame)) return undefined const event = frame.event @@ -525,6 +545,13 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh if (retry) dispatch(liveRetrySet({ ...locator, ...retry })) return true } + case 'freshAgent.notice': { + // Non-terminal (P4): a notice is shown, never folded into the error path. + const notice = parseLiveNotice(event) + if (notice) dispatch(liveNoticeReceived({ ...locator, ...notice })) + else log.warn('dropping malformed freshAgent.notice', { sessionId, code: event.code }) + return true + } case 'freshAgent.stream': { const op = parseLiveStreamOp(event) if (!op) return true diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index 91759494b..10d7305cc 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -14,6 +14,7 @@ import type { FreshAgentLiveState, LiveBlock, LiveBlockKind, + LiveNotice, LiveRetry, LiveStreamOp, } from './freshAgentLiveTypes' @@ -179,6 +180,17 @@ const freshAgentLiveSlice = createSlice({ sessionFor(state, { sessionId, sessionType, provider }).retry = retry }, + /** + * A provider notice (rate limit, failed compaction, informational banner). + * It never ends the turn or touches the session (P4); `seq` lets the pane + * show each notice once, even when the same text repeats. + */ + liveNoticeReceived(state, action: PayloadAction>) { + const { sessionId, sessionType, provider, ...notice } = action.payload + state.noticeSeq += 1 + sessionFor(state, { sessionId, sessionType, provider }).notice = { seq: state.noticeSeq, ...notice } + }, + /** The pane's turn has landed and ended: nothing live is left to show. */ liveTailCleared(state, action: PayloadAction<{ sessionKey: string }>) { const session = state.bySession[action.payload.sessionKey] @@ -214,6 +226,7 @@ const freshAgentLiveSlice = createSlice({ export const { assistantBlockBound, + liveNoticeReceived, liveRetrySet, liveTailCleared, streamOpsApplied, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx index 635c8ea37..a44fa501f 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx @@ -342,3 +342,60 @@ describe('FreshAgentView live transcript (transcript-live D5)', () => { expect(within(liveArticle).getByLabelText('running')).toBe(running[0]) }) }) + +describe('provider notices (transcript-live D6)', () => { + it('shows a notice in the pane banner without ending the running turn', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'question')])) + const store = createStore() + await mount(store) + deliver(store, { type: 'freshAgent.status', status: 'running' }) + const sessionsBefore = store.getState().freshAgent.sessions + + deliver(store, { + type: 'freshAgent.notice', + level: 'error', + code: 'COMPACTION_FAILED', + message: 'Error during compaction: API Error: 400 prompt is too long', + }) + + expect(screen.getByRole('alert')).toHaveTextContent('Error during compaction: API Error: 400 prompt is too long') + expect(store.getState().freshAgent.sessions).toBe(sessionsBefore) + expect(store.getState().freshAgent.sessions[KEY].status).toBe('running') + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument() + }) + + it('tells when a rate limit resets, in local time', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'question')])) + const store = createStore() + await mount(store) + const resetsAt = Math.floor(Date.now() / 1000) + 2 * 60 * 60 + const expectedTime = new Date(resetsAt * 1000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + + deliver(store, { + type: 'freshAgent.notice', + level: 'error', + code: 'RATE_LIMITED', + message: 'You have reached your Claude usage limit.', + rateLimit: { status: 'rejected', resetsAt, rateLimitType: 'five_hour' }, + }) + + const banner = screen.getByRole('alert') + expect(banner).toHaveTextContent('You have reached your Claude usage limit.') + expect(banner).toHaveTextContent(expectedTime) + }) + + it('shows each notice once, even when the same text repeats', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'question')])) + const store = createStore() + await mount(store) + const notice = { type: 'freshAgent.notice', level: 'warning', code: 'INFORMATIONAL', message: 'Hook feedback: lint failed' } + + deliver(store, notice) + expect(screen.getByRole('alert')).toHaveTextContent('Hook feedback: lint failed') + await advance(10_000) + expect(screen.queryByText('Hook feedback: lint failed')).not.toBeInTheDocument() + + deliver(store, notice) + expect(screen.getByRole('alert')).toHaveTextContent('Hook feedback: lint failed') + }) +}) diff --git a/test/unit/client/lib/fresh-agent-notice.test.ts b/test/unit/client/lib/fresh-agent-notice.test.ts new file mode 100644 index 000000000..0f464836d --- /dev/null +++ b/test/unit/client/lib/fresh-agent-notice.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { formatFreshAgentNotice, formatResetTime } from '@/lib/fresh-agent-notice' + +const at = (date: Date) => Math.floor(date.getTime() / 1000) + +describe('provider notice text (transcript-live D6)', () => { + const now = new Date(2026, 9, 23, 10, 0) + + it('keeps a notice without a rate limit as the CLI wrote it', () => { + expect(formatFreshAgentNotice({ message: 'Compaction failed: too long' }, now)).toBe('Compaction failed: too long') + expect(formatFreshAgentNotice({ message: 'Close to your limit.', rateLimit: { status: 'allowed_warning' } }, now)).toBe('Close to your limit.') + }) + + it('names only the time for a reset later today, and the day too for a later day', () => { + const later = new Date(2026, 9, 23, 15, 45) + const tomorrow = new Date(2026, 9, 24, 15, 45) + const today = formatResetTime(at(later), now) + const otherDay = formatResetTime(at(tomorrow), now) + expect(today).toBe(later.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })) + expect(otherDay).not.toBe(today) + expect(otherDay.endsWith(today)).toBe(true) + expect(otherDay.startsWith(tomorrow.toLocaleDateString(undefined, { weekday: 'short' }))).toBe(true) + }) + + it('reads a reset in epoch seconds or milliseconds alike', () => { + const later = new Date(2026, 9, 23, 15, 45) + expect(formatResetTime(later.getTime(), now)).toBe(formatResetTime(at(later), now)) + }) + + it('appends the reset time to a rate-limit notice', () => { + const later = new Date(2026, 9, 23, 15, 45) + const text = formatFreshAgentNotice({ + message: 'You have reached your Claude usage limit.', + rateLimit: { status: 'rejected', resetsAt: at(later) }, + }, now) + expect(text.startsWith('You have reached your Claude usage limit.')).toBe(true) + expect(text).toContain(formatResetTime(at(later), now)) + }) +}) diff --git a/test/unit/client/store/freshAgentLiveSlice.test.ts b/test/unit/client/store/freshAgentLiveSlice.test.ts index 21c785c19..db2972397 100644 --- a/test/unit/client/store/freshAgentLiveSlice.test.ts +++ b/test/unit/client/store/freshAgentLiveSlice.test.ts @@ -228,6 +228,24 @@ describe('websocket routing into the live tail (transcript-live D3)', () => { expect(tail(store).retry).toBeUndefined() }) + it('numbers each notice and drops a malformed one', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.status', status: 'running' })) + const sessionsBefore = store.getState().freshAgent.sessions + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.notice', level: 'warning', code: 'INFORMATIONAL', message: ' heads up ' })) + expect(tail(store).notice).toEqual({ seq: 1, level: 'warning', code: 'INFORMATIONAL', message: 'heads up' }) + handleFreshAgentMessage(store.dispatch, frame({ + type: 'freshAgent.notice', level: 'loud', code: 'RATE_LIMIT_WARNING', message: 'near', rateLimit: { status: 'allowed_warning', resetsAt: 1761300000, extra: 1 }, + })) + expect(tail(store).notice).toEqual({ + seq: 2, level: 'info', code: 'RATE_LIMIT_WARNING', message: 'near', rateLimit: { status: 'allowed_warning', resetsAt: 1761300000 }, + }) + handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.notice', level: 'error', code: 'X' })) + expect(tail(store).notice?.seq).toBe(2) + // A notice never ends the turn or touches the session (P4). + expect(store.getState().freshAgent.sessions).toBe(sessionsBefore) + }) + it('drops a retry frame without a positive attempt', () => { const store = createStore() handleFreshAgentMessage(store.dispatch, frame({ type: 'freshAgent.retry', attempt: 'soon' })) From a318e9da9fdbd1bb51da3d60a39541b9086b6bcd Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:45:07 -0700 Subject: [PATCH 23/45] feat(fresh-agent): the pane follows the permission mode the session states (D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freshAgent.session.metadata may now state permissionMode (absent = no statement, as for model and effort); the session record keeps it. When the stated mode changes — the CLI left plan mode after an approved ExitPlanMode, or another device changed it — the pane adopts it, so the gear shows it and the next send keeps the CLI there instead of reverting it. Only a change of the stated mode acts, so a mode the user just picked in the pane is never undone; a mode outside the pane's known set is kept out with a warn log. Co-Authored-By: Claude Opus 5.5 --- src/components/fresh-agent/FreshAgentView.tsx | 24 ++++++- src/lib/fresh-agent-ws.ts | 2 + src/store/freshAgentSlice.ts | 2 + src/store/freshAgentTypes.ts | 5 ++ .../FreshAgentView.live-tail.test.tsx | 67 ++++++++++++++++++- test/unit/client/lib/fresh-agent-ws.test.ts | 19 ++++++ 6 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index e9a3949f2..05597c647 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -109,7 +109,7 @@ import { getFreshAgentSnapshotThreadId, getManagedBootstrapHistoryThreadId, } from '@/lib/fresh-agent-snapshot-thread' -import { DEFAULT_FRESH_AGENT_STYLE, normalizeFreshAgentStyle } from '@shared/settings' +import { CLAUDE_PERMISSION_MODE_VALUES, DEFAULT_FRESH_AGENT_STYLE, normalizeFreshAgentStyle } from '@shared/settings' import { pickCheckpointForTurn, type CheckpointEntry, @@ -3971,6 +3971,28 @@ export function FreshAgentView({ shownNoticeSeqRef.current = liveNotice.seq setNotice(formatFreshAgentNotice(liveNotice)) }, [liveNotice]) + // The session's permission mode converges onto the pane (transcript-live + // §4.4): when the server states a new mode — the CLI left plan mode after an + // approved ExitPlanMode, or another device changed it — the pane adopts it, + // so its next send does not put the CLI back. Only a change of the stated + // mode acts, so a mode the user just picked here is never undone. + const sessionPermissionMode = agentSession?.permissionMode + const adoptedPermissionModeRef = useRef(undefined) + useEffect(() => { + if (!sessionPermissionMode || sessionPermissionMode === adoptedPermissionModeRef.current) return + adoptedPermissionModeRef.current = sessionPermissionMode + const current = paneContentRef.current + if (current.provider === 'opencode' || current.permissionMode === sessionPermissionMode) return + if (!(CLAUDE_PERMISSION_MODE_VALUES as readonly string[]).includes(sessionPermissionMode)) { + log.warn('the session reports a permission mode the pane cannot represent; keeping the pane\'s', { + event: 'fresh_agent.permission_mode_unknown', + provider: current.provider, + permissionMode: sessionPermissionMode, + }) + return + } + dispatch(mergePaneContent({ tabId, paneId, updates: { permissionMode: sessionPermissionMode } })) + }, [dispatch, paneId, sessionPermissionMode, tabId]) const isLiveSessionBusyNow = useCallback(() => { const status = liveSessionKey ? appStore.getState().freshAgent.sessions[liveSessionKey]?.status : undefined return BUSY_STATES.has(status ?? paneContentRef.current.status) diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index eba8456b6..42769bf69 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -490,6 +490,8 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh cliSessionId: event.cliSessionId as string | undefined, model: event.model as string | null | undefined, effort: event.effort as string | null | undefined, + // Absent = no statement (as for model/effort); only a mode name states one. + ...(typeof event.permissionMode === 'string' && event.permissionMode ? { permissionMode: event.permissionMode } : {}), cwd: event.cwd as string | undefined, tools: event.tools as Array<{ name: string }> | undefined, })) diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 1675c227f..54b4fbf7d 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -214,6 +214,7 @@ const freshAgentSlice = createSlice({ cliSessionId?: string model?: string | null effort?: string | null + permissionMode?: string cwd?: string tools?: Array<{ name: string }> }>) { @@ -237,6 +238,7 @@ const freshAgentSlice = createSlice({ // and the effort tooltip track the live session exactly. if (action.payload.model !== undefined) session.model = action.payload.model if (action.payload.effort !== undefined) session.effort = action.payload.effort + if (action.payload.permissionMode !== undefined) session.permissionMode = action.payload.permissionMode session.cwd = action.payload.cwd ?? session.cwd session.tools = action.payload.tools ?? session.tools if (metadataCliSessionId && !wouldDowngradeSnapshotIdentity) { diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index 23f8ad4d4..855c23a8b 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -107,6 +107,11 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { * it. The status-strip tooltip prefers it over the REST snapshot's * settings.effort: the event is fresher by construction. */ effort?: string | null + /** The LIVE session's permission mode as the server last stated it + * (`freshAgent.session.metadata`): after a configure, or when the CLI + * changed it itself (an approved ExitPlanMode leaves plan mode). Absent + * while no metadata ever stated it. */ + permissionMode?: string tools?: Array<{ name: string }> turns: FreshAgentTurn[] historyItems: FreshAgentTurn[] diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx index a44fa501f..e765bc1f0 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.live-tail.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import { appReducers } from '@/store/appReducers' -import { initLayout } from '@/store/panesSlice' +import { initLayout, mergePaneContent } from '@/store/panesSlice' import { setStatus } from '@/store/connectionSlice' import { useAppSelector } from '@/store/hooks' import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' @@ -399,3 +399,66 @@ describe('provider notices (transcript-live D6)', () => { expect(screen.getByRole('alert')).toHaveTextContent('Hook feedback: lint failed') }) }) + +describe('the CLI\'s own permission mode (transcript-live D7)', () => { + function setPanePermissionMode(store: Store, permissionMode: string) { + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { permissionMode } })) + } + + function paneContent(store: Store) { + const layout = store.getState().panes.layouts['tab-1'] + if (!layout || layout.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('missing pane') + return layout.content + } + + function sends() { + return wsMock.send.mock.calls.map(([message]) => message as Record).filter((message) => message?.type === 'freshAgent.send') + } + + it('adopts a mode change the CLI reports and keeps it on the next send', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'plan it')])) + const store = createStore() + setPanePermissionMode(store, 'plan') + await mount(store) + + // ExitPlanMode was approved: the CLI left plan mode on its own. + deliver(store, { type: 'freshAgent.session.metadata', permissionMode: 'default' }) + await advance(0) + expect(paneContent(store).permissionMode).toBe('default') + + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + act(() => { + fireEvent.change(composer, { target: { value: 'now build it' } }) + }) + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + }) + expect(sends().at(-1)?.settings?.permissionMode).toBe('default') + }) + + it('leaves the pane\'s own later choice alone when unrelated metadata repeats the session\'s mode', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'hi')])) + const store = createStore() + setPanePermissionMode(store, 'plan') + await mount(store) + deliver(store, { type: 'freshAgent.session.metadata', permissionMode: 'default' }) + await advance(0) + + // The user picks a mode in the pane; a model-only statement must not undo it. + act(() => setPanePermissionMode(store, 'acceptEdits')) + deliver(store, { type: 'freshAgent.session.metadata', model: 'claude-sonnet-4-6', permissionMode: 'default' }) + deliver(store, { type: 'freshAgent.session.metadata', model: 'claude-opus-4-6' }) + await advance(0) + expect(paneContent(store).permissionMode).toBe('acceptEdits') + }) + + it('ignores a mode the pane cannot represent', async () => { + serve(() => claudeSnapshot([userTurn('u-user', 'hi')])) + const store = createStore() + setPanePermissionMode(store, 'plan') + await mount(store) + deliver(store, { type: 'freshAgent.session.metadata', permissionMode: 'someFutureMode' }) + await advance(0) + expect(paneContent(store).permissionMode).toBe('plan') + }) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 9ea42c068..ba3c3a1b6 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -802,6 +802,25 @@ describe('fresh-agent-ws', () => { }) }) + it('folds a stated permission mode; metadata that states none keeps it (transcript-live D7)', () => { + const store = createFreshAgentStore() + const sessionId = 'claude-permission-mode' + const key = `freshclaude:claude:${sessionId}` + const metadata = (fields: Record) => handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.event', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + event: { type: 'freshAgent.session.metadata', sessionId, ...fields }, + }) + metadata({ permissionMode: 'bypassPermissions' }) + expect(store.getState().freshAgent.sessions[key].permissionMode).toBe('bypassPermissions') + metadata({ model: 'claude-sonnet-4-6', effort: null }) + expect(store.getState().freshAgent.sessions[key].permissionMode).toBe('bypassPermissions') + metadata({ permissionMode: 'default' }) + expect(store.getState().freshAgent.sessions[key].permissionMode).toBe('default') + }) + it('does not let delayed metadata downgrade newer snapshot identity', () => { const store = createFreshAgentStore() const sessionId = 'claude-thread-metadata-order' From b870e7051b1d0829dcd40698a7d18b71d51c60d9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:45:35 -0700 Subject: [PATCH 24/45] fix(fresh-agent): a turn's cost replaces the session total instead of adding to it (D8) The SDK's total_cost_usd on each result already covers the whole session, so summing it counted every earlier turn again. Token usage is per turn and still adds up; a result without a cost keeps the last known total. Co-Authored-By: Claude Opus 5.5 --- src/store/freshAgentSlice.ts | 4 +++- test/unit/client/store/freshAgentSlice.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 54b4fbf7d..56179bf64 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -549,7 +549,9 @@ const freshAgentSlice = createSlice({ if (!session) return writeSessionStatus(session, 'idle') session.streamingActive = false - session.totalCostUsd += action.payload.costUsd ?? 0 + // The SDK's total_cost_usd already covers the whole session so far: it + // is the total, never an increment. (Token usage is per turn, so it adds.) + if (typeof action.payload.costUsd === 'number') session.totalCostUsd = action.payload.costUsd session.totalInputTokens += action.payload.usage?.input_tokens ?? 0 session.totalOutputTokens += action.payload.usage?.output_tokens ?? 0 }, diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts index 9934eb3bb..840076298 100644 --- a/test/unit/client/store/freshAgentSlice.test.ts +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -15,6 +15,7 @@ import reducer, { sessionInit, sessionSnapshotReceived, setSessionStatus, + turnResult, } from '@/store/freshAgentSlice' describe('freshAgentSlice busy/streaming clearing', () => { @@ -262,3 +263,20 @@ describe('freshAgentSlice lost revocation on truth-bearing snapshot evidence', ( expect(state.sessions[key].lost).toBe(false) }) }) + +describe('turnResult cost and usage (transcript-live D8)', () => { + const loc = { sessionId: 'cost-1', sessionType: 'freshclaude' as const, provider: 'claude' as const } + const key = makeFreshAgentSessionKey(loc) + + it('takes the SDK\'s session-cumulative cost as the total and adds up per-turn token usage', () => { + let state = reducer(undefined, turnResult({ ...loc, costUsd: 0.02, usage: { input_tokens: 10, output_tokens: 4 } })) + state = reducer(state, turnResult({ ...loc, costUsd: 0.05, usage: { input_tokens: 7, output_tokens: 3 } })) + expect(state.sessions[key]).toMatchObject({ totalCostUsd: 0.05, totalInputTokens: 17, totalOutputTokens: 7 }) + }) + + it('keeps the last known cost when a result carries none', () => { + let state = reducer(undefined, turnResult({ ...loc, costUsd: 0.05 })) + state = reducer(state, turnResult({ ...loc })) + expect(state.sessions[key].totalCostUsd).toBe(0.05) + }) +}) From cda363e85469512319caf749db810315237aef6d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:06 -0700 Subject: [PATCH 25/45] fix(fresh-agent): a streamed block whose line uuid never arrived still hides once it lands A live block was hidden only by the line uuid its assistant frame names. When the snapshot holding the line outran that frame, or a reconnect lost it, the block stayed beside its durable turn and the text showed twice. The CLI writes one line per content block of a message, in block order (real-CLI fact F2), and snapshot turns carry their API message id, so an unbound block now lands when the line at its message position holds the same kind of block. A bound block is still matched by its uuid alone. fresh-agent-landed-transcript.ts derives this once per committed snapshot and is shared by the reducer, the tail and the pane. Co-Authored-By: Claude Opus 5.5 --- .../fresh-agent/FreshAgentLiveTail.tsx | 21 +++--- src/components/fresh-agent/FreshAgentView.tsx | 16 ++-- .../fresh-agent/useTranscriptLandings.ts | 39 ++++------ src/lib/fresh-agent-landed-transcript.ts | 75 +++++++++++++++++++ src/store/freshAgentLiveSlice.ts | 23 ++++-- .../fresh-agent/FreshAgentLiveTail.test.tsx | 31 ++++++-- .../client/store/freshAgentLiveSlice.test.ts | 26 +++++++ 7 files changed, 171 insertions(+), 60 deletions(-) create mode 100644 src/lib/fresh-agent-landed-transcript.ts diff --git a/src/components/fresh-agent/FreshAgentLiveTail.tsx b/src/components/fresh-agent/FreshAgentLiveTail.tsx index fb5de4afe..bd8d9a326 100644 --- a/src/components/fresh-agent/FreshAgentLiveTail.tsx +++ b/src/components/fresh-agent/FreshAgentLiveTail.tsx @@ -2,6 +2,7 @@ import { memo, useContext, useLayoutEffect, useMemo } from 'react' import type { FreshAgentTranscriptItem, FreshAgentTurn } from '@shared/fresh-agent-contract' import { useAppSelector } from '@/store/hooks' import type { FreshAgentLiveSession, LiveBlock, LiveRetry } from '@/store/freshAgentLiveTypes' +import { isLiveBlockLanded, type LandedTranscript } from '@/lib/fresh-agent-landed-transcript' import { buildTranscriptLayout, FreshAgentTurnArticle, @@ -52,17 +53,17 @@ function blockItem(block: LiveBlock): FreshAgentTranscriptItem { /** * The live tail's one synthetic turn: the blocks whose transcript line is not - * on screen yet (a block whose uuid the rendered snapshot holds is already - * shown there), then the API retry in progress — the newest thing happening, + * on screen yet (a block the rendered snapshot already shows is hidden in the + * same frame), then the API retry in progress — the newest thing happening, * so it is the strip's live row. */ export function buildLiveTailTurn( tail: FreshAgentLiveSession | undefined, - landedTurnIds: ReadonlySet, + landed: LandedTranscript, ): FreshAgentTurn | null { if (!tail) return null const items: FreshAgentTranscriptItem[] = tail.blocks - .filter((block) => block.uuid === undefined || !landedTurnIds.has(block.uuid)) + .filter((block) => !isLiveBlockLanded(block, landed)) .map(blockItem) if (tail.retry) { const error = describeRetry(tail.retry) @@ -79,12 +80,12 @@ export function buildLiveTailTurn( */ export function liveTailShowsContent( tail: FreshAgentLiveSession | undefined, - landedTurnIds: ReadonlySet, + landed: LandedTranscript, ): boolean { if (!tail) return false if (tail.retry) return true return tail.blocks.some((block) => ( - (block.uuid === undefined || !landedTurnIds.has(block.uuid)) + !isLiveBlockLanded(block, landed) && (block.kind === 'tool_use' || block.text.trim().length > 0) )) } @@ -110,21 +111,21 @@ function tailSignature(turn: FreshAgentTurn | null): string { */ function FreshAgentLiveTailImpl({ sessionKey, - landedTurnIds, + landed, agentLabel, expandThinking = false, expandTools = false, }: { sessionKey: string - /** Line uuids the rendered snapshot already shows. */ - landedTurnIds: ReadonlySet + /** What the rendered snapshot already shows. */ + landed: LandedTranscript agentLabel?: string expandThinking?: boolean expandTools?: boolean }) { const tail = useAppSelector((state) => state.freshAgentLive?.bySession[sessionKey]) const follow = useContext(TranscriptFollowContext) - const turn = useMemo(() => buildLiveTailTurn(tail, landedTurnIds), [tail, landedTurnIds]) + const turn = useMemo(() => buildLiveTailTurn(tail, landed), [tail, landed]) const layout = useMemo(() => { if (!turn) return null const built = buildTranscriptLayout([turn]) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 05597c647..78c381475 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -122,7 +122,8 @@ import { FreshAgentOpenSessionContext } from './FreshAgentItemCard' import FreshAgentQuestionBanner from './FreshAgentQuestionBanner' import { FreshAgentTranscript, type FreshAgentTranscriptHandle } from './FreshAgentTranscript' import { FreshAgentLiveTail, liveTailShowsContent } from './FreshAgentLiveTail' -import { presentTranscriptLineIds, useTranscriptLandings } from './useTranscriptLandings' +import { useTranscriptLandings } from './useTranscriptLandings' +import { landedTranscriptOf } from '@/lib/fresh-agent-landed-transcript' import { formatFreshAgentNotice } from '@/lib/fresh-agent-notice' import type { FreshAgentTurnRewindAvailability } from './FreshAgentTurnActions' import { FreshAgentComposer, type FreshAgentComposerHandle } from './FreshAgentComposer' @@ -3952,12 +3953,9 @@ export function FreshAgentView({ const liveSessionKey = paneContent.sessionId ? makeFreshAgentSessionKey({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider }) : undefined - const landedTurnIds = useMemo>( - () => new Set(snapshot ? presentTranscriptLineIds(snapshot) : []), - [snapshot], - ) + const landedTranscript = useMemo(() => landedTranscriptOf(snapshot), [snapshot]) const liveTailActive = useAppSelector((state) => ( - liveSessionKey ? liveTailShowsContent(state.freshAgentLive?.bySession[liveSessionKey], landedTurnIds) : false + liveSessionKey ? liveTailShowsContent(state.freshAgentLive?.bySession[liveSessionKey], landedTranscript) : false )) // Provider notices (rate limits, a failed compaction, CLI banners) show in // the pane's notice banner; they never end the turn (transcript-live P4). @@ -4002,7 +4000,7 @@ export function FreshAgentView({ }, [requestSnapshotRefresh]) useTranscriptLandings({ sessionKey: liveSessionKey, - snapshot, + landed: snapshot ? landedTranscript : null, busy: isBusy, isBusyNow: isLiveSessionBusyNow, requestRefresh: requestLandingRecheck, @@ -4804,7 +4802,7 @@ export function FreshAgentView({ tail={liveSessionKey ? ( () - for (const turn of snapshot.turns) { - ids.add(turn.turnId) - ids.add(turn.id) - } - const claude = snapshot.extensions?.claude - const lineUuids = claude && typeof claude === 'object' ? (claude as { transcriptLineUuids?: unknown }).transcriptLineUuids : undefined - if (Array.isArray(lineUuids)) { - for (const uuid of lineUuids) if (typeof uuid === 'string') ids.add(uuid) - } - return [...ids] -} - function hasLiveContent(tail: FreshAgentLiveSession | undefined): boolean { return Boolean(tail && (tail.blocks.length > 0 || tail.retry || tail.expectedTurnIds.length > 0)) } /** * Keeps a pane's live tail in step with the snapshots it commits. On every - * committed snapshot: the blocks and announced lines it holds are landed; an + * committed snapshot (`landed`, derived once per snapshot): the blocks and + * announced lines it holds are landed; an * announced line still missing earns a bounded re-poll; and once nothing is * awaited and the turn is over, the tail is cleared — after its lines are on * screen, so the text never blinks out between the result and the snapshot. @@ -54,13 +36,14 @@ function hasLiveContent(tail: FreshAgentLiveSession | undefined): boolean { */ export function useTranscriptLandings({ sessionKey, - snapshot, + landed, busy, isBusyNow, requestRefresh, }: { sessionKey: string | undefined - snapshot: FreshAgentSnapshot | null + /** What the committed snapshot holds; null before the first one. */ + landed: LandedTranscript | null busy: boolean /** The pane's busy state at the moment of the check (not of the last render). */ isBusyNow: () => boolean @@ -79,8 +62,12 @@ export function useTranscriptLandings({ }, [sessionKey]) useEffect(() => { - if (!sessionKey || !snapshot) return - dispatch(transcriptLanded({ sessionKey, present: presentTranscriptLineIds(snapshot) })) + if (!sessionKey || !landed) return + dispatch(transcriptLanded({ + sessionKey, + present: [...landed.lineIds], + messageBlockKinds: landed.messageBlockKinds, + })) const expected = store.getState().freshAgentLive?.bySession[sessionKey]?.expectedTurnIds ?? [] const attempts = attemptsRef.current for (const uuid of [...attempts.keys()]) { @@ -112,7 +99,7 @@ export function useTranscriptLandings({ return } if (!isBusyNowRef.current()) dispatch(liveTailCleared({ sessionKey })) - }, [dispatch, sessionKey, snapshot, store]) + }, [dispatch, landed, sessionKey, store]) const wasBusyRef = useRef(busy) useEffect(() => { diff --git a/src/lib/fresh-agent-landed-transcript.ts b/src/lib/fresh-agent-landed-transcript.ts new file mode 100644 index 000000000..772a28f5c --- /dev/null +++ b/src/lib/fresh-agent-landed-transcript.ts @@ -0,0 +1,75 @@ +import type { FreshAgentSnapshot, FreshAgentTurn } from '@shared/fresh-agent-contract' +import type { LiveBlock, LiveBlockKind } from '@/store/freshAgentLiveTypes' + +/** + * What a committed snapshot proves the CLI has written to its transcript, + * in the two forms a live-tail block can be matched by (transcript-live §4.4). + */ +export type LandedTranscript = { + /** + * Line uuids on screen: the snapshot's turn ids (a claude turn is keyed by + * its line uuid) plus the newest line uuids the server lists separately, + * which include lines that produce no turn of their own. + */ + lineIds: ReadonlySet + /** + * Per API message id, the block kind of each of its lines, in order. The CLI + * writes one line per content block of a message, in block order (real-CLI + * fact F2), so the line at position i is block i. `null` marks a line no + * live block corresponds to. + */ + messageBlockKinds: Readonly>> +} + +export function landedTranscript( + lineIds: Iterable, + messageBlockKinds: LandedTranscript['messageBlockKinds'] = {}, +): LandedTranscript { + return { lineIds: new Set(lineIds), messageBlockKinds } +} + +/** The live block kind a durable turn's line holds, or null when it holds none. */ +function blockKindOf(turn: FreshAgentTurn): LiveBlockKind | null { + switch (turn.items[0]?.kind) { + case 'text': return 'text' + case 'thinking': return 'thinking' + // A subagent's tool line is projected as its delegation. + case 'tool_use': + case 'task_delegation': return 'tool_use' + default: return null + } +} + +export function landedTranscriptOf(snapshot: FreshAgentSnapshot | null): LandedTranscript { + if (!snapshot) return landedTranscript([]) + const lineIds = new Set() + const messageBlockKinds: Record> = {} + for (const turn of snapshot.turns) { + lineIds.add(turn.turnId) + lineIds.add(turn.id) + if (turn.role === 'assistant' && turn.messageId) { + (messageBlockKinds[turn.messageId] ??= []).push(blockKindOf(turn)) + } + } + const claude = snapshot.extensions?.claude + const lineUuids = claude && typeof claude === 'object' + ? (claude as { transcriptLineUuids?: unknown }).transcriptLineUuids + : undefined + if (Array.isArray(lineUuids)) { + for (const uuid of lineUuids) if (typeof uuid === 'string') lineIds.add(uuid) + } + return { lineIds, messageBlockKinds } +} + +/** + * Whether a live block is already on screen in the snapshot. A block whose + * line uuid this client learned (its assistant frame) is matched by that uuid + * alone. A block it never learned the uuid of — the snapshot outran the + * assistant frame, or a reconnect lost it — is matched by its API message id + * and position, and only when the line there holds the same kind of block. + */ +export function isLiveBlockLanded(block: LiveBlock, landed: LandedTranscript): boolean { + if (block.uuid !== undefined) return landed.lineIds.has(block.uuid) + if (block.messageId === null) return false + return landed.messageBlockKinds[block.messageId]?.[block.index] === block.kind +} diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index 10d7305cc..586a4921a 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -1,5 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import { isLiveBlockLanded, landedTranscript, type LandedTranscript } from '@/lib/fresh-agent-landed-transcript' import { markSessionLost, materializeSession, @@ -151,16 +152,24 @@ const freshAgentLiveSlice = createSlice({ if (!session.expectedTurnIds.includes(uuid)) session.expectedTurnIds.push(uuid) }, - /** A committed snapshot holds these line uuids: their blocks and expectations are done. */ - transcriptLanded(state, action: PayloadAction<{ sessionKey: string; present: readonly string[] }>) { + /** + * A committed snapshot holds these lines: the blocks it shows (by uuid, or + * by message id and position for a block whose uuid never arrived) and + * the announced lines it holds are done. + */ + transcriptLanded(state, action: PayloadAction<{ + sessionKey: string + present: readonly string[] + messageBlockKinds?: LandedTranscript['messageBlockKinds'] + }>) { const session = state.bySession[action.payload.sessionKey] if (!session) return - const present = new Set(action.payload.present) - if (session.blocks.some((block) => block.uuid !== undefined && present.has(block.uuid))) { - session.blocks = session.blocks.filter((block) => block.uuid === undefined || !present.has(block.uuid)) + const landed = landedTranscript(action.payload.present, action.payload.messageBlockKinds) + if (session.blocks.some((block) => isLiveBlockLanded(block, landed))) { + session.blocks = session.blocks.filter((block) => !isLiveBlockLanded(block, landed)) } - if (session.expectedTurnIds.some((uuid) => present.has(uuid))) { - session.expectedTurnIds = session.expectedTurnIds.filter((uuid) => !present.has(uuid)) + if (session.expectedTurnIds.some((uuid) => landed.lineIds.has(uuid))) { + session.expectedTurnIds = session.expectedTurnIds.filter((uuid) => !landed.lineIds.has(uuid)) } }, diff --git a/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx index 5950775a4..fd050db05 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentLiveTail.test.tsx @@ -11,6 +11,7 @@ import freshAgentLiveReducer, { import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' import { FreshAgentLiveTail } from '@/components/fresh-agent/FreshAgentLiveTail' import { FreshAgentTranscript, TranscriptFollowContext } from '@/components/fresh-agent/FreshAgentTranscript' +import { landedTranscript, type LandedTranscript } from '@/lib/fresh-agent-landed-transcript' // Render markdown synchronously (the transcript tests' established mock): the // real LazyMarkdown swaps a plain fallback for the renderer chunk once loaded. @@ -23,7 +24,7 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { const locator = { sessionId: 'tail-1', sessionType: 'freshclaude' as const, provider: 'claude' as const } const KEY = 'freshclaude:claude:tail-1' -const NONE: ReadonlySet = new Set() +const NONE: LandedTranscript = landedTranscript([]) function createStore() { return configureStore({ reducer: { freshAgent: freshAgentReducer, freshAgentLive: freshAgentLiveReducer } }) @@ -42,10 +43,10 @@ const start = (index: number, kind: 'text' | 'thinking' | 'tool_use', extra: Rec ) const delta = (index: number, text: string): LiveStreamOp => ({ op: 'delta', messageId: 'msg_1', index, text }) -function renderTail(store: Store, landed: ReadonlySet = NONE) { +function renderTail(store: Store, landed: LandedTranscript = NONE) { return render( - + , ) } @@ -130,7 +131,7 @@ describe('FreshAgentLiveTail (transcript-live D4)', () => { rerender( - + , ) expect(screen.queryByText('Final words')).not.toBeInTheDocument() @@ -141,7 +142,21 @@ describe('FreshAgentLiveTail (transcript-live D4)', () => { }) rerender( - + + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('hides a block whose assistant frame never arrived once the snapshot holds its message\'s line at its position', async () => { + const store = createStore() + apply(store, start(0, 'text'), delta(0, 'Unbound words')) + const { rerender, container } = renderTail(store) + await screen.findByText('Unbound words') + + rerender( + + , ) expect(container).toBeEmptyDOMElement() @@ -154,7 +169,7 @@ describe('FreshAgentLiveTail (transcript-live D4)', () => { render( - + , ) @@ -171,7 +186,7 @@ describe('FreshAgentLiveTail (transcript-live D4)', () => { render( {}, lastTurnRole: 'assistant' }}> - + , ) @@ -198,7 +213,7 @@ describe('FreshAgentTranscript live tail slot (transcript-live D4)', () => { } + tail={} /> , ) diff --git a/test/unit/client/store/freshAgentLiveSlice.test.ts b/test/unit/client/store/freshAgentLiveSlice.test.ts index db2972397..6fbb07d77 100644 --- a/test/unit/client/store/freshAgentLiveSlice.test.ts +++ b/test/unit/client/store/freshAgentLiveSlice.test.ts @@ -120,6 +120,32 @@ describe('freshAgentLive slice (transcript-live D3)', () => { expect(tail(store).blocks.map((block) => block.uuid ?? block.kind)).toEqual(['u-text', 'tool_use']) }) + it('lands a block it never learned the line of by its API message id and position', () => { + const store = createStore() + // The snapshot holding msg_1's lines outran the assistant frames that name them. + streamTurn(store) + apply(store, start('msg_2', 0, 'text'), delta('msg_2', 0, 'next message')) + store.dispatch(transcriptLanded({ + sessionKey: KEY, + present: ['line-a', 'line-b'], + messageBlockKinds: { msg_1: ['thinking', 'text'] }, + })) + // msg_1's thinking (0) and text (1) are on screen; its tool (2) and msg_2 are not. + expect(tail(store).blocks.map((block) => `${block.messageId}:${block.index}`)).toEqual(['msg_1:2', 'msg_2:0']) + }) + + it('trusts a bound block\'s uuid over its position, and never lands a block of another kind', () => { + const store = createStore() + apply(store, start('msg_1', 0, 'text'), delta('msg_1', 0, 'bound'), start('msg_1', 1, 'thinking'), delta('msg_1', 1, 'mismatch')) + store.dispatch(assistantBlockBound({ ...locator, uuid: 'u-text', messageId: 'msg_1', content: [{ type: 'text', text: 'bound' }] })) + store.dispatch(transcriptLanded({ + sessionKey: KEY, + present: ['some-other-line'], + messageBlockKinds: { msg_1: ['text', 'text'] }, + })) + expect(tail(store).blocks.map((block) => block.text)).toEqual(['bound', 'mismatch']) + }) + it('shows a retry until the next block starts', () => { const store = createStore() store.dispatch(liveRetrySet({ ...locator, attempt: 2, maxRetries: 10, delayMs: 1200, errorStatus: 529, error: 'overloaded' })) From a48e4807192471fa1ac76be65c75bbda78799f7c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:06 -0700 Subject: [PATCH 26/45] test(fresh-agent): a streamed answer is on screen exactly once, frame by frame (D9) A jsdom flow over the app's real store, the real websocket handler and batcher and the real pane, with only the snapshot API mocked. A React profiler counts the answer on screen after every commit: from its first token through the landed transcript and the turn's end it is there exactly once, also when its line lands only after the result. The first case found the unbound-block duplicate fixed in the previous commit; clearing the tail before its lines land fails the second (a gap frame). Co-Authored-By: Claude Opus 5.5 --- .../freshclaude-live-rendering-flow.test.tsx | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 test/e2e/freshclaude-live-rendering-flow.test.tsx diff --git a/test/e2e/freshclaude-live-rendering-flow.test.tsx b/test/e2e/freshclaude-live-rendering-flow.test.tsx new file mode 100644 index 000000000..9d2547be7 --- /dev/null +++ b/test/e2e/freshclaude-live-rendering-flow.test.tsx @@ -0,0 +1,251 @@ +import { Profiler } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import { appReducers } from '@/store/appReducers' +import { initLayout } from '@/store/panesSlice' +import { setStatus } from '@/store/connectionSlice' +import { useAppSelector } from '@/store/hooks' +import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { resetSnapshotSchedulerForTests, SNAPSHOT_DEBOUNCE_MS } from '@/lib/fresh-agent-snapshot-scheduler' +import { LIVE_STREAM_FLUSH_MS } from '@/lib/fresh-agent-stream-batcher' + +// Transcript-live plan D9: one freshclaude turn end to end in the DOM — the +// app's real store and reducers, the real websocket handler (with its stream +// batcher) and the real pane, with only the snapshot API mocked. Streamed +// text appears, the snapshot holding its line commits, the turn ends; across +// every React commit in between, the text is on screen exactly once: never +// twice (tail and transcript together) and never zero times (a gap between +// the tail letting go and the transcript taking over). + +const wsMock = vi.hoisted(() => ({ + handlers: new Set<(message: unknown) => void>(), + send: vi.fn(), +})) + +const apiMock = vi.hoisted(() => ({ + getFreshAgentThreadSnapshot: vi.fn(), +})) + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: wsMock.send, + onMessage: (handler: (message: unknown) => void) => { + wsMock.handlers.add(handler) + return () => { wsMock.handlers.delete(handler) } + }, + onReconnect: () => () => {}, + }), +})) + +vi.mock('@/lib/api', async () => { + const actual = await vi.importActual('@/lib/api') + return { + ...actual, + api: { + ...actual.api, + get: vi.fn().mockRejectedValue(new Error('no server in tests')), + post: vi.fn().mockRejectedValue(new Error('no server in tests')), + }, + getFreshAgentThreadSnapshot: apiMock.getFreshAgentThreadSnapshot, + getFreshAgentCheckpointOutcomePage: vi.fn().mockResolvedValue({ outcomes: [], nextCursor: null }), + getFreshAgentModelCapabilities: vi.fn().mockRejectedValue(new Error('no server in tests')), + setSessionMetadata: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock('@/store/settingsThunks', () => ({ + saveServerSettingsPatch: (patch: unknown) => ({ type: 'settings/saveServerSettingsPatch', payload: patch }), +})) + +// Synchronous markdown (the transcript tests' established mock), so a frame +// never holds both the renderer's Suspense fallback and its content. +vi.mock('@/components/markdown/LazyMarkdown', async () => { + const { MarkdownRenderer } = await import('@/components/markdown/MarkdownRenderer') + return { + LazyMarkdown: ({ content }: { content: string }) => , + } +}) + +const SESSION_ID = '550e8400-e29b-41d4-a716-44665544d009' +const locator = { sessionId: SESSION_ID, sessionType: 'freshclaude' as const, provider: 'claude' as const } +const ANSWER = 'Kittens are small cats that love yarn.' +const ANSWER_CHUNKS = ['Kittens are ', 'small cats ', 'that love ', 'yarn.'] + +type Turn = { id: string; turnId: string; messageId?: string; role: 'user' | 'assistant'; summary: string; items: unknown[] } + +const question: Turn = { id: 'u-question', turnId: 'u-question', role: 'user', summary: 'Tell me about kittens', items: [{ id: 'q', kind: 'text', text: 'Tell me about kittens' }] } +// As the server projects it: keyed by its line uuid, carrying its API message id. +const answer: Turn = { id: 'u-answer', turnId: 'u-answer', messageId: 'msg_kittens', role: 'assistant', summary: ANSWER, items: [{ id: 'a', kind: 'text', text: ANSWER }] } + +function claudeSnapshot(turns: Turn[]) { + return { + sessionType: 'freshclaude', + provider: 'claude', + threadId: SESSION_ID, + sessionId: SESSION_ID, + revision: 1, + latestTurnId: turns.at(-1)?.turnId ?? null, + status: 'idle', + summary: '', + capabilities: { send: true, interrupt: true, approvals: false, questions: false, fork: false }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + pendingApprovals: [], + pendingQuestions: [], + worktrees: [], + diffs: [], + childThreads: [], + turns, + extensions: { claude: { transcriptLineUuids: turns.map((turn) => turn.turnId) } }, + } +} + +/** The transcript as the server would serve it next; a new object per read, as parsed JSON is. */ +let served: Turn[] = [] + +function createStore() { + const store = configureStore({ + reducer: appReducers, + middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }), + }) + store.dispatch(setStatus('ready')) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { kind: 'fresh-agent', ...locator, createRequestId: 'req-live-flow', status: 'idle' }, + })) + return store +} + +type Store = ReturnType + +function StoreBackedView() { + const content = useAppSelector((state) => { + const layout = state.panes.layouts['tab-1'] + if (!layout || layout.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('missing pane') + return layout.content + }) + return +} + +/** How many times the answer is on screen, counted after every React commit. */ +function countOnScreen(text: string): number { + return (document.body.textContent ?? '').split(text).length - 1 +} + +function renderFlow(store: Store) { + const frames: number[] = [] + render( + { frames.push(countOnScreen(ANSWER)) }}> + + + + , + ) + return frames +} + +function deliver(store: Store, event: Record) { + const message = { type: 'freshAgent.event', ...locator, event: { sessionId: SESSION_ID, ...event } } + act(() => { + handleFreshAgentMessage(store.dispatch, message) + for (const handler of [...wsMock.handlers]) handler(message) + }) +} + +const stream = (event: Record) => ({ + type: 'freshAgent.stream', messageId: 'msg_kittens', parentToolUseId: null, event, +}) + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +async function settleRefetches(rounds: number) { + for (let i = 0; i < rounds; i += 1) await advance(SNAPSHOT_DEBOUNCE_MS) +} + +/** The model streams the answer, then the CLI sends the block's message and announces its line. */ +async function streamAnswer(store: Store) { + deliver(store, { type: 'freshAgent.status', status: 'running' }) + deliver(store, stream({ type: 'message_start', message: { id: 'msg_kittens' } })) + deliver(store, stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })) + for (const chunk of ANSWER_CHUNKS) { + deliver(store, stream({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: chunk } })) + await advance(LIVE_STREAM_FLUSH_MS) + } + deliver(store, stream({ type: 'content_block_stop', index: 0 })) + deliver(store, { + type: 'freshAgent.assistant', content: [{ type: 'text', text: ANSWER }], uuid: 'u-answer', messageId: 'msg_kittens', parentToolUseId: null, + }) + deliver(store, { type: 'freshAgent.session.changed', reason: 'transcript', uuid: 'u-answer', parentToolUseId: null }) +} + +/** Every frame from the first one showing the answer: the answer is there exactly once. */ +function framesSinceFirstShown(frames: number[]): number[] { + const first = frames.findIndex((count) => count > 0) + expect(first).toBeGreaterThanOrEqual(0) + return frames.slice(first) +} + +beforeEach(() => { + vi.useFakeTimers() + resetSnapshotSchedulerForTests() + wsMock.handlers.clear() + wsMock.send.mockReset() + served = [question] + apiMock.getFreshAgentThreadSnapshot.mockReset() + apiMock.getFreshAgentThreadSnapshot.mockImplementation(async () => claudeSnapshot(served)) +}) + +afterEach(() => { + cleanup() + resetSnapshotSchedulerForTests() + vi.useRealTimers() +}) + +describe('freshclaude live rendering flow (transcript-live D9)', () => { + it('shows a streamed answer exactly once from its first token through the landed transcript and the turn\'s end', async () => { + const store = createStore() + const frames = renderFlow(store) + await settleRefetches(2) + expect(countOnScreen(ANSWER)).toBe(0) + + served = [question, answer] + await streamAnswer(store) + await settleRefetches(3) + deliver(store, { type: 'freshAgent.result', costUsd: 0.01 }) + deliver(store, { type: 'freshAgent.turn.complete', at: Date.now() }) + await settleRefetches(4) + + expect(countOnScreen(ANSWER)).toBe(1) + expect(document.querySelector('[data-turn-source="live"]')).toBeNull() + const shown = framesSinceFirstShown(frames) + expect(shown.length).toBeGreaterThan(3) + expect(shown.filter((count) => count !== 1)).toEqual([]) + }) + + it('keeps the answer on screen exactly once when its line lands only after the turn ended', async () => { + const store = createStore() + const frames = renderFlow(store) + await settleRefetches(2) + + // The CLI is slow to write the line: the refetches around the result miss it. + await streamAnswer(store) + deliver(store, { type: 'freshAgent.result', costUsd: 0.01 }) + await settleRefetches(2) + expect(countOnScreen(ANSWER)).toBe(1) + expect(document.querySelector('[data-turn-source="live"]')).not.toBeNull() + + served = [question, answer] + await settleRefetches(4) + + expect(countOnScreen(ANSWER)).toBe(1) + expect(document.querySelector('[data-turn-source="live"]')).toBeNull() + expect(framesSinceFirstShown(frames).filter((count) => count !== 1)).toEqual([]) + }) +}) From 2160e33a85346268004b2b114f399c24b0db250c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:52:13 -0700 Subject: [PATCH 27/45] test(fresh-agent): the live tail lands a real CLI turn by uuid and by position Replays the pinned CLI's recorded tools-sync turn through the real sidecar mapper and the websocket handler, then lands it against the snapshot the server projects from the transcript the CLI wrote: every streamed main-thread block binds to its line, and every block lands once the snapshot holds the turn, also when every assistant frame was lost. It pins the order the position match relies on (one line per content block, in block order, F2) to real data. Co-Authored-By: Claude Opus 5.5 --- .../lib/fresh-agent-landed-transcript.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/unit/client/lib/fresh-agent-landed-transcript.test.ts diff --git a/test/unit/client/lib/fresh-agent-landed-transcript.test.ts b/test/unit/client/lib/fresh-agent-landed-transcript.test.ts new file mode 100644 index 000000000..cecdadb5a --- /dev/null +++ b/test/unit/client/lib/fresh-agent-landed-transcript.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' +import { FreshAgentSnapshotSchema, type FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import freshAgentReducer from '@/store/freshAgentSlice' +import freshAgentLiveReducer, { transcriptLanded } from '@/store/freshAgentLiveSlice' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' +import { landedTranscriptOf } from '@/lib/fresh-agent-landed-transcript' +import { + createFrameState, + framesForSdkMessage, +} from '../../../../crates/freshell-claude-sidecar/sdk-message-frames.mjs' + +// The live tail's landing rules against what the pinned real CLI produced +// (test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync): the SDK +// messages it emitted, mapped by the real sidecar mapper, against the +// snapshot the server projects from the transcript it wrote. + +const SCENARIO = path.resolve(__dirname, '../../../fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync') +const locator = { sessionId: 'real-cli-tools-sync', sessionType: 'freshclaude' as const, provider: 'claude' as const } +const KEY = 'freshclaude:claude:real-cli-tools-sync' + +type Msg = Record + +function recorded(): Msg[] { + return fs.readFileSync(path.join(SCENARIO, 'sdk.jsonl'), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line).msg as Msg) +} + +function goldenSnapshot(): FreshAgentSnapshot { + return FreshAgentSnapshotSchema.parse(JSON.parse(fs.readFileSync(path.join(SCENARIO, 'snapshot.golden.json'), 'utf8'))) +} + +/** The server's rename of the sidecar frames the live tail reads (sdk.* → freshAgent.*). */ +const RENAMED: Record = { + 'sdk.stream': 'freshAgent.stream', + 'sdk.assistant': 'freshAgent.assistant', + 'sdk.session.changed': 'freshAgent.session.changed', +} + +/** Replays the recorded turn into a store through the real sidecar mapping and websocket handler. */ +function replay({ dropAssistantFrames }: { dropAssistantFrames: boolean }) { + const store = configureStore({ reducer: { freshAgent: freshAgentReducer, freshAgentLive: freshAgentLiveReducer } }) + const session = { stream: createFrameState(), permissionMode: 'bypassPermissions', turnPending: true } + for (const msg of recorded()) { + for (const frame of framesForSdkMessage(locator.sessionId, msg, session) as Msg[]) { + const type = RENAMED[frame.type] + if (!type || (dropAssistantFrames && type === 'freshAgent.assistant')) continue + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, event: { ...frame, type } }) + } + } + streamBatcherFor(store.dispatch).flush() + return store +} + +describe('live tail landing against the real CLI (transcript-live D9)', () => { + it('lists each API message\'s lines in block order, as the CLI wrote them', () => { + const landed = landedTranscriptOf(goldenSnapshot()) + expect(landed.messageBlockKinds.msg_fixture0001).toEqual(['thinking', 'text', 'tool_use']) + // A subagent's tool line is projected as its delegation and still counts as the tool block. + expect(landed.messageBlockKinds.msg_fixture0006).toEqual(['tool_use']) + }) + + it('streams one block per content block of the main thread and binds each to the line the CLI wrote', () => { + const store = replay({ dropAssistantFrames: false }) + const blocks = store.getState().freshAgentLive.bySession[KEY].blocks + expect(blocks.map((block) => `${block.messageId}:${block.index}:${block.kind}`)).toEqual([ + 'msg_fixture0001:0:thinking', 'msg_fixture0001:1:text', 'msg_fixture0001:2:tool_use', + 'msg_fixture0002:0:tool_use', 'msg_fixture0003:0:tool_use', 'msg_fixture0004:0:tool_use', + 'msg_fixture0005:0:tool_use', 'msg_fixture0006:0:tool_use', 'msg_fixture0009:0:text', + ]) + const snapshotTurnIds = new Set(goldenSnapshot().turns.map((turn) => turn.turnId)) + expect(blocks.every((block) => block.uuid !== undefined && snapshotTurnIds.has(block.uuid))).toBe(true) + }) + + it.each([ + ['by their line uuids', false], + ['by message position when every assistant frame was lost', true], + ])('lands every streamed block once the snapshot holds the turn (%s)', (_label, dropAssistantFrames) => { + const store = replay({ dropAssistantFrames }) + expect(store.getState().freshAgentLive.bySession[KEY].blocks).toHaveLength(9) + const landed = landedTranscriptOf(goldenSnapshot()) + store.dispatch(transcriptLanded({ sessionKey: KEY, present: [...landed.lineIds], messageBlockKinds: landed.messageBlockKinds })) + const tail = store.getState().freshAgentLive.bySession[KEY] + expect(tail.blocks).toEqual([]) + expect(tail.expectedTurnIds).toEqual([]) + }) +}) From a3e60164a6b7b9ec97e7e2328fbe04e95ba77650 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:04:30 -0700 Subject: [PATCH 28/45] perf(fresh-agent): a background pane does not render its live tail A pane in a background tab kept re-rendering its tail's markdown ten times a second while its agent streamed. The tail now mounts only while the pane is visible; its state stays in the store, so revealing the pane shows the tail as it stands (after the usual hidden-pane refresh). Co-Authored-By: Claude Opus 5.5 --- src/components/fresh-agent/FreshAgentView.tsx | 4 ++- .../FreshAgentView.live-tail.test.tsx | 31 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 78c381475..2c8384d2f 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -4799,7 +4799,9 @@ export function FreshAgentView({ showTimecodes={effectiveShowTimecodes} showTranscriptMinimap={showTranscriptMinimap} isStreaming={isBusy && !liveTailActive} - tail={liveSessionKey ? ( + // A background pane keeps its tail in the store but renders + // none of it: no markdown re-parse per token while unseen. + tail={liveSessionKey && !hidden ? ( turn.turnId)) { +function claudeSnapshot(turns: Turn[], lineUuids: string[] = turns.map((turn) => turn.turnId), revision = 1) { return { sessionType: 'freshclaude', provider: 'claude', threadId: SESSION_ID, sessionId: SESSION_ID, - revision: 1, + revision, latestTurnId: turns.at(-1)?.turnId ?? null, status: 'idle', summary: '', @@ -136,13 +136,13 @@ function createStore() { type Store = ReturnType -function StoreBackedView() { +function StoreBackedView({ hidden = false }: { hidden?: boolean }) { const content = useAppSelector((state) => { const layout = state.panes.layouts['tab-1'] if (!layout || layout.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('missing pane') return layout.content }) - return + return
+ +
+ + Freshclaude + +
@@ -979,6 +1002,69 @@

Task Board

+ +
+
+
+
~/code/freshell
+
+
+
Diffs
src/store/sessionsSlice.ts+3 −3
src/store/store.ts+1 −1
+
+
You
Rename sessionsSlice to sessionStore and update everything that uses it.
+
+
Freshclaude
+
+
✓Editsrc/store/sessionsSlice.ts
+
✓General-purpose Task — find other uses1.4s
+
GrepsessionsSlice
+
Readsrc/store/store.ts
+
✓Editsrc/store/store.ts
+
+
+
+
Renamed the slice and its two imports. Running the store tests next, so nothing still points at the old name
+
+
+
+
+
Agent is working — sends queue for the next turn
+
+
+
+
+
+
+
+
~/code/freshell-web
+
+
+
+
You
Summarize the open pull request.
+
+
Freshclaude
+
+
✓Bashgh pr view 812
+
Retrying (attempt 2) — overloaded (529) · retrying in 2s
+
+
+
+
+
+
Send a message — / commands, @ files, ! shell
+
+
+
+
+
+
+
+