diff --git a/package.json b/package.json index dbd4c74..354d627 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,16 @@ "onStartupFinished" ], "contributes": { + "configuration": { + "title": "Cowriting", + "properties": { + "cowriting.liveProgress.revealOutput": { + "type": "boolean", + "default": true, + "description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams." + } + } + }, "commands": [ { "command": "cowriting.showClineSdkInfo", diff --git a/scripts/smoke-live-turn.mjs b/scripts/smoke-live-turn.mjs index 1a5f17d..9a64d02 100644 --- a/scripts/smoke-live-turn.mjs +++ b/scripts/smoke-live-turn.mjs @@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`); console.log(`text: ${text}`); try { const t0 = Date.now(); - const result = await runEditTurn(instruction, text); + // #60: exercise the live-progress path against the real SDK — log each reduced + // snapshot so the smoke shows streaming/activity/tokens, not just the result. + const result = await runEditTurn(instruction, text, { + onProgress: (s) => { + const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`]; + if (s.tokens) bits.push(`${s.tokens}tok`); + console.log(` progress: ${bits.join(" ")}`); + }, + }); console.log(`replacement: ${JSON.stringify(result.replacement)}`); console.log(`model: ${result.model}`); console.log(`sessionId: ${result.sessionId}`); diff --git a/src/extension.ts b/src/extension.ts index 1db0f5f..12b1006 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { LiveProgressUi } from "./liveProgressUi"; import { isAuthorable, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -23,12 +24,18 @@ export interface CowritingApi { diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; sidecarRouter: SidecarRouter; + liveProgressUi: LiveProgressUi; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { // --- POC command (Feature #2), unchanged --- const output = vscode.window.createOutputChannel(CHANNEL_NAME); context.subscriptions.push(output); + + // #60: shared live-progress UI (notification activity line + "Cowriting: Claude" + // OutputChannel) for both Ask-Claude entry points. + const liveProgressUi = new LiveProgressUi(); + context.subscriptions.push(liveProgressUi); context.subscriptions.push( vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { try { @@ -104,6 +111,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.extensionUri, attributionController, proposalController, + liveProgressUi, ); context.subscriptions.push(trackChangesPreviewController); @@ -230,10 +238,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const turnId = `turn-${Date.now().toString(36)}`; try { await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, - async () => { + { + location: vscode.ProgressLocation.Notification, + title: "Cowriting: asking Claude…", + cancellable: true, + }, + async (progress, token) => { const { runEditTurn } = await import("./liveTurn"); - const turn = await runEditTurn(instruction, selectedText); + const ui = liveProgressUi.begin(instruction, progress, token); + let turn; + try { + turn = await runEditTurn(instruction, selectedText, { + onProgress: ui.onProgress, + signal: ui.signal, + }); + } catch (err) { + // #60 (INV-47): a user cancel surfaces as "cancelled", not a failure. + if (token.isCancellationRequested) { + void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled."); + return; + } + throw err; + } if (turn.replacement === "") { void vscode.window.showWarningMessage( "Cowriting: Claude returned an empty replacement — nothing was proposed.", @@ -292,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef diffViewController, trackChangesPreviewController, sidecarRouter, + liveProgressUi, }; } diff --git a/src/liveProgressUi.ts b/src/liveProgressUi.ts new file mode 100644 index 0000000..d693c41 --- /dev/null +++ b/src/liveProgressUi.ts @@ -0,0 +1,65 @@ +/** + * liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI + * (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the + * existing withProgress notification (an activity line) and a dedicated + * OutputChannel streaming the full assistant text — no new network/webview + * surface (INV-45). vscode-only; all pure logic lives in turnProgress.ts. + */ +import * as vscode from "vscode"; +import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress"; + +const CHANNEL_NAME = "Cowriting: Claude"; + +export interface TurnUi { + /** Pass to runEditTurn's opts.onProgress. */ + onProgress: (snapshot: TurnProgressSnapshot) => void; + /** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */ + signal: AbortSignal; +} + +export class LiveProgressUi { + readonly channel: vscode.OutputChannel; + + constructor() { + this.channel = vscode.window.createOutputChannel(CHANNEL_NAME); + } + + /** + * Begin one turn's UI. Writes the per-turn header to the channel and returns + * the onProgress relay + an AbortSignal linked to the notification's cancel + * token. The channel APPENDS (it doubles as a debug log of recent turns, spec + * §3.5); it auto-reveals (without stealing focus) on the first streamed text, + * gated by `cowriting.liveProgress.revealOutput`. + */ + begin( + instruction: string, + progress: vscode.Progress<{ message?: string }>, + token: vscode.CancellationToken, + ): TurnUi { + const controller = new AbortController(); + token.onCancellationRequested(() => controller.abort()); + this.channel.appendLine(`── asking: ${instruction} ──`); + + let revealed = false; + const reveal = (): void => { + if (revealed) return; + revealed = true; + const cfg = vscode.workspace.getConfiguration("cowriting"); + if (cfg.get("liveProgress.revealOutput", true)) this.channel.show(true); + }; + + const onProgress = (s: TurnProgressSnapshot): void => { + progress.report({ message: formatProgressLine(s) }); + if (s.textDelta) { + this.channel.append(s.textDelta); + reveal(); + } + }; + + return { onProgress, signal: controller.signal }; + } + + dispose(): void { + this.channel.dispose(); + } +} diff --git a/src/liveTurn.ts b/src/liveTurn.ts index 9a4d7ed..3edac9e 100644 --- a/src/liveTurn.ts +++ b/src/liveTurn.ts @@ -9,6 +9,9 @@ * never bundled (esbuild keeps it external). */ +import type { TurnProgressSnapshot } from "./turnProgress"; +import { createTurnProgressState, reduceTurnProgress } from "./turnProgress"; + export interface EditTurnResult { replacement: string; model: string; @@ -16,6 +19,19 @@ export interface EditTurnResult { sessionId: string; } +/** + * Options for runEditTurn. Both new fields are purely additive observability / + * control over the existing turn (INV-44): `onProgress` streams reduced progress + * snapshots out; `signal` cancels the turn in. Neither touches the result path, + * and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web + * standard, the progress snapshot is a domain type). + */ +export interface RunEditTurnOptions { + modelId?: string; + onProgress?: (snapshot: TurnProgressSnapshot) => void; + signal?: AbortSignal; +} + const SYSTEM_PROMPT = [ "You are a precise text editor embedded in VS Code.", "You will be given a piece of text and an instruction.", @@ -40,7 +56,7 @@ export function extractReplacement(outputText: string, selectedText: string): st export async function runEditTurn( instruction: string, selectedText: string, - opts?: { modelId?: string }, + opts?: RunEditTurnOptions, ): Promise { const sdk = await import("@cline/sdk"); const modelId = opts?.modelId ?? "sonnet"; @@ -49,16 +65,50 @@ export async function runEditTurn( modelId, systemPrompt: SYSTEM_PROMPT, }); - const result = await agent.run( - `\n${instruction}\n\n\n${selectedText}\n`, - ); - // The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed" - // (@cline/shared agent.d.ts) — "completed" is the success status. - if (result.status !== "completed") { - throw new Error( - `claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` + - "(is Claude Code installed and signed in?)", + + // Stream reduced progress snapshots out (INV-44 additive) and wire cancellation + // in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn. + let state = createTurnProgressState(); + const unsubscribe = opts?.onProgress + ? agent.subscribe((event) => { + const next = reduceTurnProgress(state, event); + state = next.state; + // Observability must never affect the result (INV-44): a throwing relay + // is swallowed, not allowed to propagate into the SDK and fail the turn. + if (next.snapshot) { + try { + opts.onProgress!(next.snapshot); + } catch { + /* progress is best-effort */ + } + } + }) + : undefined; + const onAbort = () => agent.abort(); + opts?.signal?.addEventListener("abort", onAbort); + + try { + // A signal already aborted before the turn starts can't be honored by + // agent.abort() (the SDK's AbortController isn't created until run()), so + // short-circuit to the same aborted outcome the call site reflects (INV-47). + if (opts?.signal?.aborted) { + throw new Error("claude-code turn aborted: cancelled before start"); + } + const result = await agent.run( + `\n${instruction}\n\n\n${selectedText}\n`, ); + // The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed" + // (@cline/shared agent.d.ts) — "completed" is the success status. An aborted + // turn (user cancel) falls into this throw; the call site reflects "cancelled". + if (result.status !== "completed") { + throw new Error( + `claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` + + "(is Claude Code installed and signed in?)", + ); + } + return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId }; + } finally { + unsubscribe?.(); + opts?.signal?.removeEventListener("abort", onAbort); } - return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId }; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index f7c7f38..037cf91 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -16,10 +16,15 @@ import type { ProposalController } from "./proposalController"; import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import { isAuthorable } from "./workspacePath"; -import type { EditTurnResult } from "./liveTurn"; +import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; +import type { LiveProgressUi } from "./liveProgressUi"; -/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */ -type EditTurn = (instruction: string, text: string) => Promise; +/** + * F11: a host edit turn (selection/document text + instruction → rewrite). + * Injectable for tests. #60: accepts optional turn options (onProgress/signal); + * the arg is optional so existing test stubs that ignore it stay valid. + */ +type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise; /** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */ type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" }; @@ -53,9 +58,9 @@ export class TrackChangesPreviewController implements vscode.Disposable { * F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and * never bundled). Injectable so host E2E can stub it (no LLM in CI). */ - private editTurn: EditTurn = async (instruction, text) => { + private editTurn: EditTurn = async (instruction, text, opts) => { const { runEditTurn } = await import("./liveTurn"); - return runEditTurn(instruction, text); + return runEditTurn(instruction, text, opts); }; /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; @@ -68,6 +73,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, + private readonly liveProgressUi: LiveProgressUi, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; @@ -230,8 +236,24 @@ export class TrackChangesPreviewController implements vscode.Disposable { if (!instruction) return; try { const ids = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, - () => this.runEditAndPropose(document, target, instruction), + { + location: vscode.ProgressLocation.Notification, + title: "Cowriting: asking Claude…", + cancellable: true, + }, + async (progress, token) => { + const ui = this.liveProgressUi.begin(instruction, progress, token); + try { + return await this.runEditAndPropose(document, target, instruction, { + onProgress: ui.onProgress, + signal: ui.signal, + }); + } catch (err) { + // #60 (INV-47): a user cancel proposes nothing (the benign empty path). + if (token.isCancellationRequested) return [] as string[]; + throw err; + } + }, ); if (ids.length === 0) { void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes."); @@ -258,6 +280,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { document: vscode.TextDocument, target: EditTarget, instruction: string, + opts?: RunEditTurnOptions, ): Promise { const full = document.getText(); // One turnId per gesture — the document case's N hunk-proposals all share it, @@ -267,13 +290,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { ({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } }); if (target.kind === "range") { const selected = full.slice(target.start, target.end); - const turn = await this.editTurn(instruction, selected); + const turn = await this.editTurn(instruction, selected, opts); if (turn.replacement === "" || turn.replacement === selected) return []; const fp = buildFingerprint(full, { start: target.start, end: target.end }); const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction }); return id ? [id] : []; } - const turn = await this.editTurn(instruction, full); + const turn = await this.editTurn(instruction, full, opts); const ids: string[] = []; // #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK // granularity — one proposal per changed block (the unit a human reviews) — 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/e2e/suite/liveProgress.test.ts b/test/e2e/suite/liveProgress.test.ts new file mode 100644 index 0000000..a10bbe7 --- /dev/null +++ b/test/e2e/suite/liveProgress.test.ts @@ -0,0 +1,83 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController, "exports preview controller"); + return api; +} + +async function freshDoc(rel: string, body: string): Promise { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); + await vscode.window.showTextDocument(doc); + await settle(); + return doc; +} + +// #60 host E2E (no LLM): live progress is purely additive observability. The +// harness can't read notification subtitles, so it asserts the CONTRACT — +// progress events don't change the proposals (INV-44), and an aborted turn +// proposes nothing (INV-47). The live notification/OutputChannel UI itself is +// covered by the turnProgress unit tests + the manual smoke. +suite("#60 live turn progress (additive + cancel)", () => { + test("a stub that emits progress still produces the same proposals (INV-44)", async () => { + const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + // The stub honors opts.onProgress (emitting synthetic snapshots) but returns + // the same rewrite — proposals must be unaffected by progress events. + ctl.setEditTurnForTest(async (_i, _text, opts) => { + opts?.onProgress?.({ phase: "writing", chars: 5 }); + opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." }); + return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" }; + }); + + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); + await settle(); + assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); + const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]); + assert.ok(view, "the proposal is live"); + assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)"); + }); + + test("an aborted turn proposes nothing (INV-47)", async () => { + const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + // The stub throws ONLY when the aborted signal reached it — so if opts.signal + // failed to thread through runEditAndPropose, the stub would instead return a + // rewrite and create a proposal, failing this test. That proves propagation. + ctl.setEditTurnForTest(async (_i, _text, opts) => { + if (opts?.signal?.aborted) throw new Error("claude-code turn aborted"); + return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" }; + }); + + const ac = new AbortController(); + ac.abort(); + let ids: string[] = []; + try { + ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); + } catch { + ids = []; + } + await settle(); + assert.strictEqual(ids.length, 0, "aborted turn must create no proposals"); + assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort"); + }); +}); diff --git a/test/liveTurn.test.ts b/test/liveTurn.test.ts index da8596e..32f022d 100644 --- a/test/liveTurn.test.ts +++ b/test/liveTurn.test.ts @@ -1,5 +1,32 @@ -import { describe, it, expect } from "vitest"; -import { extractReplacement } from "../src/liveTurn"; +import { describe, it, expect, vi } from "vitest"; +import { extractReplacement, runEditTurn } from "../src/liveTurn"; + +// A fake Agent that replays a scripted event list to subscribers, supports abort, +// and resolves agent.run() with a completed (or aborted) result. +const runs = { count: 0 }; +function fakeSdk(events: any[]) { + return { + Agent: class { + private listeners: ((e: any) => void)[] = []; + constructor(_cfg: unknown) {} + subscribe(fn: (e: any) => void) { + this.listeners.push(fn); + return () => { + this.listeners = this.listeners.filter((l) => l !== fn); + }; + } + // The real SDK's abort() is a no-op before run() creates its AbortController, + // so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own + // short-circuit, not the fake's leniency. + abort() {} + async run(_input: string) { + runs.count += 1; + for (const e of events) for (const l of this.listeners) l(e); + return { status: "completed", outputText: "EDITED", runId: "r1" }; + } + }, + }; +} describe("extractReplacement", () => { it("returns plain text untouched", () => { @@ -24,3 +51,33 @@ describe("extractReplacement", () => { expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain"); }); }); + +describe("runEditTurn progress + cancel", () => { + it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => { + vi.resetModules(); + vi.doMock("@cline/sdk", () => + fakeSdk([ + { type: "assistant-text-delta", text: "ED", accumulatedText: "ED" }, + { type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + ]), + ); + const seen: string[] = []; + const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) }); + expect(turn.replacement).toBe("EDITED"); + expect(seen).toContain("writing"); + vi.doUnmock("@cline/sdk"); + vi.resetModules(); + }); + + it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => { + vi.resetModules(); + vi.doMock("@cline/sdk", () => fakeSdk([])); + runs.count = 0; + const ac = new AbortController(); + ac.abort(); + await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/); + expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced + vi.doUnmock("@cline/sdk"); + vi.resetModules(); + }); +}); 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"); + }); +});