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:
+50
-11
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user