From 5d9f7ddaaf2fd1bd7d2fc619176d95a0ed475864 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 04:31:29 -0700 Subject: [PATCH] feat(#60): pure turn-progress reducer (INV-46) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/turnProgress.ts | 122 ++++++++++++++++++++++++++++++++++++++ test/turnProgress.test.ts | 102 +++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/turnProgress.ts create mode 100644 test/turnProgress.test.ts diff --git a/src/turnProgress.ts b/src/turnProgress.ts new file mode 100644 index 0000000..b00cc81 --- /dev/null +++ b/src/turnProgress.ts @@ -0,0 +1,122 @@ +/** + * turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a + * small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2). + * + * INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime + * dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at + * compile and never pulls the ESM SDK into the bundle). All event→state logic + * lives here so it is unit-tested in isolation; the UI call sites only format and + * relay snapshots. + */ +import type { AgentRuntimeEvent } from "@cline/shared"; + +export type TurnPhase = "thinking" | "writing" | "tool"; + +export interface TurnProgressSnapshot { + phase: TurnPhase; + /** present iff phase === "tool" — the running tool's name. */ + tool?: string; + /** accumulated assistant-text length so far. */ + chars: number; + /** running total tokens (input+output); undefined until the first usage event. */ + tokens?: number; + /** the new assistant-text chunk since the last snapshot (for the OutputChannel). */ + textDelta?: string; +} + +export interface TurnProgressState { + phase: TurnPhase; + chars: number; + tokens?: number; + /** stack of tool names currently running (depth-tracked for overlap). */ + activeTools: string[]; + /** true once any assistant text has streamed (tool-finish then reverts to writing). */ + sawText: boolean; +} + +export function createTurnProgressState(): TurnProgressState { + return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false }; +} + +function restingPhase(state: TurnProgressState): TurnPhase { + if (state.activeTools.length) return "tool"; + return state.sawText ? "writing" : "thinking"; +} + +function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot { + return { + phase: state.phase, + tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined, + chars: state.chars, + tokens: state.tokens, + textDelta, + }; +} + +/** + * Fold one SDK event into the state, returning the next state and the snapshot to + * emit (snapshot undefined for events that don't change the surface). + */ +export function reduceTurnProgress( + state: TurnProgressState, + event: AgentRuntimeEvent, +): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } { + switch (event.type) { + case "run-started": + case "turn-started": { + const next: TurnProgressState = { ...state, phase: restingPhase(state) }; + return { state: next, snapshot: toSnapshot(next) }; + } + case "assistant-text-delta": { + const next: TurnProgressState = { + ...state, + phase: state.activeTools.length ? "tool" : "writing", + chars: event.accumulatedText.length, + sawText: true, + }; + return { state: next, snapshot: toSnapshot(next, event.text) }; + } + case "assistant-reasoning-delta": { + // Reasoning TEXT is not surfaced (operator fork); collapse to motion only. + const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" }; + return { state: next, snapshot: toSnapshot(next) }; + } + case "tool-started": { + const activeTools = [...state.activeTools, event.toolCall.toolName]; + const next: TurnProgressState = { ...state, phase: "tool", activeTools }; + return { state: next, snapshot: toSnapshot(next) }; + } + case "tool-updated": { + const next: TurnProgressState = { ...state, phase: "tool" }; + return { state: next, snapshot: toSnapshot(next) }; + } + case "tool-finished": { + const name = event.toolCall.toolName; + const idx = state.activeTools.lastIndexOf(name); + const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1); + const next: TurnProgressState = { ...state, activeTools, phase: "thinking" }; + next.phase = restingPhase(next); + return { state: next, snapshot: toSnapshot(next) }; + } + case "usage-updated": { + const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined; + const next: TurnProgressState = { ...state, tokens }; + return { state: next, snapshot: toSnapshot(next) }; + } + default: + return { state }; + } +} + +/** Render the notification activity line from a snapshot (pure; spec §2.1). */ +export function formatProgressLine(s: TurnProgressSnapshot): string { + let head: string; + if (s.phase === "tool") head = `running ${s.tool ?? "tool"}…`; + else if (s.phase === "writing") head = `writing… (${s.chars} chars)`; + else head = "thinking…"; + return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head; +} + +export function formatTokens(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} diff --git a/test/turnProgress.test.ts b/test/turnProgress.test.ts new file mode 100644 index 0000000..98b9e82 --- /dev/null +++ b/test/turnProgress.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + createTurnProgressState, + reduceTurnProgress, + formatProgressLine, + formatTokens, + type TurnProgressSnapshot, +} from "../src/turnProgress"; + +// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent +// members the reducer reads. `as any` because we only supply the fields used. +const ev = (e: any) => e as any; +const textDelta = (text: string, accumulatedText: string) => + ev({ type: "assistant-text-delta", text, accumulatedText }); +const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } }); +const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } }); +const usage = (inputTokens: number, outputTokens: number) => + ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } }); + +// Drive a sequence of events, returning every emitted snapshot. +function run(events: any[]): TurnProgressSnapshot[] { + let state = createTurnProgressState(); + const out: TurnProgressSnapshot[] = []; + for (const e of events) { + const r = reduceTurnProgress(state, e); + state = r.state; + if (r.snapshot) out.push(r.snapshot); + } + return out; +} + +describe("reduceTurnProgress", () => { + it("starts in thinking", () => { + const s = run([ev({ type: "run-started" })]); + expect(s.at(-1)!.phase).toBe("thinking"); + expect(s.at(-1)!.chars).toBe(0); + expect(s.at(-1)!.tokens).toBeUndefined(); + }); + + it("text deltas move to writing and accumulate chars + carry the delta", () => { + const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]); + expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]); + expect(s.at(-1)!.chars).toBe(5); + expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]); + }); + + it("usage sets a running token total (input+output)", () => { + const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]); + expect(s.at(-1)!.tokens).toBe(1234); + }); + + it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => { + const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]); + expect(s[1].phase).toBe("tool"); + expect(s[1].tool).toBe("read_file"); + expect(s.at(-1)!.phase).toBe("writing"); + }); + + it("tool finish reverts to thinking when no text was seen", () => { + const s = run([toolStarted("grep"), toolFinished("grep")]); + expect(s.at(-1)!.phase).toBe("thinking"); + }); + + it("overlapping tools resolve in order", () => { + const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]); + expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]); + expect(s[1].tool).toBe("b"); + expect(s[2].tool).toBe("a"); + }); + + it("reasoning deltas stay thinking and surface no text", () => { + const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]); + expect(s.at(-1)!.phase).toBe("thinking"); + expect(s.at(-1)!.textDelta).toBeUndefined(); + }); + + it("ignores lifecycle/finish events (no snapshot)", () => { + const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]); + expect(s).toEqual([]); + }); +}); + +describe("formatProgressLine / formatTokens", () => { + it("thinking", () => { + expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…"); + }); + it("writing with chars", () => { + expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)"); + }); + it("writing with chars + tokens", () => { + expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe( + "writing… (412 chars) · 1.2k tokens", + ); + }); + it("tool with name", () => { + expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…"); + }); + it("formats token magnitudes", () => { + expect(formatTokens(950)).toBe("950"); + expect(formatTokens(1234)).toBe("1.2k"); + }); +});