27 Commits

Author SHA1 Message Date
BenStullsBets a3ea6c65ca fix(#60): pre-aborted signal short-circuits before run; guard onProgress relay
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>
2026-06-26 04:49:02 -07:00
BenStullsBets 21bbf6b114 test(#60): smoke-live-turn logs live progress snapshots
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:38:33 -07:00
BenStullsBets 0743cf9a8a test(#60): host E2E — progress is additive; abort proposes nothing (INV-44/47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:37:52 -07:00
BenStullsBets 946625e899 feat(#60): wire live progress + cancel into both Ask-Claude call sites
editSelection (extension.ts) and preview askClaude (trackChangesPreview.ts)
both run the turn cancellable, relay TurnProgress via the shared LiveProgressUi,
and thread onProgress/signal through runEditAndPropose. The EditTurn seam widens
to accept opts (back-compat: existing stubs ignore it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:35:41 -07:00
BenStullsBets 20e13bba4d feat(#60): liveProgressUi host relay + revealOutput setting (INV-45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:34:10 -07:00
BenStullsBets d2405a2ca8 feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:33:40 -07:00
BenStullsBets 5d9f7ddaaf feat(#60): pure turn-progress reducer (INV-46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:32:02 -07:00
BenStullsBets 98b33ff53b claim vscode-cowriting-plugin session 0056 (placeholder) + sessions.json entry 2026-06-26 04:24:43 -07:00
BenStullsBets c5e464fbeb add sessions/0055/SESSION-0055.0-TRANSCRIPT-2026-06-22T23-19--2026-06-26T04-23.md + replace placeholder/variant SESSION-0055.0-TRANSCRIPT-2026-06-22T23-19--INPROGRESS.md 2026-06-26 04:24:19 -07:00
Ben Stull 16cca30d39 claim vscode-cowriting-plugin session 0055 (placeholder) + sessions.json entry 2026-06-22 23:19:57 -07:00
Ben Stull 9b6a15a43c add sessions/0054/SESSION-0054.0-TRANSCRIPT-2026-06-15T10-42--2026-06-15T10-48.md + replace placeholder/variant SESSION-0054.0-TRANSCRIPT-2026-06-15T10-42--INPROGRESS.md 2026-06-22 22:59:20 -07:00
Ben Stull edba577586 claim vscode-cowriting-plugin session 0054 (placeholder) + sessions.json entry 2026-06-15 10:43:00 -07:00
Ben Stull ceca17aa40 add sessions/0053/SESSION-0053.0-TRANSCRIPT-2026-06-15T10-23--2026-06-15T10-29.md + replace placeholder/variant SESSION-0053.0-TRANSCRIPT-2026-06-15T10-23--INPROGRESS.md 2026-06-15 10:42:23 -07:00
Ben Stull 528c76d23b claim vscode-cowriting-plugin session 0053 (placeholder) + sessions.json entry 2026-06-15 10:23:27 -07:00
Ben Stull b9517e0f68 add sessions/0052/SESSION-0052.0-TRANSCRIPT-2026-06-15T08-33--2026-06-15T08-41.md + replace placeholder/variant SESSION-0052.0-TRANSCRIPT-2026-06-15T08-33--INPROGRESS.md 2026-06-15 10:22:35 -07:00
Ben Stull 5a02e793dd claim vscode-cowriting-plugin session 0052 (placeholder) + sessions.json entry 2026-06-15 08:33:55 -07:00
Ben Stull faf0810a6c add sessions/0051/SESSION-0051.0-TRANSCRIPT-2026-06-13T17-49--2026-06-13T17-58.md + replace placeholder/variant SESSION-0051.0-TRANSCRIPT-2026-06-13T17-49--INPROGRESS.md 2026-06-15 03:28:01 -07:00
Ben Stull d597c1c362 claim vscode-cowriting-plugin session 0051 (placeholder) + sessions.json entry 2026-06-13 17:49:33 -07:00
Ben Stull 790d88c827 add sessions/0050/SESSION-0050.0-TRANSCRIPT-2026-06-13T17-43--2026-06-13T17-52.md + replace placeholder/variant SESSION-0050.0-TRANSCRIPT-2026-06-13T17-43--INPROGRESS.md 2026-06-13 17:48:52 -07:00
Ben Stull 37953cfcad claim vscode-cowriting-plugin session 0050 (placeholder) + sessions.json entry 2026-06-13 17:43:55 -07:00
Ben Stull 24e329e25d add sessions/0049/SESSION-0049.0-TRANSCRIPT-2026-06-13T09-19--2026-06-13T09-24.md + replace placeholder/variant SESSION-0049.0-TRANSCRIPT-2026-06-13T09-19--INPROGRESS.md 2026-06-13 09:22:49 -07:00
benstull 96a689aedf Merge pull request '#54 follow-up: pretest:e2e cleans out/test (stop stale cross-branch compiled tests running)' (#56) from s54-pretest-clean into main 2026-06-13 16:21:57 +00:00
Ben Stull 911ed21671 #54 follow-up: pretest:e2e cleans out/test before recompiling
`tsc -p tsconfig.e2e.json` emits to `out/` but never removes outputs for test
sources absent on the current branch, so stale compiled
`out/test/e2e/suite/*.test.js` from other branches were picked up and run by the
suite glob (`**/*.test.js`) — this caused real cross-branch test confusion in
session 0048 (a deleted probe + another branch's tests ran on an unrelated branch).

Add a `clean:e2e` script (`fs.rmSync('out/test', {recursive, force})` via node, so
no shell `rm` dependency) and run it between `build` and `tsc` in `pretest:e2e`.
Cleans ONLY `out/test` — never `out/`, which holds the just-built esbuild bundle
(`out/extension.cjs`, `out/media`).

Verified: planting a stale `out/test/.../zz.test.js` then running `pretest:e2e`
removes it, and the E2E suite stays green (73 passing + 1 pending, both passes
exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:21:31 -07:00
Ben Stull 2ca0fc8c51 claim vscode-cowriting-plugin session 0049 (placeholder) + sessions.json entry 2026-06-13 09:19:26 -07:00
benstull 3d9270ecc4 Merge pull request '#54: resilient undo E2E (preflight skip-guard) — fixes main E2E red' (#55) from s54-undo-e2e-resilience into main 2026-06-13 16:17:02 +00:00
Ben Stull e53f0c30ad #54: make undo-dependent E2E resilient to environments where undo is broken
`executeCommand("undo")` is non-functional in some headless `.vscode-test`
instances (it does not restore the buffer — see #54's diagnosis), which
false-fails every undo-dependent host E2E and turns `main`'s E2E red there even
though the product code is fine.

Add a RUNTIME preflight probe (`undoCapable.ts`): edit a scratch buffer, undo,
and report whether the buffer was actually restored (memoized per run). The #38
undoMarks suite gates on it via `suiteSetup` — running normally where undo works
(full coverage preserved, real regressions still caught) and skipping with a LOUD
console warning where it doesn't (no false red, no silent loss — the skip is
logged and shows as `pending`).

This fixes the "main E2E red" symptom. The underlying headless-undo limitation is
documented in #54; #40's undo-behavior coverage runs wherever undo works.

Test-infra only — no product code changed. Verified: with undo broken locally the
#38 suite skips (1 pending) and both E2E passes exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:16:40 -07:00
Ben Stull ad6cbe10c7 add sessions/0048/SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--2026-06-13T08-58.md + replace placeholder/variant SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--INPROGRESS.md 2026-06-13 08:58:11 -07:00
24 changed files with 1263 additions and 186 deletions
+12 -1
View File
@@ -18,6 +18,16 @@
"onStartupFinished"
],
"contributes": {
"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."
}
}
},
"commands": [
{
"command": "cowriting.showClineSdkInfo",
@@ -202,7 +212,8 @@
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"clean:e2e": "node -e \"require('fs').rmSync('out/test',{recursive:true,force:true})\"",
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"vscode:prepublish": "node esbuild.mjs"
+9 -1
View File
@@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`);
console.log(`text: ${text}`);
try {
const t0 = Date.now();
const result = await runEditTurn(instruction, text);
// #60: exercise the live-progress path against the real SDK — log each reduced
// snapshot so the smoke shows streaming/activity/tokens, not just the result.
const result = await runEditTurn(instruction, text, {
onProgress: (s) => {
const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`];
if (s.tokens) bits.push(`${s.tokens}tok`);
console.log(` progress: ${bits.join(" ")}`);
},
});
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
console.log(`model: ${result.model}`);
console.log(`sessionId: ${result.sessionId}`);
@@ -0,0 +1,90 @@
# Session 0048.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-45 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-58 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
```
## Plan
**#40 (task, P3)** — restore exact author attribution on undo/redo (follow-up to
#38). Anchor: leaf `task` → §4.3 R2 (no design gate). Picked autonomously (last
R2-eligible leaf not blocked by a content-repo/careful/irreversible gate).
#38 made undo/redo re-inserted text **neutral** (no false human coloring) but
lossy: undoing a deletion of Claude text shows it neutral, not blue. #40 restores
the **exact prior** attribution.
**Mechanism (engineering choice): text-keyed attribution snapshots.** Per-doc
`Map<documentText, spans>`; snapshot after every FORWARD edit (and at load). On
undo/redo, after the #38 geometry reconcile, if a snapshot's text equals the
current buffer, restore those spans exactly (offsets valid — text identical).
Robust to VS Code undo coalescing (only the event whose resulting text matches a
snapshot restores; far-back/evicted states fall back to #38 neutral). Bounded
history.
Tasks: snapshot+restore in `attributionController` (loadAll + onDidChange);
tests (agent-text undo restores blue; edit→undo→redo round-trip; #38 regression
green). No deploy pipeline (extension).
## Results
**#40 implemented but NOT shipped — verification-blocked.** On branch
`s40-undo-provenance` (pushed, unmerged); issue #40 **kept open**.
- `attributionController.ts` — text-keyed attribution snapshots
(`attrHistory: Map<documentText, spans>`, `ATTR_HISTORY_MAX` bounded): snapshot
after every forward edit + at `loadAll`; on undo/redo restore the snapshot whose
text equals the current buffer (else #38 neutral fallback). Robust to undo
coalescing.
- `test/e2e/suite/s40Provenance.test.ts` — agent-text-undo-restores-blue +
edit→undo→redo round-trip.
- **222 unit + typecheck green.**
**BLOCKER (environmental, not the code):** the #40 host E2E — and the *untouched*
#38 `undoMarks` E2E — drive `executeCommand("undo")`, which does **not restore
text** in this local test instance. Proven by stashing all my changes and running
clean `main`: the #38 test fails identically (`undo restored 'bravo'`). This is the
**known undoMarks flake (session 0037), now deterministic** in this environment.
The usual remedy — clearing `.vscode-test/user-data` — is **permission-blocked**
this session (`rm -rf` denied). Focusing the doc before `undo` (`showTextDocument`)
did not help. So #40's end-to-end behavior cannot be verified here; shipping
unverified changes to the load-bearing F3 attribution controller would violate
verify-before-completion. Left on a branch for the operator to verify + merge in a
working E2E environment.
**Also surfaced:** `main`'s E2E is currently **red in this environment** for the
same environmental reason (the undoMarks `undo` flake) — a test-infra issue
independent of product code.
## Session arc
1. Stop hook required determining/executing the next milestone; chose #40 (last
R2-eligible leaf). Claimed 0048, clean `main`.
2. Read #40 + #38 + the attribution change handler; chose text-keyed snapshots.
3. TDD: wrote #40 E2E + implemented snapshot/restore; 222 unit green.
4. E2E: 3 undo-driven tests failed at the `undo restored X` step. Isolated by
stashing → clean `main`'s #38 test fails identically → environmental undo flake,
not my code. Could not clear `.vscode-test` (permission-blocked).
5. Committed #40 to a branch (pushed, unmerged, marked UNVERIFIED); kept #40 open;
stopped per verify-before-completion.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Empty if none._
- **Did not merge #40** (driver call). Alternative: merge on unit-green + reasoning
alone. Why not: it changes the core F3 attribution controller and its behavior is
only meaningfully provable through an undo E2E, which is environmentally broken
here — verify-before-completion says don't claim/ship it. Preserved on a branch
for operator verification instead. (Reversible: just merge once verified.)
@@ -0,0 +1,61 @@
# Session 0049.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T09-19 (PST)
> Type: planning-and-executing
> End: 2026-06-13T09-24 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute: pretest:e2e cleans stale compiled tests in out/test (stale *.test.js from other branches were running via the glob) — build hygiene follow-up to #54
```
## Plan
Build-hygiene follow-up to #54 (noted on that issue). `tsc -p tsconfig.e2e.json`
emits to `out/` but never removes outputs for test sources absent on the current
branch, so stale compiled `out/test/e2e/suite/*.test.js` from other branches get
run by the suite glob (`**/*.test.js`) — this caused real cross-branch test
confusion in session 0048 (a removed probe + the s40 branch's tests ran on an
unrelated branch). Fix: `pretest:e2e` cleans `out/test` before recompiling.
- `package.json` — add `clean:e2e` (node `fs.rmSync('out/test', {recursive,force})`
— avoids shell `rm` issues) and run it between `build` and `tsc` in `pretest:e2e`.
Clean ONLY `out/test` (NOT `out/`, which holds the just-built esbuild bundle).
- Verify: introduce a stale `out/test/.../zz.test.js`, run `pretest:e2e`, confirm
it's gone + the E2E suite is green.
Trivial, ungated, verifiable; test-infra only. No deploy pipeline (extension).
## Results
**Shipped to `main`** — PR #56 (merged). `package.json`: new `clean:e2e`
(`fs.rmSync('out/test',{recursive,force})`) run between `build` and `tsc` in
`pretest:e2e`. Cleans only `out/test` (never the esbuild bundle in `out/`).
Verified: a planted stale `out/test/.../zz.test.js` is removed by `pretest:e2e`;
E2E green (73 passing + 1 pending [#38 undo-skip], both passes exit 0). Test-infra
only; no product code.
Properly tracked under this session (0049) — closing the protocol gap where
#54/#55 merged after 0048 had finalized.
## Session arc
1. Stop hook pushed me to execute the one remaining ungated/verifiable item (the
`out/`-clean follow-up I'd noted on #54). Claimed 0049 to track it cleanly.
2. Confirmed `tsc` outDir → `out/test`; esbuild bundle → `out/extension.cjs`
(separate), so cleaning `out/test` is safe.
3. Implemented `clean:e2e`; verified by planting a stale compiled test (removed)
+ full E2E green. Shipped PR #56.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Empty if none._
- _No low-confidence calls this session._ (Cleaning stale build output is
unambiguously correct; scoped to `out/test` to protect the esbuild bundle.)
@@ -0,0 +1,76 @@
# Session 0050.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T17-43 (PST)
> End: 2026-06-13T17-52 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "When changing documents, we should decide what happens to the
review pane of the current document, and if the document switched to should get a
review experience or not"
## Pre-state
- Branch `main`, clean, pushed (origin/main). No in-flight sessions for the app.
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
repo `vscode-cowriting-plugin-content` cloned, with ~18 prior uncommitted
capture drafts in `issues/`.
- Prior frontier exhausted (session 0047 note): remaining backlog items gated;
operator directing. This is a capture session, not a build.
## Arc
1. **Claimed** tracked-lite session ID **0050** (`claim-session-id.sh --type
capture`). Resolved app + content repo (`resolve-app.py`).
2. **Grounded** the ask by reading the code (Explore subagent over
`src/trackChangesPreview.ts`, `diffViewController.ts`, `proposalController.ts`):
confirmed the review pane is one-panel-per-doc keyed by URI
(`trackChangesPreview.ts:45`) with **no** `onDidChangeActiveTextEditor`
listener — so on document switch the pane **stays pinned** to its original doc
(incidental, not designed) and the switched-to doc gets no review until
`showTrackChangesPreview` is re-invoked. Per-doc review state (F6 baseline / F4
proposals / F3 attribution) auto-creates on demand.
3. **Sized** the single ask: genuine forks (follow / pin / close; auto-review /
on-demand) + needs design before build → `type/feature` (R3), `priority/P2`.
Captures the decision-to-be-made; option space goes in Solution notes,
non-binding.
4. **Drafted** `issues/review-pane-on-document-switch.md` in the content repo
(working tree only, INV-8), full §5 template. Scanned clean for secrets
(INV-3).
5. **Operator approved** filing as-is (feature, P2) via AskUserQuestion.
6. **Filed** → ensured labels (all pre-existing), then `capture-issues.sh` →
**#57** (https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/57).
## Cut state
- Session repo `vscode-cowriting-plugin`: clean `main`, nothing committed this
session (capture touches only the content repo working tree). No PRs.
- Content repo: new draft `issues/review-pane-on-document-switch.md` left
uncommitted for the Author to publish/discard (INV-8), alongside the prior
drafts already there. Not pushed by this skill.
- Issue **#57** filed and open on the tracker.
## Deferred decisions
_None — single clear ask, operator approved the framing and type before filing._
## Operator plate
- One feature filed: **#57** (review-pane behavior on document switch, P2).
- ~19 capture drafts now sit uncommitted in `vscode-cowriting-plugin-content` —
yours to commit/publish (or discard) when convenient.
- #57 needs a design before build (R3). No active plan frontier; operator to
direct next.
## Next session prompt
`/goal` — operator to direct. Candidate: brainstorm a Solution Design for **#57**
(review-pane behavior on document switch) — settle the two coupled rules (outgoing
pane fate; whether the switched-to doc auto-gets a review experience) before
building. Other open: OQ-2 (F11 graduation), #32 (scroll-sync, needs design),
#35 (repo rename), #40 (undo provenance, P3, branch `s40-undo-provenance`
unmerged), #54 (undo still broken in sandbox).
@@ -0,0 +1,71 @@
# Session 0051.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T17-49 (PST)
> End: 2026-06-13T17-58 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — two asks:
1. "Undo doesn't undo Claude's edits"
2. "When I make a change within a block that Claude has changed that I haven't
approved, it doesn't show my changes"
## Pre-state
- Branch `main`, clean, pushed. Immediately followed capture session 0050 (#57).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
repo cloned.
## Arc
1. **Claimed** tracked-lite session ID **0051**.
2. **Grounded** both asks in code (Explore subagent):
- Undo: Claude's edits are pending proposals; `proposalController.ts:303-310`
shifts anchors on edit but ignores `e.reason === Undo` — proposal not
reconciled on undo (accepted edits hit the doc and are undoable, but the
pending proposal lingers).
- Pending-block edits: a proposal freezes its "before" (`replaced: fp.text`,
`proposalController.ts:81-95`); the block renders from current text but the
proposal's frozen view (`trackChangesModel.ts:706-722`/`:769-805`) obscures
live human edits in that block.
3. **Checked duplication** against the undo cluster — #38 (wrong marks, closed),
#40 (attribution color, open), #54 (undo E2E infra, open). Both new asks are
distinct (undo failing to *reverse* the edit; live edits obscured by a pending
proposal). Verified `type/bug` exists on the tracker.
4. **Drafted** both as `type/bug` (P1) in the content repo working tree
(`issues/undo-does-not-undo-claude-edits.md`,
`issues/edits-in-pending-block-not-shown.md`), each framed as user-facing pain
with repro steps + code diagnosis demoted to parenthetical. Scanned clean for
secrets (INV-3).
5. **Operator triage:** before filing, operator said "ignore both of these
issues — you can delete the drafts." Both drafts **deleted**; nothing filed.
## Cut state
- **Nothing filed** (INV-4 — no-actionable outcome after operator triage).
- Both draft files removed from the content repo working tree.
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator explicitly directed dropping both asks._
## Operator plate
- No issues filed this session. The two undo/pending-block asks were
considered and intentionally dropped (recorded in memory
`session-0051-capture-no-actionable.md` so they aren't re-raised as new).
- Note: these remain *real* observed behaviors per the code reading — dropped on
operator triage, not because they were invalid. If they resurface, the prior
grounding is in the memory note.
## Next session prompt
`/goal` — operator to direct. From session 0050: candidate is brainstorming a
Solution Design for **#57** (review-pane behavior on document switch). Other
open: OQ-2 (F11 graduation), #32 (scroll-sync), #35 (repo rename), #40 (undo
provenance, P3, branch `s40-undo-provenance` unmerged), #54 (undo E2E infra).
@@ -0,0 +1,73 @@
# Session 0052.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T08-33 (PST)
> End: 2026-06-15T08-41 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — two asks (second arrived mid-session):
1. "the review window and markdown source file should have scroll location sync'd"
2. "the claude change recommendation block (which user will accept or decline)
should show the diff with the original"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57) and 0051
(no-actionable).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
**Ask 1 — scroll-sync (duplicate, nothing filed):**
1. Claimed tracked-lite session ID **0052**.
2. Recognized scroll-sync as a known F10 follow-up; fetched **#32** — exact match
(*"Scroll-sync the review preview with the source editor"*, open,
`type/feature`, `priority/P3`, filed session 0031).
3. Surfaced to operator; operator chose to **leave #32 at P3**. No new issue
(INV-4 duplicate).
**Ask 2 — proposal block diff vs original (filed #58):**
4. Grounded in code (Explore): the pending proposal block renders original +
proposed as **two separate full blocks** (`<del>` whole-before + `<ins>`
whole-after, `trackChangesModel.ts:715-716`) with **no word-level diff** — the
changed-block rendering already uses `wordMergedMarkdown`/`diffWords`
(`trackChangesModel.ts:431-438`) but `proposalBlockHtml()` doesn't.
`ProposalView` already carries `replaced`+`replacement`
(`proposalController.ts:81-95`).
5. Sized `type/story`, P2 (reuses existing word-diff helper; distinct from #31
placement / #47 granularity). Drafted
`issues/proposal-block-shows-diff-with-original.md` (working tree only, secrets
scanned).
6. Operator approved as-is → ensured labels → filed **#58**
(https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/58).
## Cut state
- **#58** filed (story, P2). Scroll-sync left as existing **#32** (P3) — nothing
new filed for it.
- Draft `issues/proposal-block-shows-diff-with-original.md` left uncommitted in
the content repo for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator directly resolved the duplicate and approved the #58 framing._
## Operator plate
- One issue filed: **#58** (proposal block word-diff, P2).
- Scroll-sync remains **#32** (P3, open, unchanged).
- One uncommitted capture draft added to the content repo (joins the prior
drafts) — yours to publish or discard.
## Next session prompt
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
Solution Design for **#57** (review-pane behavior on document switch). Open work:
#58 (proposal word-diff, P2, story), #32 (scroll-sync, P3), OQ-2 (F11
graduation), #35 (repo rename), #40 (undo provenance, P3, branch
`s40-undo-provenance` unmerged), #54 (undo E2E infra).
@@ -0,0 +1,66 @@
# Session 0053.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T10-23 (PST)
> End: 2026-06-15T10-29 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "The plugin is asking for access to other applications but works
fine when I decline. It just shouldn't"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
(no-actionable), 0052 (#58 + scroll-sync dup).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
1. Claimed tracked-lite session ID **0053**.
2. **Investigated** the macOS Automation/Apple-Events trigger (Explore): the
extension's own source has **no** AppleScript/`osascript`/automation calls; the
probable origin is **`@cline/sdk`/`@cline/core`** during agent activation
(likely local Claude Code hub-discovery), which falls back gracefully when
denied — matching "works fine when declined." Framed as a lead, not a settled
root cause.
3. **Sized** `type/bug` (declared defect: unwanted OS permission request).
Drafted `issues/spurious-automation-permission-prompt.md` (working tree only,
secrets scanned) with repro steps + expected/actual + the upstream caveat.
4. **Operator set priority P1** (trust/launch-optics over the harmless functional
impact); updated the WSJF line accordingly.
5. **Filed #59.** capture-issues.sh `--type` rejects `bug` (known #124 taxonomy
gap), so filed with `--type task` then swapped the label to `type/bug` via the
Gitea API (deleted type/task, added type/bug) — verified `['priority/P1',
'type/bug']` (INV-2 holds).
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/59
## Cut state
- **#59** filed (bug, P1), label corrected to exactly `type/bug`.
- Draft `issues/spurious-automation-permission-prompt.md` left uncommitted in the
content repo for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator set the priority directly and approved the framing._
## Operator plate
- One issue filed: **#59** (spurious macOS automation prompt, bug, **P1**).
- The cause is likely upstream in `@cline/sdk` — the fix may be a config/flag or
dependency update, not necessarily this repo's code.
- Reminder: bug capture needs the file-as-task-then-relabel workaround until
plugin #124 (capture can't type bugs) is fixed.
## Next session prompt
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
Solution Design for **#57** (review-pane behavior on document switch). Open work:
**#59** (automation prompt, bug P1 — may be upstream), #58 (proposal word-diff,
story P2), #32 (scroll-sync, P3), OQ-2 (F11 graduation), #35 (repo rename), #40
(undo provenance, P3), #54 (undo E2E infra).
@@ -0,0 +1,66 @@
# Session 0054.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T10-42 (PST)
> End: 2026-06-15T10-48 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "See the Claude output/progress in the 'asking Claude…' status"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
(no-actionable), 0052 (#58 + scroll-sync dup), 0053 (#59).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
1. Claimed tracked-lite session ID **0054**.
2. **Grounded** (Explore): the "Cowriting: asking Claude…" status is an opaque
`withProgress` notification (`extension.ts:232-233`,
`trackChangesPreview.ts:232-233`) awaiting `agent.run()` as one black-box
promise (`liveTurn.ts:52`). Key enabler: `@cline/sdk` already emits streaming
events (`assistant-text-delta`, `tool-started/updated/finished`,
`usage-updated`) via `agent.subscribe()` / an `onEvent` hook, but the extension
constructs the Agent with **no hooks** (`liveTurn.ts:47-51`) and discards it.
3. **Sized** `type/feature` — implementable by subscribing to existing events, but
a real design fork on the surface (notification text vs. OutputChannel vs.
status bar vs. webview relay) and content (text/tool/usage/reasoning) → design
first (R3). Drafted `issues/show-live-claude-progress.md` (working tree only,
secrets scanned).
4. **Operator set priority** — chose "adjust priority" → **P1** (opaque wait hurts
every turn). Updated WSJF line.
5. **Filed #60** (`type/feature`, P1).
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/60
## Cut state
- **#60** filed (feature, P1).
- Draft `issues/show-live-claude-progress.md` left uncommitted in the content repo
for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator set the priority directly and approved the framing._
## Operator plate
- One issue filed: **#60** (live Claude progress, feature, **P1**).
- Implementation is largely "subscribe to SDK events already available + pick the
progress surface" — needs a small design first (R3).
- One uncommitted capture draft added to the content repo — yours to publish or
discard.
## Next session prompt
`/goal` — operator to direct. Capture run 00500054 filed #57, #58, #59, #60
(scroll-sync = existing #32). Two P1s now open (**#59** automation prompt,
**#60** live progress) plus standing #57 (review-pane on doc switch, design
candidate). Other open: #58 (proposal word-diff, P2), #32 (scroll-sync P3), OQ-2
(F11 graduation), #35 (repo rename), #40 (undo provenance P3), #54 (undo E2E
infra).
@@ -0,0 +1,105 @@
# Session 0055.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-22T23-19 (PST)
> End: 2026-06-26T04-23 (PST)
> Type: brainstorming
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-brainstorming highest priority ticket`
## Plan
Brainstorming session: explore the highest-priority open ticket and write its
Solution-Design spec. Highest-priority that fits a brainstorming session (spec
output; a feature needs a design per §4.3 R3) was **#60 — Show Claude's live
output/progress during the "asking Claude…" status** (P1 feature). #59 (P1 bug)
is coding-session work, not brainstorming.
Output: `specs/coauthoring-live-progress.md` (Solution Design), submitted graduated
at finalize to the content repo's `specs/` collection.
## Pre-state
- Clean, pushed `main` (`24e329e`). No in-flight sessions (next ID 0055).
- Two P1 tickets tied for highest priority: **#59** (P1 bug — spurious macOS
"control other applications" prompt) and **#60** (P1 feature — live turn
progress). Brainstorming produces a spec; a feature needs a design (§4.3 R3),
so #60 was the fit. #59 (bug) → a coding session.
- Highest INV in use across the content-repo specs: **INV-42**.
## Session arc (turn-by-turn)
1. **Gate + classify.** Launch prompt classified as **brainstorming**; ran
`wgl-brainstorming`.
2. **Claim (peek → gate → claim).** `claim-session-id.sh --dry-run` → no in-flight
sessions, next ID **0055**. Claimed `--type brainstorming` (placeholder at
`16cca30`). Verified clean pushed `main` baseline.
3. **Orient.** Surveyed the open tracker. Picked **#60** (P1 feature, design
needed) over #59 (P1 bug, coding-session work). Read #60 in full.
4. **Ground in code (read-only).** Read `liveTurn.ts` (`runEditTurn`
`agent.run()` as one opaque promise; module is deliberately vscode-free), both
call sites (`extension.ts:232` editSelection, `trackChangesPreview.ts:232`
preview `askClaude` — both wrap the turn in `withProgress("asking Claude…")`),
the injectable `editTurn` seam (trackChangesPreview.ts:56, for host-E2E
stubbing), and the `@cline/sdk` event API: `@cline/agents` `Agent` exposes
`subscribe(listener) → unsubscribe`, `run()`, `abort()`; `AgentRuntimeEvent`
(in `@cline/shared/dist/agent.d.ts`) carries `assistant-text-delta`
(+`accumulatedText`), `tool-started|updated|finished`, `usage-updated`,
lifecycle events. The enabler the issue cites is real.
5. **Brainstorm forks (superpowers:brainstorming).** Skipped the visual companion
(choices are conceptual/native-UI, not visual mockups). Presented three forks
via AskUserQuestion; operator picked all three recommendations:
- **Surface** → notification activity-line + token count **and** a shared
`"Cowriting"` OutputChannel streaming full assistant text. (Rejected: webview
relay can't cover the editSelection path; status-bar-only; OutputChannel-
primary.)
- **Content** → activity + token count (reasoning text not surfaced).
- **Cancellation** → `cancellable` notification → `agent.abort()`
"cancelled", proposes nothing (reflect + cancel button).
6. **Design presented + approved.** Three-unit architecture (pure
`turnProgress.ts` reducer → `runEditTurn` extended with `onProgress` +
`AbortSignal` → both call sites relay via a shared `liveProgressUi`); layering
rule keeps `liveTurn.ts`/`turnProgress.ts` vscode-free; INV-43..47. Two
sub-decisions (OutputChannel auto-reveal gated by a new setting; append-not-
clear) confirmed. Operator: "design looks right."
7. **Spec written + self-review.** `specs/coauthoring-live-progress.md` authored
in house Solution-Design format. Self-review: no placeholders, INV-43..47 (no
collision with ≤42; INV-8/21/39 are correct citations), internally consistent,
single-feature scope.
8. **Operator: "Implement it."** Per the pipeline, that's the brainstorm→coding
handoff (one SPEC = one plan = one execution, §4.3). Finalized this
brainstorming session (below); a fresh `wgl-planning-and-executing` session
plans + builds #60.
## Cut state (at finalize)
- **Spec graduated.** `submit-spec.sh --status graduated`
`vscode-cowriting-plugin-content` `specs/coauthoring-live-progress.md` at
`3bd1ae5`.
- **Code repo:** clean `main`; no code changes this session (brainstorming).
A local stray `specs/coauthoring-live-progress.md` remains untracked in the code
repo (an `rm` cleanup was permission-denied; harmless — canonical copy is in the
content repo).
- **Memory:** added `session-0055-live-progress-spec-graduated.md` + index line.
## Next-session prompt
```
/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md
```
## Deferred decisions
- **OutputChannel auto-reveal** (fires every Claude turn): decided to auto-`show(true)`
(preserveFocus) on the *first* text delta, gated by a new setting
`cowriting.liveProgress.revealOutput` (default `true`). Operator confirmed the
design including this.
- **OutputChannel history:** decided to *append* per-turn (with a header) rather
than clear, so it doubles as a debug log. Operator confirmed.
- **Stray local spec file:** `rm -rf specs/` was permission-denied; left the
untracked local copy in the code repo (cosmetic; content repo holds the
canonical copy).
@@ -1,20 +1,17 @@
# Session 0048.0 — Transcript
# Session 0056.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-45 (PST)
> Start: 2026-06-26T04-24 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
## Launch prompt
```
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
```
_(launch prompt not captured at claim time)_
## Deferred decisions
+24
View File
@@ -142,5 +142,29 @@
},
"0048": {
"title": ""
},
"0049": {
"title": ""
},
"0050": {
"title": ""
},
"0051": {
"title": ""
},
"0052": {
"title": ""
},
"0053": {
"title": ""
},
"0054": {
"title": ""
},
"0055": {
"title": ""
},
"0056": {
"title": ""
}
}
+1 -38
View File
@@ -44,19 +44,8 @@ interface DocAttribution {
* orphans on reload).
*/
hadAttributions: boolean;
/**
* #40: text-keyed attribution snapshots for exact provenance restoration on
* undo/redo. Maps a document-text state → the live spans at that state (offsets
* valid for that exact text). Captured after every forward edit + at load;
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
* evicted) — a far-back/evicted state falls back to the #38 neutral reconcile.
*/
attrHistory: Map<string, LiveSpan[]>;
}
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
const ATTR_HISTORY_MAX = 200;
export class AttributionController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocAttribution>();
@@ -101,7 +90,7 @@ export class AttributionController implements vscode.Disposable {
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
if (!s) {
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
this.docs.set(docPath, s);
}
return s;
@@ -136,9 +125,6 @@ export class AttributionController implements vscode.Disposable {
s.spans = coalesce(s.spans);
if (artifact.attributions.length > 0) s.hadAttributions = true;
}
// #40: seed the snapshot history with the loaded state so undoing back to it
// restores its exact attribution.
this.snapshotAttribution(s, document.getText());
this.render(document);
}
@@ -154,17 +140,6 @@ export class AttributionController implements vscode.Disposable {
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
/** #40: snapshot the current spans keyed by the document's current text. */
private snapshotAttribution(s: DocAttribution, text: string): void {
s.attrHistory.delete(text); // re-insert at the end (recency order)
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
const oldest = s.attrHistory.keys().next().value as string | undefined;
if (oldest === undefined) break;
s.attrHistory.delete(oldest);
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
const docPath = this.keyOf(e.document);
@@ -227,18 +202,6 @@ export class AttributionController implements vscode.Disposable {
);
}
}
// #40: exact provenance across history navigation. After a FORWARD edit,
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
// left re-inserted text neutral (#38); if a snapshot's text equals the current
// buffer, restore that state's spans exactly (offsets are valid — identical
// text) so Claude's restored text is blue again, the human's green. No match
// (far-back/evicted state) keeps the #38 neutral fallback.
if (isUndoRedo) {
const restored = s.attrHistory.get(e.document.getText());
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
} else {
this.snapshotAttribution(s, e.document.getText());
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
}
+30 -3
View File
@@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { isAuthorable, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,12 +24,18 @@ export interface CowritingApi {
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
// --- POC command (Feature #2), unchanged ---
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
context.subscriptions.push(output);
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
// OutputChannel) for both Ask-Claude entry points.
const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(liveProgressUi);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try {
@@ -104,6 +111,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
);
context.subscriptions.push(trackChangesPreviewController);
@@ -230,10 +238,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
async () => {
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText);
const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
@@ -292,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
diffViewController,
trackChangesPreviewController,
sidecarRouter,
liveProgressUi,
};
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 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; all 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 and returns
* the onProgress relay + an AbortSignal linked to the notification's cancel
* token. The channel APPENDS (it doubles as a debug log of recent turns, spec
* §3.5); it auto-reveals (without stealing focus) on the first streamed text,
* gated by `cowriting.liveProgress.revealOutput`.
*/
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 = (): void => {
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): void => {
progress.report({ message: formatProgressLine(s) });
if (s.textDelta) {
this.channel.append(s.textDelta);
reveal();
}
};
return { onProgress, signal: controller.signal };
}
dispose(): void {
this.channel.dispose();
}
}
+61 -11
View File
@@ -9,6 +9,9 @@
* never bundled (esbuild keeps it external).
*/
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface EditTurnResult {
replacement: string;
model: string;
@@ -16,6 +19,19 @@ export interface EditTurnResult {
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 = [
"You are a precise text editor embedded in VS Code.",
"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(
instruction: string,
selectedText: string,
opts?: { modelId?: string },
opts?: RunEditTurnOptions,
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
@@ -49,16 +65,50 @@ export async function runEditTurn(
modelId,
systemPrompt: SYSTEM_PROMPT,
});
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.
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
let state = createTurnProgressState();
const unsubscribe = opts?.onProgress
? agent.subscribe((event) => {
const next = reduceTurnProgress(state, event);
state = next.state;
// Observability must never affect the result (INV-44): a throwing relay
// is swallowed, not allowed to propagate into the SDK and fail the turn.
if (next.snapshot) {
try {
opts.onProgress!(next.snapshot);
} catch {
/* progress is best-effort */
}
}
})
: undefined;
const onAbort = () => agent.abort();
opts?.signal?.addEventListener("abort", onAbort);
try {
// A signal already aborted before the turn starts can't be honored by
// agent.abort() (the SDK's AbortController isn't created until run()), so
// short-circuit to the same aborted outcome the call site reflects (INV-47).
if (opts?.signal?.aborted) {
throw new Error("claude-code turn aborted: cancelled before start");
}
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 };
}
+32 -9
View File
@@ -16,10 +16,15 @@ import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/**
* F11: a host edit turn (selection/document text + instruction rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
@@ -53,9 +58,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
* F11: the host edit turn (INV-8 runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text) => {
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text);
return runEditTurn(instruction, text, opts);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
@@ -68,6 +73,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -230,8 +236,24 @@ export class TrackChangesPreviewController implements vscode.Disposable {
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
() => this.runEditAndPropose(document, target, instruction),
{
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) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
@@ -258,6 +280,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
@@ -267,13 +290,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full);
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
+122
View File
@@ -0,0 +1,122 @@
/**
* 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 eventstate logic
* lives here so it is unit-tested in isolation; the UI call sites only format and
* relay snapshots.
*/
import type { AgentRuntimeEvent } from "@cline/shared";
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);
}
+83
View File
@@ -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");
});
});
-113
View File
@@ -1,113 +0,0 @@
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));
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.attributionController, "exports attribution");
return api;
}
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
const api = await getApi();
const ctl = api.attributionController;
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
const t = "xxxx";
const start = doc.getText().indexOf(t);
const ok = await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
"ROBOT",
AGENT,
{ turnId: "turn-s40" },
);
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const key = api.proposalController.keyFor(doc);
assert.ok(
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
"ROBOT is agent-attributed after the seam edit",
);
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
const rs = doc.getText().indexOf("ROBOT");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
await settle();
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo");
await settle();
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
const r2 = doc.getText().indexOf("ROBOT");
const spans = ctl.getSpans(key);
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
assert.ok(over.length > 0, "restored ROBOT carries a span");
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
});
test("edit → undo → redo round-trips attribution to identical state", async () => {
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
const api = await getApi();
const ctl = api.attributionController;
const key = api.proposalController.keyFor(doc);
const start = doc.getText().indexOf("yyyy");
await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
"BLUEWORD",
AGENT,
{ turnId: "turn-s40b" },
);
await settle();
const norm = (k: string) =>
ctl
.getSpans(k)
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
.sort((x, y) => (x.t < y.t ? -1 : 1));
const afterAgent = JSON.stringify(norm(key));
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
const bs = doc.getText().indexOf("BLUEWORD");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
await vscode.workspace.applyEdit(del);
await settle();
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
await settle();
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
});
});
+41
View File
@@ -0,0 +1,41 @@
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
/**
* #54: `vscode.commands.executeCommand("undo")` is non-functional in some headless
* `.vscode-test` instances (it does not restore the buffer), which false-fails
* every undo-dependent E2E (#38, #40) and makes `main` E2E red there. This is a
* RUNTIME probe: it performs a real edit-then-undo on a scratch buffer and reports
* whether undo actually restored it. Undo-dependent suites gate on it running
* normally where undo works (real coverage), skipping with a loud warning where it
* doesn't (no false red, no silent loss the skip is logged). Memoized per run.
*/
let cached: boolean | undefined;
export async function undoWorks(): Promise<boolean> {
if (cached !== undefined) return cached;
const abs = path.join(process.env.E2E_WORKSPACE!, "docs/.undo-probe.md");
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, "undo probe baseline\n", "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await new Promise((r) => setTimeout(r, 250));
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "PROBE-EDIT-MARKER");
await vscode.workspace.applyEdit(edit);
await new Promise((r) => setTimeout(r, 250));
if (!doc.getText().includes("PROBE-EDIT-MARKER")) {
cached = false; // even the edit didn't take — treat as not undo-capable
return cached;
}
await vscode.commands.executeCommand("undo");
await new Promise((r) => setTimeout(r, 250));
cached = !doc.getText().includes("PROBE-EDIT-MARKER"); // undo removed the marker → undo works
return cached;
}
/** Loud, single-line reason logged when an undo suite skips (no silent loss — #54). */
export const UNDO_SKIP_REASON =
"[E2E] SKIPPING undo-dependent suite — executeCommand('undo') is non-functional in this " +
"VS Code test instance (see vscode-cowriting-plugin#54). These tests run where undo works.";
+9
View File
@@ -3,6 +3,7 @@ import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -34,6 +35,14 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
const DOC_REL = "docs/undo38.md";
const BASE = "Alpha bravo charlie.\n";
// #54: skip (loudly) where executeCommand("undo") is non-functional; run where it works.
suiteSetup(async function () {
if (!(await undoWorks())) {
console.warn(UNDO_SKIP_REASON);
this.skip();
}
});
test("undo of a deletion of baseline text leaves it unattributed (not human)", async () => {
const { doc, key } = await freshDoc(DOC_REL, BASE);
const api = await getApi();
+59 -2
View File
@@ -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();
});
});
+102
View File
@@ -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");
});
});