F3 SLICE-5: live claude-code turn via applyAgentEdit seam + manual smoke (INV-8) (#6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<doc>.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. |
|
||||||
+17
-2
@@ -17,11 +17,26 @@ const options = {
|
|||||||
logLevel: "info",
|
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) {
|
if (watch) {
|
||||||
const ctx = await context(options);
|
const ctx = await context(options);
|
||||||
await ctx.watch();
|
const ctxLive = await context(liveTurnOptions);
|
||||||
|
await Promise.all([ctx.watch(), ctxLive.watch()]);
|
||||||
console.log("esbuild: watching…");
|
console.log("esbuild: watching…");
|
||||||
} else {
|
} else {
|
||||||
await build(options);
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,8 @@
|
|||||||
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
|
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "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": {
|
"menus": {
|
||||||
"commandPalette": [
|
"commandPalette": [
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
||||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||||
|
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
|
||||||
"vscode:prepublish": "node esbuild.mjs"
|
"vscode:prepublish": "node esbuild.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
// Render threads + attributions for already-open editors, and on future opens.
|
||||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||||
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
||||||
|
|||||||
@@ -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<EditTurnResult> {
|
||||||
|
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(
|
||||||
|
`<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?)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { replacement: extractReplacement(result.outputText), model: modelId, sessionId: result.runId };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user