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

Cards: a words field so the app card can Tell and the decision card can Redirect, and a test of the shell's drawing #1885

Merged opened this

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

Merged

scout/forge:scout/1535-cards-a-words-field-so-the-app-card-can → main 4 commits fork

  • Merged. scout/1535-cards-a-words-field-so-the-app-card-can was merged into main as f74b5e7.

Review

Sign in to review this change.
6 files changed +125 −7
docs/MCP.md +6 −1
@@ -171,7 +171,12 @@ answers
171171 tool result carries, with `textContent` only. The model is built in
172172 `src/web/studio/cards.ts`. A card's
173173 buttons ask the host to call a tool (`asks_act`, `say`, `stop`, `tell`); the
174 host decides, and every call is checked as any other.
174 host decides, and every call is checked as any other. A card with a
175 words action shows one text field: **Tell <app>** (app card) sends the
176 words to `tell` as `text` with `repo` and `context.said`; **Redirect**
177 (decision card) sends them to `say` with the first ask's `repo`, `number`
178 and `text`. Empty words send nothing. `asks` rows carry `repo`
179 (`owner/name`) beside `number` for this.
175180 - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes
176181 the manifest (`here`) names, plus `POST /mcp`. `test/openapi.test.ts` fails
177182 when the manifest names a route it lacks.
scripts/gate.ts +1
@@ -137,6 +137,7 @@ export const GATE: readonly string[] = [
137137 "test/planner-declined.test.ts",
138138 "test/feed.test.ts",
139139 "test/feed-page.test.ts",
140 "test/card-shell.test.ts",
140141 "test/offline.test.ts",
141142 "test/share.test.ts",
142143 "test/cloud.test.ts",
src/web/studio/asks.ts +3
@@ -687,6 +687,8 @@ export function askLinesFor(
687687 line: string;
688688 kind: DecisionKind;
689689 repoId: string;
690 /** The app, as owner/name: what `say` and `tell` take as `repo`. */
691 repo: string;
690692 /** Where a person decides it: the pull or issue, or the row on the home. */
691693 link: string;
692694 /** The pull's or issue's number, for a row of one that is about one. */
@@ -704,6 +706,7 @@ export function askLinesFor(
704706 id: row.id,
705707 kind: row.kind,
706708 repoId: row.repo.id,
709 repo: `${row.repo.ownerName}/${row.repo.name}`,
707710 items: row.items.map((c) => c.id),
708711 words: ASK_WORDS[row.kind],
709712 consequence: row.consequence,
src/web/studio/cards.ts +24 −4
@@ -20,6 +20,8 @@ export interface CardAction {
2020 /** The tool the host calls when it is pressed, and with what. */
2121 readonly tool: string;
2222 readonly args: Record<string, unknown>;
23 /** Set when the press sends the person's typed words as `text` (and into `context.said`, if the args carry a `context`). */
24 readonly words?: true;
2325 }
2426 export interface Card {
2527 readonly kind: CardKind;
@@ -73,6 +75,9 @@ export function cardFor(tool: string, value: unknown): Card | undefined {
7375 Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : ""
7476 }`,
7577 });
78 for (const a of apps.slice(0, 3))
79 if (text(a["app"]) !== "")
80 actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true });
7681 } else if (kind === "run") {
7782 const runs = list(value).map(obj);
7883 status = runs.length === 0 ? "nothing running" : `${runs.length} going`;
@@ -100,11 +105,15 @@ export function cardFor(tool: string, value: unknown): Card | undefined {
100105 const items = list(first["items"]).map(text);
101106 const kindOf = text(first["kind"]);
102107 // No `dryRun`: the press is the dry run `asks_act` defaults to, and the
103 // host shows what would change before anything ships or closes. Tell and
104 // Redirect are absent: the card has no field for the person's words.
108 // host shows what would change before anything ships or closes. Redirect
109 // sends the typed words to `say` on the row's own pull or issue.
105110 if (kindOf === "look" || kindOf === "review")
106111 actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } });
107112 actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } });
113 const repo = text(first["repo"]);
114 const number = Number(first["number"]);
115 if (repo !== "" && Number.isInteger(number) && number > 0)
116 actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true });
108117 }
109118 } else {
110119 const limbs = list(obj(value)["limbs"]).map(obj);
@@ -125,7 +134,7 @@ export function cardFor(tool: string, value: unknown): Card | undefined {
125134 * structured content and draws it with `textContent`. A button asks the
126135 * host to call the tool; the host decides whether to.
127136 */
128 const SHELL_JS = `
137 export const SHELL_JS = `
129138 const root = document.getElementById("card");
130139 const el = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls) e.className = cls; return e; };
131140 let n = 0;
@@ -134,9 +143,20 @@ function draw(card) {
134143 const dl = document.createElement("dl");
135144 for (const r of card.rows) dl.append(el("dt", r.label), el("dd", r.value));
136145 root.append(dl);
146 const field = card.actions.some((a) => a.words) ? document.createElement("input") : null;
147 if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); }
137148 for (const a of card.actions) {
138149 const b = el("button", a.label);
139 b.onclick = () => parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: a.args } }, "*");
150 b.onclick = () => {
151 let args = a.args;
152 if (a.words) {
153 const said = field.value.trim();
154 if (said === "") { field.focus(); return; }
155 args = { ...a.args, text: said };
156 if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said };
157 }
158 parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*");
159 };
140160 root.append(b);
141161 }
142162 }
test/card-shell.test.ts added +88
@@ -0,0 +1,88 @@
1 import { describe, expect, test } from "bun:test";
2 import { SHELL_JS, cardFor } from "../src/web/studio/cards.ts";
3
4 class Node {
5 children: Node[] = [];
6 textContent = "";
7 className = "";
8 value = "";
9 onclick: (() => void) | undefined;
10 focused = false;
11 constructor(readonly tag: string) {}
12 append(...n: Node[]) { this.children.push(...n); }
13 replaceChildren(...n: Node[]) { this.children = n; }
14 setAttribute() {}
15 focus() { this.focused = true; }
16 all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; }
17 }
18
19 function shell() {
20 const root = new Node("div");
21 const posted: any[] = [];
22 const parent = { postMessage: (m: unknown) => posted.push(m) };
23 let listener: (e: any) => void = () => {};
24 const document = { getElementById: () => root, createElement: (t: string) => new Node(t) };
25 new Function("document", "parent", "addEventListener", SHELL_JS)(
26 document, parent, (_: string, f: (e: any) => void) => (listener = f),
27 );
28 const send = (data: unknown, source: unknown = parent) => listener({ source, data });
29 const result = (card: unknown) =>
30 ({ method: "ui/notifications/tool-result", params: { structuredContent: { card } } });
31 return { root, posted, parent, send, result };
32 }
33
34 describe("the card shell", () => {
35 test("announces itself, draws with textContent, ignores other sources, posts tools/call", () => {
36 const s = shell();
37 expect(s.posted[0].method).toBe("ui/notifications/initialized");
38 const card = {
39 kind: "run", title: "<b>T</b>", status: "ok",
40 rows: [{ label: "<img src=x onerror=1>", value: "v" }],
41 actions: [{ label: "Stop", tool: "stop", args: { repo: "a/b", run: "r1" } }],
42 };
43 s.send(s.result(card), {});
44 expect(s.root.children.length).toBe(0);
45 s.send(s.result(card));
46 const nodes = s.root.all();
47 expect(nodes.find((n) => n.tag === "h2")!.textContent).toBe("<b>T</b>");
48 expect(nodes.find((n) => n.tag === "dt")!.textContent).toBe("<img src=x onerror=1>");
49 nodes.find((n) => n.tag === "button")!.onclick!();
50 expect(s.posted[1]).toMatchObject({
51 method: "tools/call",
52 params: { name: "stop", arguments: { repo: "a/b", run: "r1" } },
53 });
54 });
55
56 test("Tell and Redirect send the typed words, and nothing when empty", () => {
57 const s = shell();
58 const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!;
59 s.send(s.result(app));
60 const input = s.root.all().find((n) => n.tag === "input")!;
61 const tell = s.root.all().find((n) => n.tag === "button")!;
62 tell.onclick!();
63 expect(s.posted.length).toBe(1);
64 expect(input.focused).toBe(true);
65 input.value = " make it blue ";
66 tell.onclick!();
67 expect(s.posted[1].params).toEqual({
68 name: "tell",
69 arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" },
70 });
71 const ask = cardFor("asks", {
72 rows: [{ line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 4, words: {} }],
73 })!;
74 s.send(s.result(ask));
75 const redirect = s.root.all().find((n) => n.tag === "button" && n.textContent === "Redirect")!;
76 s.root.all().find((n) => n.tag === "input")!.value = "not like that";
77 redirect.onclick!();
78 expect(s.posted[2].params).toEqual({
79 name: "say",
80 arguments: { repo: "alice/game", number: 4, text: "not like that" },
81 });
82 });
83
84 test("an ask without a number offers no Redirect", () => {
85 const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!;
86 expect(card.actions.some((a) => a.tool === "say")).toBe(false);
87 });
88 });
test/mcp.test.ts +3 −2
@@ -591,7 +591,7 @@ describe("the forge over MCP", () => {
591591 cardFor("now", [{ who: "scout", app: "alice/game", run: "r1", on: "#1", state: "going" }]),
592592 cardFor("asks", {
593593 rows: [
594 { line: "a", kind: "look", items: ["x:1"], words: {} },
594 { line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 3, words: {} },
595595 { line: "b", kind: "review", items: ["x:2"], words: {} },
596596 ],
597597 }),
@@ -602,7 +602,8 @@ describe("the forge over MCP", () => {
602602 actions++;
603603 expect(a.args["dryRun"]).not.toBe(false);
604604 const t = tools.find((x: any) => x.name === a.tool);
605 for (const f of t.inputSchema.required ?? []) {
605 // A words action gets `text` from the shell's field when pressed.
606 for (const f of (t.inputSchema.required ?? []).filter((f: string) => !(a.words && f === "text"))) {
606607 expect(a.args[f]).toBeDefined();
607608 expect(a.args[f]).not.toBe("");
608609 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs