From d2405a2ca8ff469a87dec826b4c26366f2dee77e Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 04:33:40 -0700 Subject: [PATCH] feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/liveTurn.ts | 61 +++++++++++++++++++++++++++++++++++-------- test/liveTurn.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/liveTurn.ts b/src/liveTurn.ts index 9a4d7ed..0965cae 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,39 @@ 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; + if (next.snapshot) opts.onProgress!(next.snapshot); + }) + : undefined; + const onAbort = () => agent.abort(); + opts?.signal?.addEventListener("abort", onAbort); + // Handle a signal that was already aborted before the turn started (the + // "abort" event won't re-fire for an already-aborted signal). + if (opts?.signal?.aborted) onAbort(); + + try { + 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/test/liveTurn.test.ts b/test/liveTurn.test.ts index da8596e..805b2ed 100644 --- a/test/liveTurn.test.ts +++ b/test/liveTurn.test.ts @@ -1,5 +1,33 @@ -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. +function fakeSdk(events: any[], opts?: { abortAware?: boolean }) { + return { + Agent: class { + private listeners: ((e: any) => void)[] = []; + private aborted = false; + constructor(_cfg: unknown) {} + subscribe(fn: (e: any) => void) { + this.listeners.push(fn); + return () => { + this.listeners = this.listeners.filter((l) => l !== fn); + }; + } + abort() { + this.aborted = true; + } + async run(_input: string) { + for (const e of events) for (const l of this.listeners) l(e); + if (opts?.abortAware && this.aborted) { + return { status: "aborted", outputText: "", runId: "r1", error: { message: "aborted" } }; + } + return { status: "completed", outputText: "EDITED", runId: "r1" }; + } + }, + }; +} describe("extractReplacement", () => { it("returns plain text untouched", () => { @@ -24,3 +52,31 @@ 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 fired AbortSignal aborts the agent and the turn throws (INV-47)", async () => { + vi.resetModules(); + vi.doMock("@cline/sdk", () => fakeSdk([], { abortAware: true })); + const ac = new AbortController(); + ac.abort(); + await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/); + vi.doUnmock("@cline/sdk"); + vi.resetModules(); + }); +});