feat: comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8, PUC-8); sunset the input webview (spec §6.10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 08:48:20 -07:00
parent 8fbbe451ea
commit 75f4a3cc20
10 changed files with 324 additions and 268 deletions
+6
View File
@@ -42,6 +42,12 @@ suite("coediting registry (PUC-7)", () => {
// rendered thread (not the sidecar-backed data); re-entering restores it.
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
const api = await activateApi();
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
// thread turn once the doc is coediting — stub it off (not under test
// here; this suite asserts render/hide/restore, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("coediting suite: reply-loop turn stubbed off");
});
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
const ed = await vscode.window.showTextDocument(doc);
// not entered → no thread creation
+47
View File
@@ -0,0 +1,47 @@
import * as assert from "assert";
import * as vscode from "vscode";
import { activateApi, settleUntil } from "./helpers";
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
// workspace-folder dependency.
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
api.threadController.setTurnRunnerForTest(async () => ({
replacement: "I would tighten this sentence.",
model: "stub",
sessionId: "s1",
}));
api.editFlow.setEditTurnForTest(async () => ({
replacement: "The quick fox jumps the lazy dog.",
model: "stub",
sessionId: "s1",
}));
ed.selection = new vscode.Selection(2, 0, 2, 20);
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
// reply-loop fires on the human message; wait for the machine message to persist
await settleUntil(() => {
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
assert.ok(ids.length >= 1);
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
});
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
await vscode.window.showTextDocument(doc);
const id = await api.threadController.createThreadOnSelection("hello");
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
});
});
+7
View File
@@ -57,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
const DOC = "docs/crossrung.md";
const doc = await open(DOC);
const api = await getApi();
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
// turn now — stub it off so it can't race the forge stand-in's reply
// below (this test asserts an EXACT 2-message sequence: editor note +
// forge reply).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("crossrung suite: reply-loop turn stubbed off");
});
const target = "portable record";
const start = doc.getText().indexOf(target);
const editor = vscode.window.activeTextEditor!;
+6
View File
@@ -41,6 +41,12 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const api = await getApi();
// Task 6 (D10): createThreadOnSelection (used later in this test) also
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
// asserts the programmatic propose/accept seam, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
});
const key = uri.toString();
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
+13
View File
@@ -41,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
return api;
}
// Task 6 (D10): every human comment on a coedited doc now also fires the
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
// that loop and asserts exact message sequences unrelated to it — stub the
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
// extra machine message lands in the sidecar this suite's assertions read).
function stubReplyLoop(api: CowritingApi): void {
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
});
}
// Reaches the controller's internal docs map for reply/resolve handlers (which
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
// assert Comments-controller state" fallback.
@@ -67,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("create on selection persists a thread sidecar", async () => {
const doc = await openSample();
const api = await getApi();
stubReplyLoop(api);
const start = doc.getText().indexOf("quick brown fox");
const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
@@ -89,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("reply appends and resolve flips status, both persisted", async () => {
const api = await getApi();
stubReplyLoop(api);
const art0 = readSidecar();
const threadId = art0.threads[0].id;