add plan ./plans/2026-06-26-live-turn-progress.md
This commit is contained in:
@@ -0,0 +1,910 @@
|
|||||||
|
# Live Turn Progress (#60) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Surface Claude's live output/progress (activity + token count + streamed text + cancel) during the otherwise-opaque "asking Claude…" status, for both edit entry points.
|
||||||
|
|
||||||
|
**Architecture:** A pure reducer (`turnProgress.ts`) folds `@cline/sdk` Agent runtime events into a small `TurnProgressSnapshot`; `runEditTurn` subscribes to the agent and emits snapshots via an `onProgress` callback while accepting an `AbortSignal` for cancellation — staying `vscode`-free. The two call sites relay snapshots through a shared host `liveProgressUi` module: the existing `withProgress` notification shows a live activity line, a dedicated OutputChannel streams the full assistant text, and the notification becomes cancellable. The final result path (proposals) is untouched.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript (strict, `moduleResolution: Bundler`), VS Code extension API, `@cline/sdk` (ESM, dynamic-import-only, never bundled), Vitest (unit), `@vscode/test-electron` (host E2E), esbuild.
|
||||||
|
|
||||||
|
**Spec:** `coauthoring-live-progress.md` (graduated, content repo). Invariants INV-43..47.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create `src/turnProgress.ts`** — pure reducer + line formatter. No `vscode`, no SDK runtime import (`AgentRuntimeEvent` type-only). Owns ALL event→state logic (INV-46).
|
||||||
|
- **Create `test/turnProgress.test.ts`** — unit tests for the reducer + `formatProgressLine`.
|
||||||
|
- **Modify `src/liveTurn.ts`** — `runEditTurn` gains `RunEditTurnOptions { modelId?, onProgress?, signal? }`; subscribes to the agent, folds events through the reducer, wires `signal`→`agent.abort()`. Result shape unchanged (INV-44). Stays `vscode`-free (INV-43).
|
||||||
|
- **Create `src/liveProgressUi.ts`** — host module (vscode-only): owns the shared `"Cowriting: Claude"` OutputChannel and a `begin(instruction, progress, token)` helper returning `{ signal, onProgress }` for one `withProgress` run. Reads `cowriting.liveProgress.revealOutput`.
|
||||||
|
- **Modify `src/extension.ts`** — construct `liveProgressUi` once at activate; the `editSelection` command uses it (cancellable notification + onProgress + signal); pass it into the preview controller; expose it on `CowritingApi` for E2E.
|
||||||
|
- **Modify `src/trackChangesPreview.ts`** — widen the `EditTurn` seam to accept `opts?`; thread `opts` through `askClaude` → `runEditAndPropose` → `editTurn`; use the injected `liveProgressUi` in `askClaude`.
|
||||||
|
- **Modify `package.json`** — add `contributes.configuration` with `cowriting.liveProgress.revealOutput` (boolean, default `true`).
|
||||||
|
- **Modify `test/e2e/suite/f11Toolbar.test.ts`** (or a new `test/e2e/suite/liveProgress.test.ts`) — extend the preview `editTurn` stub to emit synthetic progress and assert (a) identical proposals (INV-44) and (b) cancel→zero proposals (INV-47).
|
||||||
|
|
||||||
|
**Build note:** the type-only `import type { AgentRuntimeEvent } from "@cline/sdk"` is erased at compile and `@cline/*` stays external in esbuild — no bundling of the ESM SDK (preserves the POC constraint).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure progress reducer (`turnProgress.ts`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/turnProgress.ts`
|
||||||
|
- Test: `test/turnProgress.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `test/turnProgress.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run test/turnProgress.test.ts`
|
||||||
|
Expected: FAIL — `Cannot find module "../src/turnProgress"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the reducer**
|
||||||
|
|
||||||
|
Create `src/turnProgress.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
|
||||||
|
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
|
||||||
|
*
|
||||||
|
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
|
||||||
|
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
|
||||||
|
* compile and never pulls the ESM SDK into the bundle). All event→state logic
|
||||||
|
* lives here so it is unit-tested in isolation; the UI call sites only format and
|
||||||
|
* relay snapshots.
|
||||||
|
*/
|
||||||
|
import type { AgentRuntimeEvent } from "@cline/sdk";
|
||||||
|
|
||||||
|
export type TurnPhase = "thinking" | "writing" | "tool";
|
||||||
|
|
||||||
|
export interface TurnProgressSnapshot {
|
||||||
|
phase: TurnPhase;
|
||||||
|
/** present iff phase === "tool" — the running tool's name. */
|
||||||
|
tool?: string;
|
||||||
|
/** accumulated assistant-text length so far. */
|
||||||
|
chars: number;
|
||||||
|
/** running total tokens (input+output); undefined until the first usage event. */
|
||||||
|
tokens?: number;
|
||||||
|
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
|
||||||
|
textDelta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnProgressState {
|
||||||
|
phase: TurnPhase;
|
||||||
|
chars: number;
|
||||||
|
tokens?: number;
|
||||||
|
/** stack of tool names currently running (depth-tracked for overlap). */
|
||||||
|
activeTools: string[];
|
||||||
|
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
|
||||||
|
sawText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTurnProgressState(): TurnProgressState {
|
||||||
|
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restingPhase(state: TurnProgressState): TurnPhase {
|
||||||
|
if (state.activeTools.length) return "tool";
|
||||||
|
return state.sawText ? "writing" : "thinking";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
|
||||||
|
return {
|
||||||
|
phase: state.phase,
|
||||||
|
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
|
||||||
|
chars: state.chars,
|
||||||
|
tokens: state.tokens,
|
||||||
|
textDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one SDK event into the state, returning the next state and the snapshot to
|
||||||
|
* emit (snapshot undefined for events that don't change the surface).
|
||||||
|
*/
|
||||||
|
export function reduceTurnProgress(
|
||||||
|
state: TurnProgressState,
|
||||||
|
event: AgentRuntimeEvent,
|
||||||
|
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
|
||||||
|
switch (event.type) {
|
||||||
|
case "run-started":
|
||||||
|
case "turn-started": {
|
||||||
|
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
case "assistant-text-delta": {
|
||||||
|
const next: TurnProgressState = {
|
||||||
|
...state,
|
||||||
|
phase: state.activeTools.length ? "tool" : "writing",
|
||||||
|
chars: event.accumulatedText.length,
|
||||||
|
sawText: true,
|
||||||
|
};
|
||||||
|
return { state: next, snapshot: toSnapshot(next, event.text) };
|
||||||
|
}
|
||||||
|
case "assistant-reasoning-delta": {
|
||||||
|
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
|
||||||
|
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
case "tool-started": {
|
||||||
|
const activeTools = [...state.activeTools, event.toolCall.toolName];
|
||||||
|
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
case "tool-updated": {
|
||||||
|
const next: TurnProgressState = { ...state, phase: "tool" };
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
case "tool-finished": {
|
||||||
|
const name = event.toolCall.toolName;
|
||||||
|
const idx = state.activeTools.lastIndexOf(name);
|
||||||
|
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
|
||||||
|
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
|
||||||
|
next.phase = restingPhase(next);
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
case "usage-updated": {
|
||||||
|
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
|
||||||
|
const next: TurnProgressState = { ...state, tokens };
|
||||||
|
return { state: next, snapshot: toSnapshot(next) };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { state };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
|
||||||
|
export function formatProgressLine(s: TurnProgressSnapshot): string {
|
||||||
|
let head: string;
|
||||||
|
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}…`;
|
||||||
|
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
|
||||||
|
else head = "thinking…";
|
||||||
|
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokens(n: number): string {
|
||||||
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run test/turnProgress.test.ts`
|
||||||
|
Expected: PASS (all cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: no errors (confirms the type-only `AgentRuntimeEvent` import resolves under `moduleResolution: Bundler` and the discriminated-union narrowing is sound).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/turnProgress.ts test/turnProgress.test.ts
|
||||||
|
git commit -m "feat(#60): pure turn-progress reducer (INV-46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Extend `runEditTurn` with progress + cancellation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/liveTurn.ts`
|
||||||
|
- Test: `test/liveTurn.test.ts` (add a mocked-agent case)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `test/liveTurn.test.ts` (keep the existing `extractReplacement` block). This mocks `@cline/sdk` via Vitest so `runEditTurn` runs without a real LLM, exercising subscribe + abort wiring:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { runEditTurn } from "../src/liveTurn";
|
||||||
|
|
||||||
|
// A fake Agent that replays a scripted event list to subscribers, supports abort,
|
||||||
|
// and resolves agent.run() with a completed 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("runEditTurn progress + cancel", () => {
|
||||||
|
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fired AbortSignal aborts the agent and the turn throws (INV-47)", async () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: dynamic `import("@cline/sdk")` inside `runEditTurn` picks up `vi.doMock`. If the existing test file lacks `vi` import, the added `import { vi }` covers it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run test/liveTurn.test.ts`
|
||||||
|
Expected: FAIL — `runEditTurn` doesn't accept `onProgress`/`signal` yet (type error) or doesn't subscribe.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the extension**
|
||||||
|
|
||||||
|
In `src/liveTurn.ts`, add the import and options type, and rewrite the body of `runEditTurn` (keep `SYSTEM_PROMPT`, `extractReplacement`, `EditTurnResult` as-is):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { TurnProgressSnapshot } from "./turnProgress";
|
||||||
|
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
|
||||||
|
|
||||||
|
export interface RunEditTurnOptions {
|
||||||
|
modelId?: string;
|
||||||
|
/** out: called with a reduced progress snapshot as the SDK streams (INV-44 additive). */
|
||||||
|
onProgress?: (snapshot: TurnProgressSnapshot) => void;
|
||||||
|
/** in: aborting this signal cancels the turn via agent.abort() (INV-47). */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runEditTurn(
|
||||||
|
instruction: string,
|
||||||
|
selectedText: string,
|
||||||
|
opts?: RunEditTurnOptions,
|
||||||
|
): Promise<EditTurnResult> {
|
||||||
|
const sdk = await import("@cline/sdk");
|
||||||
|
const modelId = opts?.modelId ?? "sonnet";
|
||||||
|
const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt: SYSTEM_PROMPT });
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await agent.run(
|
||||||
|
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`agent.subscribe` returns its unsubscribe fn per `@cline/agents` `AgentRuntime.subscribe(listener): () => void`. `agent.abort()` exists on the same class. `AbortSignal`/`addEventListener` are web standards — no `vscode` import is added, preserving INV-43.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run test/liveTurn.test.ts`
|
||||||
|
Expected: PASS (both new cases + the existing `extractReplacement` cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + full unit run**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npx vitest run`
|
||||||
|
Expected: typecheck clean; all unit tests pass (was 222 + Task 1 + these).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/liveTurn.ts test/liveTurn.test.ts
|
||||||
|
git commit -m "feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Host `liveProgressUi` module + setting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/liveProgressUi.ts`
|
||||||
|
- Modify: `package.json` (add `contributes.configuration`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the configuration to `package.json`**
|
||||||
|
|
||||||
|
In `package.json`, under the top-level `"contributes"` object, add a `configuration` block (there is no existing one). Insert it as a sibling of the existing `commands`/`menus` keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configuration": {
|
||||||
|
"title": "Cowriting",
|
||||||
|
"properties": {
|
||||||
|
"cowriting.liveProgress.revealOutput": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the module**
|
||||||
|
|
||||||
|
Create `src/liveProgressUi.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
|
||||||
|
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
|
||||||
|
* existing withProgress notification (an activity line) and a dedicated
|
||||||
|
* OutputChannel streaming the full assistant text — no new network/webview
|
||||||
|
* surface (INV-45). vscode-only; the pure logic lives in turnProgress.ts.
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
|
||||||
|
|
||||||
|
const CHANNEL_NAME = "Cowriting: Claude";
|
||||||
|
|
||||||
|
export interface TurnUi {
|
||||||
|
/** Pass to runEditTurn's opts.onProgress. */
|
||||||
|
onProgress: (snapshot: TurnProgressSnapshot) => void;
|
||||||
|
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LiveProgressUi {
|
||||||
|
readonly channel: vscode.OutputChannel;
|
||||||
|
constructor() {
|
||||||
|
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin one turn's UI. Writes the per-turn header to the channel, returns the
|
||||||
|
* onProgress relay + an AbortSignal linked to the notification's cancel token.
|
||||||
|
*/
|
||||||
|
begin(
|
||||||
|
instruction: string,
|
||||||
|
progress: vscode.Progress<{ message?: string }>,
|
||||||
|
token: vscode.CancellationToken,
|
||||||
|
): TurnUi {
|
||||||
|
const controller = new AbortController();
|
||||||
|
token.onCancellationRequested(() => controller.abort());
|
||||||
|
this.channel.appendLine(`── asking: ${instruction} ──`);
|
||||||
|
let revealed = false;
|
||||||
|
const reveal = () => {
|
||||||
|
if (revealed) return;
|
||||||
|
revealed = true;
|
||||||
|
const cfg = vscode.workspace.getConfiguration("cowriting");
|
||||||
|
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
|
||||||
|
};
|
||||||
|
const onProgress = (s: TurnProgressSnapshot) => {
|
||||||
|
progress.report({ message: formatProgressLine(s) });
|
||||||
|
if (s.textDelta) {
|
||||||
|
this.channel.append(s.textDelta);
|
||||||
|
reveal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { onProgress, signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.channel.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate package.json**
|
||||||
|
|
||||||
|
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')"`
|
||||||
|
Expected: `ok` (the configuration block is valid JSON).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/liveProgressUi.ts package.json
|
||||||
|
git commit -m "feat(#60): liveProgressUi host relay + revealOutput setting (INV-45)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire the `editSelection` call site
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/extension.ts` (construct LiveProgressUi at activate; use it in the `editSelection` command; add to `CowritingApi`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Construct `LiveProgressUi` once and pass it into the preview controller**
|
||||||
|
|
||||||
|
Near the top of `activate(...)`, after the existing POC `output` channel is created, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const liveProgressUi = new LiveProgressUi();
|
||||||
|
context.subscriptions.push(liveProgressUi);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top of `src/extension.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then thread it into the preview controller construction (Task 5 widens that constructor):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const trackChangesPreviewController = new TrackChangesPreviewController(
|
||||||
|
diffViewController,
|
||||||
|
context.extensionUri,
|
||||||
|
attributionController,
|
||||||
|
proposalController,
|
||||||
|
liveProgressUi,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the `editSelection` `withProgress` block**
|
||||||
|
|
||||||
|
Replace the `editSelection` command's `withProgress(...)` call (currently `src/extension.ts:232-268`) so it is cancellable and relays progress. The turn body is otherwise unchanged:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
await vscode.window.withProgress(
|
||||||
|
{
|
||||||
|
location: vscode.ProgressLocation.Notification,
|
||||||
|
title: "Cowriting: asking Claude…",
|
||||||
|
cancellable: true,
|
||||||
|
},
|
||||||
|
async (progress, token) => {
|
||||||
|
const { runEditTurn } = await import("./liveTurn");
|
||||||
|
const ui = liveProgressUi.begin(instruction, progress, token);
|
||||||
|
const turn = await runEditTurn(instruction, selectedText, {
|
||||||
|
onProgress: ui.onProgress,
|
||||||
|
signal: ui.signal,
|
||||||
|
});
|
||||||
|
if (turn.replacement === "") {
|
||||||
|
void vscode.window.showWarningMessage(
|
||||||
|
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (turn.replacement === selectedText) {
|
||||||
|
void vscode.window.showInformationMessage("Cowriting: Claude proposed no change to the selection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = await proposalController.propose(
|
||||||
|
document,
|
||||||
|
fp,
|
||||||
|
turn.replacement,
|
||||||
|
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } },
|
||||||
|
{ turnId, instruction },
|
||||||
|
);
|
||||||
|
if (id) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (token_was_cancelled(err)) return; // replaced below
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `token` is scoped inside the `withProgress` callback, surface cancellation by catching it there instead of the outer `catch`. Final form of the `catch` and cancel handling: wrap the `runEditTurn` call in a try/catch **inside** the callback that reflects cancel, and let other errors propagate to the outer `catch`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// inside the withProgress async (progress, token) callback, around runEditTurn:
|
||||||
|
let turn;
|
||||||
|
try {
|
||||||
|
turn = await runEditTurn(instruction, selectedText, { onProgress: ui.onProgress, signal: ui.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (token.isCancellationRequested) {
|
||||||
|
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep the single outer `try/catch` for the non-cancel error path exactly as it is today.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `liveProgressUi` to `CowritingApi` and the return**
|
||||||
|
|
||||||
|
In the `CowritingApi` interface (`src/extension.ts:18`) add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
liveProgressUi: LiveProgressUi;
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the `return { ... }` block (`src/extension.ts:287`) add `liveProgressUi,`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: no errors (Task 5 must land for the widened constructor to typecheck — if doing tasks strictly in order, expect one error here about the 5th constructor arg; that resolves in Task 5. To keep this task self-contained, do Task 5 Step 1 (constructor signature) before re-running. Otherwise proceed to Task 5 and typecheck there.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: esbuild succeeds; `@cline/*` stays external.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/extension.ts
|
||||||
|
git commit -m "feat(#60): editSelection shows live progress + cancel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Thread progress through the preview path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/trackChangesPreview.ts` (widen `EditTurn`; accept `LiveProgressUi`; thread opts through `askClaude` → `runEditAndPropose`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Widen the `EditTurn` seam type and the constructor**
|
||||||
|
|
||||||
|
In `src/trackChangesPreview.ts`, change the `EditTurn` type (line 22) to accept optional turn options (back-compat: existing E2E stubs ignore the new arg):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||||
|
import type { LiveProgressUi } from "./liveProgressUi";
|
||||||
|
|
||||||
|
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the default `editTurn` (line 56) to forward opts:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||||
|
const { runEditTurn } = await import("./liveTurn");
|
||||||
|
return runEditTurn(instruction, text, opts);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `liveProgressUi` as a constructor parameter (after `proposals`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
constructor(
|
||||||
|
private readonly diffView: DiffViewController,
|
||||||
|
private readonly extensionUri: vscode.Uri,
|
||||||
|
private readonly attribution: AttributionController,
|
||||||
|
private readonly proposals: ProposalController,
|
||||||
|
private readonly liveProgressUi: LiveProgressUi,
|
||||||
|
) {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make `askClaude` cancellable + progress-relaying**
|
||||||
|
|
||||||
|
Rewrite the `withProgress` block in `askClaude` (`src/trackChangesPreview.ts:232-235`) to mirror Task 4:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
const ids = await vscode.window.withProgress(
|
||||||
|
{
|
||||||
|
location: vscode.ProgressLocation.Notification,
|
||||||
|
title: "Cowriting: asking Claude…",
|
||||||
|
cancellable: true,
|
||||||
|
},
|
||||||
|
async (progress, token) => {
|
||||||
|
const ui = this.liveProgressUi.begin(instruction, progress, token);
|
||||||
|
try {
|
||||||
|
return await this.runEditAndPropose(document, target, instruction, { onProgress: ui.onProgress, signal: ui.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if (token.isCancellationRequested) return [] as string[];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||||
|
} else {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(On cancel the inner catch returns `[]`, which shows the benign "proposed no changes" — no proposal is created, satisfying INV-47. A distinct "cancelled" toast is optional; the editSelection path shows one. Keep parity simple: returning `[]` is the no-proposal outcome.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Thread `opts` through `runEditAndPropose`**
|
||||||
|
|
||||||
|
Change the signature (`src/trackChangesPreview.ts:257`) and both `editTurn(...)` calls (lines 270, 276) to pass opts:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async runEditAndPropose(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
target: EditTarget,
|
||||||
|
instruction: string,
|
||||||
|
opts?: RunEditTurnOptions,
|
||||||
|
): Promise<string[]> {
|
||||||
|
// ...
|
||||||
|
// range branch:
|
||||||
|
const turn = await this.editTurn(instruction, selected, opts);
|
||||||
|
// ...
|
||||||
|
// document branch:
|
||||||
|
const turn = await this.editTurn(instruction, full, opts);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck + full unit run**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npx vitest run`
|
||||||
|
Expected: typecheck clean (the widened constructor now matches Task 4's call site); all unit tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: success.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/trackChangesPreview.ts
|
||||||
|
git commit -m "feat(#60): preview Ask-Claude streams live progress + cancel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Host E2E — additive proof + cancel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/e2e/suite/liveProgress.test.ts`
|
||||||
|
|
||||||
|
The E2E harness can't read notification subtitles, so it asserts the *contract*: progress is additive (proposals unchanged with progress events) and cancel yields zero proposals. The streamed-UI itself is covered by Task 1 units + the manual smoke.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the test**
|
||||||
|
|
||||||
|
Create `test/e2e/suite/liveProgress.test.ts`, modeled on `f11Toolbar.test.ts` (same `getExtension`→`activate()`→`api.trackChangesPreviewController` pattern, same `setEditTurnForTest`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as assert from "assert";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
|
suite("#60 live progress (additive + cancel)", () => {
|
||||||
|
async function setup(): Promise<{ api: CowritingApi; doc: vscode.TextDocument }> {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# Title\n\nOld paragraph.\n" });
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||||
|
return { api, doc };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
|
||||||
|
const { api, doc } = await setup();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
|
||||||
|
// the same rewrite — proposals must be unaffected by progress.
|
||||||
|
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-live" };
|
||||||
|
});
|
||||||
|
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite");
|
||||||
|
assert.ok(ids.length >= 1, "expected at least one proposal regardless of progress events");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an aborted turn proposes nothing (INV-47)", async () => {
|
||||||
|
const { api, doc } = await setup();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
// Simulate the SDK honoring the abort signal: throw as runEditTurn would on
|
||||||
|
// a non-"completed" (aborted) status.
|
||||||
|
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||||
|
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
|
||||||
|
throw new Error("claude-code turn aborted"); // pre-aborted in this test
|
||||||
|
});
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
let ids: string[] = [];
|
||||||
|
try {
|
||||||
|
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||||
|
} catch {
|
||||||
|
ids = [];
|
||||||
|
}
|
||||||
|
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Compile + run the E2E suite**
|
||||||
|
|
||||||
|
Run: `npm run pretest:e2e && npm run test:e2e`
|
||||||
|
Expected: the new suite passes alongside the existing E2E suites. (If the sandboxed `.vscode-test` has the known `executeCommand("undo")` breakage, the #38 undo suite may skip/fail independently — that is pre-existing per session 0048/0049 and unrelated to #60; confirm only the live-progress suite is green and the rest are unchanged from baseline.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add test/e2e/suite/liveProgress.test.ts
|
||||||
|
git commit -m "test(#60): host E2E — progress is additive; abort proposes nothing (INV-44/47)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Manual smoke + final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full unit + typecheck + build**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npx vitest run && npm run build`
|
||||||
|
Expected: typecheck clean; all unit tests pass; build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual smoke (real LLM)**
|
||||||
|
|
||||||
|
Per #60 acceptance (E2E can't assert streamed UI). With Claude Code signed in, run the extension (F5 / Extension Development Host), open a markdown doc, and Ask-Claude a non-trivial edit. Confirm:
|
||||||
|
- the notification line advances (`thinking…` → `writing… (N chars)` → token count rising; `running <tool>…` if a tool fires);
|
||||||
|
- the "Cowriting: Claude" OutputChannel reveals (without stealing focus) and streams the text;
|
||||||
|
- the `✕` cancel aborts cleanly (status resolves, no proposal);
|
||||||
|
- toggling `cowriting.liveProgress.revealOutput` off suppresses the auto-reveal;
|
||||||
|
- the final proposals are exactly as before (INV-44).
|
||||||
|
|
||||||
|
`scripts/smoke-live-turn.mjs` (`npm run smoke:live`) exercises the turn headlessly if a UI smoke isn't convenient; note it does not render the notification.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Record the smoke result in the session transcript** (`## Deferred decisions` or a smoke note), since E2E doesn't cover the live UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed by plan author)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- §2.1 notification activity line + token count → Task 1 (`formatProgressLine`), Task 4/5 (`progress.report`). ✓
|
||||||
|
- §2.1 OutputChannel full-text stream + auto-reveal gate → Task 3 (`LiveProgressUi`, setting). ✓
|
||||||
|
- §2.1 cancellation → "cancelled", no proposal → Task 4 (editSelection), Task 5 (preview), Task 6 (E2E). ✓
|
||||||
|
- §2.2 reasoning not surfaced → Task 1 (reasoning-delta → thinking, no textDelta). ✓
|
||||||
|
- §3.2 pure reducer (INV-46) → Task 1. ✓
|
||||||
|
- §3.3 runEditTurn onProgress + AbortSignal, vscode-free (INV-43/44) → Task 2. ✓
|
||||||
|
- §3.4 both call sites relay; widened EditTurn seam; stub stays valid → Task 4, Task 5, Task 6. ✓
|
||||||
|
- §3.5 OutputChannel append + header → Task 3. ✓
|
||||||
|
- §3.6 setting → Task 3. ✓
|
||||||
|
- §3.7 INV-43..47 → asserted across Tasks 1/2/6. ✓
|
||||||
|
- §4 unit (reducer + format) + host E2E (additive + cancel) + manual smoke → Tasks 1, 6, 7. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** none (every code/command step is concrete). The Task 4 Step 2 `token_was_cancelled(err)` pseudo-line is immediately replaced by the concrete inner-try/catch in the same step.
|
||||||
|
|
||||||
|
**Type consistency:** `TurnProgressSnapshot`, `RunEditTurnOptions`, `TurnUi`, `LiveProgressUi`, `reduceTurnProgress`, `formatProgressLine`, `createTurnProgressState` used consistently across tasks; `editTurn` widened uniformly; `toolName`/`inputTokens+outputTokens` match the SDK `agent.d.ts`.
|
||||||
Reference in New Issue
Block a user