a3ea6c65ca
Review findings (Medium+Low): a signal already aborted before runEditTurn is called could not be honored by agent.abort() (the SDK AbortController isn't created until run()), so the turn ran to completion — now it throws before run() (INV-47). And a throwing onProgress relay is swallowed so observability can never fail the turn (INV-44). Strengthened the unit + E2E to prove both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
3.9 KiB
TypeScript
84 lines
3.9 KiB
TypeScript
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<CowritingApi> {
|
|
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<vscode.TextDocument> {
|
|
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");
|
|
});
|
|
});
|