#60: live turn progress (activity line + token count + OutputChannel stream + cancel) #61
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user