feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 04:33:40 -07:00
parent 5d9f7ddaaf
commit d2405a2ca8
2 changed files with 108 additions and 13 deletions
+50 -11
View File
@@ -9,6 +9,9 @@
* never bundled (esbuild keeps it external). * never bundled (esbuild keeps it external).
*/ */
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface EditTurnResult { export interface EditTurnResult {
replacement: string; replacement: string;
model: string; model: string;
@@ -16,6 +19,19 @@ export interface EditTurnResult {
sessionId: string; 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 = [ const SYSTEM_PROMPT = [
"You are a precise text editor embedded in VS Code.", "You are a precise text editor embedded in VS Code.",
"You will be given a piece of text and an instruction.", "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( export async function runEditTurn(
instruction: string, instruction: string,
selectedText: string, selectedText: string,
opts?: { modelId?: string }, opts?: RunEditTurnOptions,
): Promise<EditTurnResult> { ): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk"); const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet"; const modelId = opts?.modelId ?? "sonnet";
@@ -49,16 +65,39 @@ export async function runEditTurn(
modelId, modelId,
systemPrompt: SYSTEM_PROMPT, systemPrompt: SYSTEM_PROMPT,
}); });
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`, // Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
); // in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed" let state = createTurnProgressState();
// (@cline/shared agent.d.ts) — "completed" is the success status. const unsubscribe = opts?.onProgress
if (result.status !== "completed") { ? agent.subscribe((event) => {
throw new Error( const next = reduceTurnProgress(state, event);
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` + state = next.state;
"(is Claude Code installed and signed in?)", 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(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
); );
// 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 };
} }
+58 -2
View File
@@ -1,5 +1,33 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { extractReplacement } from "../src/liveTurn"; 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", () => { describe("extractReplacement", () => {
it("returns plain text untouched", () => { it("returns plain text untouched", () => {
@@ -24,3 +52,31 @@ describe("extractReplacement", () => {
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain"); 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();
});
});