Public: anyone can read this; the crew can change it.

The MCP answers the decisions waiting for the owner as a ui:// card with ship and decline on it #11852

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11816-the-mcp-answers-the-decisions-waiting-fo → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/11816-the-mcp-answers-the-decisions-waiting-fo was merged into main as f04c3dc.

Review

Sign in to review this change.
4 files changed +175 −2
docs/MCP.md +1 −1
@@ -518,7 +518,7 @@ answers
518518 | --- | --- | --- |
519519 | `ui://forge/app` | `apps`, `new_app` | Your apps, or the app just made: live or not, what is working, a Tell box. |
520520 | `ui://forge/run` | — | Legacy running-now list; `now` draws `crew` instead. |
521 | `ui://forge/asks` | `asks`, `asks_act` | One row per waiting decision with its button; after a press, the receipt. |
521 | `ui://forge/asks` | `asks`, `asks_act` | One row per waiting decision with its button; a press plans, the Confirm card (the page's own, e.g. "Close 1") does it, then the receipt. |
522522 | `ui://forge/wave` | `waves` | One wave: headline, highlights with their why, pull numbers. |
523523 | `ui://forge/map` | `map` | The plan: per pillar ready, working and waiting counts, the first ready issues. |
524524 | `ui://forge/feed` | `feed` | The newest timeline items, at most 20, each linked; how many are new. |
src/http/mcp/tools/asks_act.ts +4 −1
@@ -49,7 +49,10 @@ export const tool: Tool = {
4949 }
5050 if (args["dryRun"] !== false) {
5151 const plan = previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]);
52 return plan.ok ? good(plan.value) : bad(plan.error.code, plan.error.message);
52 // The card's Confirm button re-sends these with `dryRun: false`: the page's own two steps.
53 return plan.ok
54 ? { ...good(plan.value), card: { action: args["action"], items: args["items"], reason: args["reason"] } }
55 : bad(plan.error.code, plan.error.message);
5356 }
5457 const done = await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]);
5558 return done.ok ? good({ ...done.value, receipt: receiptOf(done.value) }) : bad(done.error.code, done.error.message);
src/web/studio/cards.ts +26
@@ -9,6 +9,7 @@
99
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
12 import { VERB_WORDS } from "../../actions/asks_words.ts";
1213 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1314
1415 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity";
@@ -415,6 +416,31 @@ export function cardFor(
415416 // A plan (`dryRun`) or a `listen` has no `receipt`.
416417 const v = obj(value);
417418 const operation = text(v["id"]);
419 // A plan (`dryRun`) is the page's confirm: what each ask would become, and the one button that does it.
420 if (text(v["receipt"]) === "" && obj(v["counts"])["change"] !== undefined && Array.isArray(v["rows"])) {
421 const verb = VERB_WORDS[text(extra["action"])] ?? text(extra["action"]);
422 const items = list(extra["items"]).map(text).filter((i) => i !== "").slice(0, 100); // asks_act accepts at most 100
423 const change = Number(obj(v["counts"])["change"]);
424 const skipped = list(v["skipped"]).map(obj);
425 const shown = (x: unknown): string => (x === null || x === undefined ? "nobody" : Array.isArray(x) ? x.map(text).join(", ") : text(x));
426 for (const r of list(v["rows"]).map(obj).slice(0, LIMIT))
427 rows.push({ label: text(r["label"]), value: `${shown(r["before"])} → ${shown(r["after"])}${text(r["reason"]) === "" ? "" : ` · ${text(r["reason"])}`} · ${text(r["outcome"])}` });
428 for (const s of skipped.slice(0, LIMIT)) rows.push({ label: text(s["item"]), value: `${text(s["reason"])} · skipped` });
429 const refused = (Number(obj(v["counts"])["refused"]) || 0) + skipped.length;
430 if (change > 0 && items.length > 0)
431 actions.push({

Number(counts.refused) is NaN if the field is missing, which would print 'NaN refused or skipped'. Default it to 0. The skipped-only and reason paths in this block have no test (see review body).

432 label: `${verb} ${change}`,
433 tool: "asks_act",
434 args: { action: text(extra["action"]), items, ...(text(extra["reason"]) === "" ? {} : { reason: text(extra["reason"]) }), dryRun: false },
435 });
436 return {
437 kind,

Forwards every item the caller sent, uncapped and unvalidated, into the button args. Slice to 100 (asks_act's limit) and drop empty strings; add a test.

438 title: "Confirm",
439 status: `${verb}: ${change} of ${items.length} · ${refused} refused or skipped · ${v["reversible"] === true ? "undo afterwards" : "not undone afterwards"}`,
440 rows,
441 actions,
442 };
443 }
418444 if (text(v["receipt"]) === "" || operation === "") return undefined;
419445 rows.push({ label: "Receipt", value: text(v["receipt"]) });
420446 if (v["undoOf"] == null && v["undoneBy"] == null)
test/mcp-asks-card-decide.test.ts added +144
@@ -0,0 +1,144 @@
1 /**
2 * The inbox card decides (#11816): `asks` lists what waits, the card's Decline
3 * is a plan whose card carries the page's confirm, pressing it settles with
4 * the page's receipt, and the page then shows the result. The token's scope
5 * is the only authority: a read-only token plans but cannot confirm.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { receiptSentence } from "../src/actions/asks_words.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { getIssueById, setAssignee } from "../src/core/issues.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dataDir = "";
24 let app: App;
25 let servers: Servers;
26 let rw = "";
27 let ro = "";
28 let cookie = "";
29 let issueId = "";
30 let owner: any;
31 let repoRow: any;
32
33 const rpc = async (token: string, name: string, args: Record<string, unknown>) => {
34 const res = await fetch(`${servers.url}/mcp`, {
35 method: "POST",
36 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
37 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
38 });
39 return (await res.json()) as any;
40 };
41 const page = async (): Promise<string> =>
42 (await (await fetch(`${servers.url}/asks`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text()).replace(/\s+/g, " ");
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-asks-card-"));
46 app = createApp({ dataDir, port: 0 });
47 servers = await serve(app);
48 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-card-passphrase", adminIfFirst: true });
49 if (!made.ok) throw new Error(made.error);
50 rw = createToken(app.db, made.value.id, "rw", "rw").token;
51 ro = createToken(app.db, made.value.id, "ro", "ro").token;
52 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-long-card-passphrase" }), redirect: "manual" });
53 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
54 const repo = await createRepo(app, made.value, { name: "inbox", visibility: "public" });
55 if (!repo.ok) throw new Error(String(repo.error));
56 const issue = openIssue(app, made.value, repo.value, { title: "Wipe the board", body: "" });
57 if (!issue.ok) throw new Error(String(issue.error));
58 setAssignee(app.db, issue.value.id, made.value.id);
59 issueId = issue.value.id;
60 owner = made.value;
61 repoRow = repo.value;
62 }, 30_000);
63
64 afterAll(async () => {
65 await servers.stop();
66 app.close();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 test("list, plan, confirm from the card, and the page shows the result", async () => {
71 const listed = await rpc(rw, "asks", {});
72 const row = listed.result.structuredContent.card.rows.find((r: any) => r.actions?.[0]?.args?.items?.includes(`assigned:${issueId}`));
73 expect(row.actions[0].label).toBe("Decline");
74 expect(await page()).toContain("Wipe the board");
75
76 // Pressing Decline plans: nothing changes, and the card is the page's confirm.
77 const press = row.actions[0];
78 const planned = await rpc(rw, press.tool, press.args);
79 expect(getIssueById(app.db, issueId)!.state).toBe("open");
80 const confirm = planned.result.structuredContent.card;
81 expect(confirm.title).toBe("Confirm");
82 expect(confirm.status).toBe("Close: 1 of 1 · 0 refused or skipped · undo afterwards");
83 expect(confirm.rows[0].value).toContain("change");
84 expect(confirm.actions.map((a: any) => a.label)).toEqual(["Close 1"]);
85 expect(confirm.actions[0].args).toEqual({ action: "close", items: [`assigned:${issueId}`], dryRun: false });
86
87 // A read-only token may plan, but the confirm is refused: the card widens nothing.
88 const refused = await rpc(ro, confirm.actions[0].tool, confirm.actions[0].args);
89 expect(refused.result.isError).toBe(true);
90 expect(getIssueById(app.db, issueId)!.state).toBe("open");
91
92 // Confirming returns the page's receipt and the page shows it.
93 const done = await rpc(rw, confirm.actions[0].tool, confirm.actions[0].args);
94 const receipt = receiptSentence("close", 1, false);
95 expect(JSON.parse(done.result.content[0].text).receipt).toBe(receipt);
96 expect(done.result.structuredContent.card.status).toBe(receipt);
97 expect(getIssueById(app.db, issueId)!.state).toBe("closed");
98 expect(await page()).not.toContain("Wipe the board");
99 });
100
101 test("review: reason, nothing-changes, ok:false, the 100 cap, and the page's words", async () => {
102 const fresh = openIssue(app, owner, repoRow, { title: "Second chore", body: "" });
103 if (!fresh.ok) throw new Error(String(fresh.error));
104 setAssignee(app.db, fresh.value.id, owner.id);
105 issueId = fresh.value.id;
106 // Box 5: the card's row says what the page says for the same ask.
107 const listed = await rpc(rw, "asks", {});
108 const row = listed.result.structuredContent.card.rows.find((r: any) => r.actions?.[0]?.args?.items?.includes(`assigned:${issueId}`));
109 const html = await page();
110 expect(row.label).toContain("Second chore");
111 expect(html).toContain("Second chore");
112 expect(row.label.toLowerCase()).toContain("assigned to you");
113 expect(html).toContain("Assigned to you");
114
115 // Box 1: a reason rides on the Confirm args.
116 const withReason = await rpc(rw, "asks_act", { action: "close", items: [`assigned:${issueId}`], reason: "not now" });
117 const c1 = withReason.result.structuredContent.card;
118 expect(c1.actions[0].args).toEqual({ action: "close", items: [`assigned:${issueId}`], reason: "not now", dryRun: false });
119
120 // Box 2: nothing changes -> skipped rows, the count, no button.
121 const none = await rpc(rw, "asks_act", { action: "close", items: ["assigned:nosuchask"] });
122 const c2 = none.result.structuredContent.card;
123 expect(c2.actions).toEqual([]);
124 expect(c2.status).toMatch(/^Close: 0 of 1 · [1-9]\d* refused or skipped · /);
125 expect(c2.status).not.toContain("NaN");
126 expect(c2.rows.length).toBeGreaterThan(0);
127
128 // Box 3: a refused plan returns the error and no card.
129 const bad = await rpc(rw, "asks_act", { action: "close", items: [] });
130 expect(bad.result.isError).toBe(true);
131 expect(bad.result.structuredContent?.card).toBeUndefined();
132
133 // Box 4: the confirm hands back at most 100 items, none empty.
134 const { cardFor } = await import("../src/web/studio/cards.ts");
135 const many = Array.from({ length: 150 }, (_, n) => (n % 10 === 0 ? "" : `assigned:x${n}`));
136 const plan = { counts: { change: 1, refused: 0 }, rows: [{ label: "a", before: "x", after: "y", outcome: "change" }], skipped: [], reversible: true };
137 const capped = cardFor("asks_act", plan, "", { action: "close", items: many })!;
138 const sent = capped.actions[0]!.args!["items"] as string[];
139 expect(sent.length).toBe(100);
140 expect(sent.includes("")).toBe(false);
141 // A missing `refused` count never prints NaN.
142 const noCount = cardFor("asks_act", { ...plan, counts: { change: 1 } }, "", { action: "close", items: ["assigned:a"] })!;
143 expect(noCount.status).toContain("0 refused or skipped");
144 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs