Surface Claude live output/progress during the asking-Claude status: notification activity line + token count, a Cowriting: Claude OutputChannel streaming assistant text, and a cancellable turn. Pure turnProgress reducer + runEditTurn onProgress/AbortSignal (vscode-free) + both call sites. INV-43..47. Fixes #60
This commit was merged in pull request #61.
This commit is contained in:
@@ -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<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");
|
||||
});
|
||||
});
|
||||
+59
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user