Files

118 lines
6.4 KiB
TypeScript

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);
// Finding 2 (final whole-branch review): the machine reply flips the
// thread into an "offer" — this used to CLOBBER the "open" status token
// instead of folding in alongside it, so Resolve/Reopen disappeared from
// the menu the moment any thread got a reply. Assert both tokens survive
// together, directly via the rendered vsThread's contextValue (the exact
// string package.json's `comments/commentThread/title` `when`-clauses key
// on) — no DOM/UI query needed.
const docKey = api.proposalController.keyFor(doc);
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.ok(afterReply, "thread is rendered after the reply lands");
assert.match(
afterReply!.contextValue,
/\bopen\b/,
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
);
assert.match(
afterReply!.contextValue,
/\boffer\b/,
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
);
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
// Spending the offer flips it to "offerdone" — status token still intact,
// and the plain "offer" token must not still match (it's a DIFFERENT word,
// not just a substring: /\boffer\b/ must not match "offerdone").
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
});
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
});
// Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO
// selection used to route through `cowriting.editDocument` → the removed
// `askEditInstruction` webview prompt — a rejecting stub `void`'d before any
// try/catch, i.e. a completely silent dead end (no toast, no thread). It must now
// reach ThreadController.askClaude()'s top-anchored whole-document path (D19) —
// the same comments-first ask the selection route already used. There is no API to
// drive VS Code's native comment-widget submission from a host E2E test (the actual
// comment→reply→offer→proposal round trip is exercised above via the
// createThreadOnSelection seam), so this asserts the routing itself: the gesture
// reaches ThreadController.askClaude (never the old broken stub) and does not throw.
test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nA document-level ask with no selection.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
// Defensive stubs, mirroring the suite's other test — no real turn should fire
// from this gesture alone (no comment text is ever submitted), but guard against
// a real @cline/sdk call if the routing regresses.
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection
let askClaudeCalls = 0;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.edit");
} finally {
api.threadController.askClaude = origAskClaude;
}
assert.strictEqual(
askClaudeCalls,
1,
"cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub",
);
});
});