From c08dc075af1bab084a0e2e326d4651fc769aa36b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:25:56 -0700 Subject: [PATCH] F3 SLICE-5: live claude-code turn via applyAgentEdit seam + manual smoke (INV-8) (#6) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MANUAL-SMOKE-F3.md | 42 +++++++++++++++++++++++++++ esbuild.mjs | 19 +++++++++++-- package.json | 4 ++- scripts/smoke-live-turn.mjs | 22 ++++++++++++++ src/extension.ts | 50 ++++++++++++++++++++++++++++++++ src/liveTurn.ts | 57 +++++++++++++++++++++++++++++++++++++ test/liveTurn.test.ts | 18 ++++++++++++ 7 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 docs/MANUAL-SMOKE-F3.md create mode 100644 scripts/smoke-live-turn.mjs create mode 100644 src/liveTurn.ts create mode 100644 test/liveTurn.test.ts diff --git a/docs/MANUAL-SMOKE-F3.md b/docs/MANUAL-SMOKE-F3.md new file mode 100644 index 0000000..6b23868 --- /dev/null +++ b/docs/MANUAL-SMOKE-F3.md @@ -0,0 +1,42 @@ +# F3 manual smoke — live `claude-code` turn (spec §6.8) + +The live turn is deliberately NOT in CI (unit + host E2E drive the seam). It +gets this documented smoke, run once per machine that has Claude Code +installed and signed in (Pro/Max). The extension itself holds no credentials +(INV-8) — auth is entirely the local Claude Code login. + +## 1. Scripted smoke (the quick check) + + npm run smoke:live + +Expected: prints a `replacement:` line containing "The smoke test passed.", +the model id, a non-empty `sessionId`, and exits 0. + +## 2. In-editor smoke (the real PUC-2) + +1. `npm run build`, then F5 (Extension Development Host) opening this repo. +2. Open any markdown file in the workspace; type a sentence — it renders with + the human gutter border (left edge) as you type. +3. Select the sentence → run **“Cowriting: Ask Claude to Edit Selection”** → + instruction: `rewrite this more formally`. +4. Expected: progress notification; on completion the replacement text lands + **tinted** (Claude-attributed), the human border remains on your other + edits, and after **save** the sidecar + `.threads/.json` has an `attributions[]` entry with + `author.kind: "agent"`, `agent.model`, `agent.sessionId`, and a `turnId`. +5. **Cowriting: Toggle Attribution** hides/shows both decorations (PUC-5). + +## 3. Failure paths (INV-8 — graceful, tracking unaffected) + +- Signed out / no Claude Code: `PATH="" npm run smoke:live` (or sign out) → + the script exits 1 with a clear error; in-editor, the command shows an + error notification and NO edit is applied. +- Buffer edited mid-turn: start an edit-selection turn, type elsewhere in the + document before it completes → warning notification "document changed", no + partial application (stale `expectedVersion`, spec §6.9). + +## Smoke log + +| Date | Machine | Result | +| --- | --- | --- | +| 2026-06-10 | benstull mac (darwin) | PASS — `replacement: "The smoke test passed."`, model `sonnet`, sessionId `run_g_rYKGYl`, 6.9s, exit 0. Failure-path note: with `PATH="/usr/bin:/bin"` the turn STILL succeeded (6.2s) — the SDK resolves `claude` via an absolute path (`~/.local/bin/claude`), not PATH, so a stripped PATH does not exercise the signed-out error; sign out of Claude Code to exercise it for real. | diff --git a/esbuild.mjs b/esbuild.mjs index f427684..907c23e 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -17,11 +17,26 @@ const options = { logLevel: "info", }; +/** @type {import('esbuild').BuildOptions} */ +const liveTurnOptions = { + entryPoints: ["src/liveTurn.ts"], + outfile: "out/liveTurn.mjs", + bundle: true, + platform: "node", + format: "esm", + target: "node22", + sourcemap: true, + external: ["vscode", "@cline/sdk"], + logLevel: "info", +}; + if (watch) { const ctx = await context(options); - await ctx.watch(); + const ctxLive = await context(liveTurnOptions); + await Promise.all([ctx.watch(), ctxLive.watch()]); console.log("esbuild: watching…"); } else { await build(options); - console.log("esbuild: build complete → out/extension.cjs"); + await build(liveTurnOptions); + console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs"); } diff --git a/package.json b/package.json index 68d2112..9302ef2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }, { "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" }, - { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" } + { "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }, + { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" } ], "menus": { "commandPalette": [ @@ -59,6 +60,7 @@ "test": "vitest run", "pretest:e2e": "npm run build && 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" }, "dependencies": { diff --git a/scripts/smoke-live-turn.mjs b/scripts/smoke-live-turn.mjs new file mode 100644 index 0000000..6a230b1 --- /dev/null +++ b/scripts/smoke-live-turn.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// Scripted half of the F3 manual smoke (docs/MANUAL-SMOKE-F3.md). Drives the +// REAL LiveTurn module against the local Claude Code login. Not run in CI. +import { runEditTurn } from "../out/liveTurn.mjs"; + +const instruction = process.argv[2] ?? "Replace this sentence with exactly: The smoke test passed."; +const text = process.argv[3] ?? "This sentence is the smoke-test input."; + +console.log(`instruction: ${instruction}`); +console.log(`text: ${text}`); +try { + const t0 = Date.now(); + const result = await runEditTurn(instruction, text); + console.log(`replacement: ${JSON.stringify(result.replacement)}`); + console.log(`model: ${result.model}`); + console.log(`sessionId: ${result.sessionId}`); + console.log(`elapsed: ${((Date.now() - t0) / 1000).toFixed(1)}s`); + process.exit(0); +} catch (err) { + console.error(`live turn failed (expected when Claude Code is absent/signed out): ${err.message}`); + process.exit(1); +} diff --git a/src/extension.ts b/src/extension.ts index eb20775..de4ac71 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -81,6 +81,56 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ), ); + // F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one + // claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9). + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.editSelection", async () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selection.isEmpty || !editor.document.uri.fsPath.startsWith(root)) { + void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first."); + return; + } + const instruction = await vscode.window.showInputBox({ + prompt: "What should Claude do with the selection?", + placeHolder: "e.g. tighten this paragraph", + }); + if (!instruction) return; + const document = editor.document; + const selection = editor.selection; + const expectedVersion = document.version; + const selectedText = document.getText(selection); + const turnId = `turn-${Date.now().toString(36)}`; + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" }, + async () => { + const { runEditTurn } = await import("./liveTurn"); + const turn = await runEditTurn(instruction, selectedText); + const ok = await attributionController.applyAgentEdit( + document, + new vscode.Range(selection.start, selection.end), + turn.replacement, + { + kind: "agent", + id: "claude", + agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId }, + }, + { expectedVersion, turnId }, + ); + if (!ok) { + void vscode.window.showWarningMessage( + "Cowriting: the document changed while Claude was editing — nothing was applied.", + ); + } + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); + } + }), + ); + // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) { diff --git a/src/liveTurn.ts b/src/liveTurn.ts new file mode 100644 index 0000000..9d952d7 --- /dev/null +++ b/src/liveTurn.ts @@ -0,0 +1,57 @@ +/** + * LiveTurn — the minimal machine-edit ingress (spec §6.2): one @cline/sdk + * Agent turn on the built-in `claude-code` provider, which rides the + * operator's local Claude Code Pro/Max login — the extension holds NO + * credentials of any kind (INV-8). vscode-free; the editSelection command + * (extension.ts) feeds the result into the applyAgentEdit seam (INV-9). + * + * Like src/cline.ts: @cline/sdk is ESM-only, loaded via dynamic import(), + * never bundled (esbuild keeps it external). + */ + +export interface EditTurnResult { + replacement: string; + model: string; + /** the SDK run id — recorded as provenance sessionId. */ + sessionId: string; +} + +const SYSTEM_PROMPT = [ + "You are a precise text editor embedded in VS Code.", + "You will be given a piece of text and an instruction.", + "Respond with ONLY the edited replacement text — no explanation, no preamble,", + "no markdown code fences. Preserve the original's leading/trailing whitespace", + "unless the instruction says otherwise.", +].join(" "); + +/** Strip a wrapping markdown code fence if the model added one anyway. */ +export function extractReplacement(outputText: string): string { + const m = outputText.match(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/); + return m ? m[1] : outputText; +} + +export async function runEditTurn( + instruction: string, + selectedText: string, + opts?: { modelId?: string }, +): Promise { + const sdk = await import("@cline/sdk"); + const modelId = opts?.modelId ?? "sonnet"; + const agent = new sdk.Agent({ + providerId: "claude-code", + modelId, + systemPrompt: SYSTEM_PROMPT, + }); + const result = await agent.run( + `\n${instruction}\n\n\n${selectedText}\n`, + ); + // 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?)", + ); + } + return { replacement: extractReplacement(result.outputText), model: modelId, sessionId: result.runId }; +} diff --git a/test/liveTurn.test.ts b/test/liveTurn.test.ts new file mode 100644 index 0000000..6c1d13d --- /dev/null +++ b/test/liveTurn.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { extractReplacement } from "../src/liveTurn"; + +describe("extractReplacement", () => { + it("returns plain text untouched", () => { + expect(extractReplacement("Hello world.")).toBe("Hello world."); + }); + it("strips a wrapping fence", () => { + expect(extractReplacement("```\nHello world.\n```")).toBe("Hello world."); + }); + it("strips a language-tagged fence", () => { + expect(extractReplacement("```markdown\nHello.\n```\n")).toBe("Hello."); + }); + it("leaves inner fences alone", () => { + const inner = "before\n```js\ncode\n```\nafter"; + expect(extractReplacement(inner)).toBe(inner); + }); +});