Public: anyone can read this; the crew can change it.
`asks` answers with a ui://forge/asks card: each decision waiting on the owner as one line with its one action #11133
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11121-asks-answers-with-a-ui-forge-asks-card-e → main 6 commits fork 3 threads · 3 unresolved
6 files
changed +104
−75
docs/MCP.md +12 −8
| @@ -57,8 +57,8 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 57 | 57 | | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 | |
| 58 | 58 | | `archive` | — | Archive or wake a repository (`archived`) | 332 | |
| 59 | 59 | | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 | |
| 60 | | `asks` | — | What waits on you, most important first; saved filter applies | 457 | | |
| 61 | | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 539 | | |
| 60 | | `asks` | — | What waits on you, most important first; saved filter applies | 453 | | |
| 61 | | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 535 | | |
| 62 | 62 | | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 | |
| 63 | 63 | | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 | |
| 64 | 64 | | `asks_undecide` | — | Put a set-aside card back on your board, within a day | 208 | |
| @@ -484,9 +484,15 @@ answers | ||
| 484 | 484 | |
| 485 | 485 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `releases`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` and `asked` name a `ui://` resource in |
| 486 | 486 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 487 | `decision`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with | |
| 487 | `asks`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with | |
| 488 | 488 | its ready, working and waiting counts, and the first five ready issues linked |
| 489 | to their pages; it has no actions. The decision card's rows link to the pull or issue | |
| 489 | to their pages; it has no actions. The asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision: | |
| 490 | what, which repository, how long it has waited, and its one button on the row itself. | |
| 491 | **Ship** is for a green or agent change, **Decide** for one parked issue and **Decline** otherwise. | |
| 492 | A proposal, a pause and a row of several parked issues have no button. | |
| 493 | The buttons call `asks_act` or `asks_decide`, which check the same permission as the page's buttons. | |
| 494 | Your saved filter applies to it as to the answer; an empty inbox is one line, `Nothing waits on you.` | |
| 495 | The rows link to the pull or issue | |
| 490 | 496 | they are about (a paused repository's row has none). |
| 491 | 497 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 492 | 498 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| @@ -500,10 +506,8 @@ answers | ||
| 500 | 506 | buttons ask the host to call a tool (`asks_act`, `say`, `stop`, `tell`); the |
| 501 | 507 | host decides, and every call is checked as any other. A card with a |
| 502 | 508 | words action shows one text field: **Tell <app>** (app card) sends the |
| 503 | words to `tell` as `text` with `repo` and `context.said`; **Redirect** | |
| 504 | (decision card) sends them to `say` with the first ask's `repo`, `number` | |
| 505 | and `text`. Empty words send nothing. `asks` rows carry `repo` | |
| 506 | (`owner/name`) beside `number` for this. | |
| 509 | words to `tell` as `text` with `repo` and `context.said`. | |
| 510 | Empty words send nothing. | |
| 507 | 511 | - **The manifest's shape.** `GET /api/v1/here?path=/owner/name` (or the |
| 508 | 512 | `here` tool with the page URL) answers `{ here, you, can, mcp, docs }`. |
| 509 | 513 | `you.as` is `anonymous`, `reader`, `writer`, `owner` or `agent`; `can` is |
src/web/studio/asks.ts +3
| @@ -1078,6 +1078,8 @@ export function askLinesFor( | ||
| 1078 | 1078 | /** For a wave proposal: what it says, as plain text (the reader escapes it). */ |
| 1079 | 1079 | proposal?: { id: string; title: string; why: string; issues: string[] }; |
| 1080 | 1080 | items: string[]; |
| 1081 | /** When the oldest ask in the row began to wait (ISO), for how long it has waited. */ | |
| 1082 | since: string; | |
| 1081 | 1083 | /** The decision, its options and what happens if nobody acts (`ASK_WORDS`). */ |
| 1082 | 1084 | words: AskWords; |
| 1083 | 1085 | /** Why it ranks where it does; rows come most consequential first. */ |
| @@ -1098,6 +1100,7 @@ export function askLinesFor( | ||
| 1098 | 1100 | repoId: row.repo.id, |
| 1099 | 1101 | repo: `${row.repo.ownerName}/${row.repo.name}`, |
| 1100 | 1102 | items: row.items.map((c) => c.id), |
| 1103 | since: row.items.reduce((a, c) => (c.since < a ? c.since : a), row.items[0]!.since), | |
| 1101 | 1104 | words: ASK_WORDS[row.kind], |
| 1102 | 1105 | consequence: row.consequence, |
| 1103 | 1106 | waitingFor: row.items[0]!.waiting, |
src/web/studio/cards.ts +43 −40
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release"; | |
| 14 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -20,6 +20,8 @@ export interface CardRow { | ||
| 20 | 20 | readonly link?: string; |
| 21 | 21 | /** How many steps the row is nested (a tree): the card pads the label by this much. */ |
| 22 | 22 | readonly depth?: number; |
| 23 | /** The row's own buttons: what the person can do with this one row. */ | |
| 24 | readonly actions?: readonly CardAction[]; | |
| 23 | 25 | } |
| 24 | 26 | export interface CardAction { |
| 25 | 27 | readonly label: string; |
| @@ -42,17 +44,17 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 42 | 44 | |
| 43 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 44 | 46 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"]; | |
| 47 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"]; | |
| 46 | 48 | |
| 47 | 49 | /** The tool whose answer each card draws. */ |
| 48 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| 49 | 51 | apps: "app", |
| 50 | 52 | now: "crew", |
| 51 | 53 | runs: "run", |
| 52 | asks: "decision", | |
| 54 | asks: "asks", | |
| 53 | 55 | milestones: "milestones", |
| 54 | 56 | releases: "release", |
| 55 | asks_act: "decision", | |
| 57 | asks_act: "asks", | |
| 56 | 58 | waves: "wave", |
| 57 | 59 | map: "map", |
| 58 | 60 | feed: "feed", |
| @@ -71,7 +73,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 71 | 73 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| 72 | 74 | app: "Apps", |
| 73 | 75 | run: "Working now", |
| 74 | decision: "Waiting on you", | |
| 76 | asks: "Waiting on you", | |
| 75 | 77 | wave: "Wave", |
| 76 | 78 | map: "Plan", |
| 77 | 79 | feed: "Feed", |
| @@ -448,39 +450,36 @@ export function cardFor( | ||
| 448 | 450 | if (r["run"] && actions.length < 3) |
| 449 | 451 | actions.push({ label: `Stop ${text(r["who"])}`, tool: "stop", args: { repo: text(r["app"]), run: text(r["run"]) } }); |
| 450 | 452 | } |
| 451 | } else if (kind === "decision") { | |
| 453 | } else if (kind === "asks") { | |
| 452 | 454 | const asks = list(obj(value)["rows"]).map(obj); |
| 453 | status = asks.length === 0 ? "nothing waits" : `${asks.length} waiting`; | |
| 455 | status = asks.length === 0 ? "Nothing waits on you." : `${asks.length} waiting`; | |
| 454 | 456 | for (const a of asks.slice(0, LIMIT)) { |
| 455 | 457 | const words = obj(a["words"]); |
| 456 | 458 | const repo = text(a["repo"]); |
| 457 | 459 | const number = Number(a["number"]); |
| 458 | 460 | const page = a["numberOf"] === "pull" ? "pulls" : a["numberOf"] === "issue" ? "issues" : ""; |
| 459 | 461 | const at = text(a["link"]); |
| 462 | const waited = text(a["since"]) === "" ? "" : `waiting ${timeAgo(text(a["since"]))}`.replace(" ago", ""); | |
| 463 | const what = `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(); | |
| 464 | const items = list(a["items"]).map(text); | |
| 465 | const kindOf = text(a["kind"]); | |
| 466 | // No `dryRun`: the press is the dry run `asks_act` defaults to, and the | |
| 467 | // host shows what would change before anything ships or closes. The | |
| 468 | // tools check the same permission the page's buttons do. | |
| 469 | const one: CardAction | undefined = | |
| 470 | items.length === 0 || kindOf === "proposal" || kindOf === "paused" ? undefined | |
| 471 | : kindOf === "look" || kindOf === "review" ? { label: "Ship", tool: "asks_act", args: { action: "ship", items } } | |
| 472 | : kindOf === "parked" ? (items.length === 1 ? { label: "Decide", tool: "asks_decide", args: { item: items[0] } } : undefined) | |
| 473 | : { label: "Decline", tool: "asks_act", args: { action: "close", items } }; | |
| 460 | 474 | rows.push({ |
| 461 | 475 | label: text(a["line"]), |
| 462 | value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(), | |
| 476 | value: [waited, what].filter((x) => x !== "").join(" · "), | |
| 463 | 477 | ...(web(origin) && slugOf(repo) && Number.isInteger(number) && number > 0 && page !== "" |
| 464 | 478 | ? { link: `${origin}/${repo}/${page}/${number}` } |
| 465 | 479 | : web(origin) && here(origin, at) ? { link: origin + at } : {}), |
| 480 | ...(one === undefined ? {} : { actions: [one] }), | |
| 466 | 481 | }); |
| 467 | 482 | } |
| 468 | const first = asks[0]; | |
| 469 | if (first !== undefined) { | |
| 470 | const items = list(first["items"]).map(text); | |
| 471 | const kindOf = text(first["kind"]); | |
| 472 | // No `dryRun`: the press is the dry run `asks_act` defaults to, and the | |
| 473 | // host shows what would change before anything ships or closes. Redirect | |
| 474 | // sends the typed words to `say` on the row's own pull or issue. | |
| 475 | if (kindOf === "look" || kindOf === "review") | |
| 476 | actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } }); | |
| 477 | actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } }); | |
| 478 | actions.push({ label: "Dismiss", tool: "asks_act", args: { action: "dismiss", items } }); | |
| 479 | const repo = text(first["repo"]); | |
| 480 | const number = Number(first["number"]); | |
| 481 | if (repo !== "" && Number.isInteger(number) && number > 0) | |
| 482 | actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true }); | |
| 483 | } | |
| 484 | 483 | } else if (kind === "map") { |
| 485 | 484 | const v = obj(value); |
| 486 | 485 | const issues = list(v["issues"]).map(obj); |
| @@ -577,6 +576,21 @@ export const SHELL_JS = ` | ||
| 577 | 576 | const root = document.getElementById("card"); |
| 578 | 577 | const el = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls) e.className = cls; return e; }; |
| 579 | 578 | let n = 0; |
| 579 | let field = null; | |
| 580 | function button(a) { | |
| 581 | const b = el("button", a.label); | |
| 582 | b.onclick = () => { | |
| 583 | let args = a.args; | |
| 584 | if (a.words) { | |
| 585 | const said = field.value.trim(); | |
| 586 | if (said === "") { field.focus(); return; } | |
| 587 | args = { ...a.args, text: said }; | |
| 588 | if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said }; | |
| 589 | } | |
| 590 | parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*"); | |
| 591 | }; | |
| 592 | return b; | |
| 593 | } | |
| 580 | 594 | function draw(card) { |
| 581 | 595 | root.replaceChildren(el("h2", card.title), el("p", card.status, "status")); |
| 582 | 596 | const dl = document.createElement("dl"); |
| @@ -584,25 +598,14 @@ function draw(card) { | ||
| 584 | 598 | const dt = el("dt", r.label); |
| 585 | 599 | if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em"; |
| 586 | 600 | if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); } |
| 587 | dl.append(dt, el("dd", r.value)); | |
| 601 | const dd = el("dd", r.value); | |
| 602 | for (const a of r.actions || []) dd.append(button(a)); | |
| 603 | dl.append(dt, dd); | |
| 588 | 604 | } |
| 589 | 605 | root.append(dl); |
| 590 | const field = card.actions.some((a) => a.words) ? document.createElement("input") : null; | |
| 606 | field = card.actions.some((a) => a.words) ? document.createElement("input") : null; | |
| 591 | 607 | if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); } |
| 592 | for (const a of card.actions) { | |
| 593 | const b = el("button", a.label); | |
| 594 | b.onclick = () => { | |
| 595 | let args = a.args; | |
| 596 | if (a.words) { | |
| 597 | const said = field.value.trim(); | |
| 598 | if (said === "") { field.focus(); return; } | |
| 599 | args = { ...a.args, text: said }; | |
| 600 | if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said }; | |
| 601 | } | |
| 602 | parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*"); | |
| 603 | }; | |
| 604 | root.append(b); | |
| 605 | } | |
| 608 | for (const a of card.actions) root.append(button(a)); | |
| 606 | 609 | } |
| 607 | 610 | addEventListener("message", (e) => { |
| 608 | 611 | if (e.source !== parent) return; |
test/card-shell.test.ts +15 −15
| @@ -57,7 +57,7 @@ describe("the card shell", () => { | ||
| 57 | 57 | }); |
| 58 | 58 | }); |
| 59 | 59 | |
| 60 | test("Tell and Redirect send the typed words, and nothing when empty", () => { | |
| 60 | test("Tell sends the typed words, and nothing when empty", () => { | |
| 61 | 61 | const s = shell(); |
| 62 | 62 | const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!; |
| 63 | 63 | s.send(s.result(app)); |
| @@ -72,22 +72,22 @@ describe("the card shell", () => { | ||
| 72 | 72 | name: "tell", |
| 73 | 73 | arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" }, |
| 74 | 74 | }); |
| 75 | const ask = cardFor("asks", { | |
| 76 | rows: [{ line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 4, words: {} }], | |
| 77 | })!; | |
| 78 | s.send(s.result(ask)); | |
| 79 | const redirect = s.root.all().find((n) => n.tag === "button" && n.textContent === "Redirect")!; | |
| 80 | s.root.all().find((n) => n.tag === "input")!.value = "not like that"; | |
| 81 | redirect.onclick!(); | |
| 82 | expect(s.posted[2].params).toEqual({ | |
| 83 | name: "say", | |
| 84 | arguments: { repo: "alice/game", number: 4, text: "not like that" }, | |
| 85 | }); | |
| 86 | 75 | }); |
| 87 | 76 | |
| 88 | test("an ask without a number offers no Redirect", () => { | |
| 89 | const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!; | |
| 90 | expect(card.actions.some((a) => a.tool === "say")).toBe(false); | |
| 77 | test("an ask whose words are markup reaches the page only as text, and a collapsed parked row has no button", () => { | |
| 78 | const s = shell(); | |
| 79 | const evil = "<script>alert(1)</script>"; | |
| 80 | const row = (kind: string, items: string[], line: string) => | |
| 81 | ({ line, kind, items, repo: "a/b", number: 1, numberOf: "issue", words: { decision: evil, options: [evil] } }); | |
| 82 | s.send(s.result(cardFor("asks", { rows: [row("parked", ["p:1"], evil), row("parked", ["p:1", "p:2"], "many"), row("look", ["l:1"], "l")] })!)); | |
| 83 | const nodes = s.root.all(); | |
| 84 | const dts = nodes.filter((n) => n.tag === "dt"); | |
| 85 | expect(dts[0]!.textContent).toBe(evil); | |
| 86 | expect(nodes.filter((n) => n.tag === "dd")[0]!.textContent).toContain(evil); | |
| 87 | // Only createElement'd text nodes: no node is a script, and no tag carries the markup. | |
| 88 | expect(nodes.some((n) => n.tag === "script")).toBe(false); | |
| 89 | expect(nodes.filter((n) => n.tag === "button").map((b) => b.textContent)).toEqual(["Decide", "Ship"]); | |
| 90 | expect(nodes.some((n) => n.tag === "input")).toBe(false); | |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | test("the map draws a Plan card from what mapJson returns: one count per issue, agreeing everywhere", () => { |
test/mcp-asks-now-milestones-cards.test.ts +25 −6
| @@ -12,7 +12,7 @@ import { claimRun, createRun } from "../src/core/runs.ts"; | ||
| 12 | 12 | import { createToken } from "../src/core/tokens.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | import { CARD_URI, cardFor } from "../src/web/studio/cards.ts"; | |
| 15 | import { CARD_SHELL, CARD_URI, SHELL_JS, cardFor } from "../src/web/studio/cards.ts"; | |
| 16 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | 17 | import { seedMilestones } from "./fixtures/milestones-seed.ts"; |
| 18 | 18 | import { listVia } from "./mcp-list.ts"; |
| @@ -68,24 +68,43 @@ describe("MCP asks, now and milestones cards", () => { | ||
| 68 | 68 | const body = await call("asks"); |
| 69 | 69 | const text = JSON.parse(body.result.content[0].text); |
| 70 | 70 | const card = body.result.structuredContent.card; |
| 71 | expect(CARD_URI(card.kind)).toBe("ui://forge/decision"); | |
| 71 | expect(CARD_URI(card.kind)).toBe("ui://forge/asks"); | |
| 72 | 72 | expect(card.title).toBe("Waiting on you"); |
| 73 | 73 | expect(card.rows.length).toBe(text.rows.length); |
| 74 | 74 | expect(card.rows.map((r: any) => r.label)).toEqual(text.rows.map((r: any) => r.line)); |
| 75 | 75 | expect(card.rows.some((r: any) => r.link === `${servers.url}/alice/game/issues/${text.rows.find((x: any) => x.numberOf === "issue")?.number}`)).toBe(true); |
| 76 | 76 | rooted(servers.url, card.rows); |
| 77 | const act = card.actions.filter((a: any) => a.tool === "asks_act"); | |
| 78 | expect(act.map((a: any) => a.args.action)).toEqual(expect.arrayContaining(["close", "dismiss"])); | |
| 79 | for (const a of act) expect(a.args.items).toEqual(text.rows[0].items); | |
| 77 | // Each row carries its one action, on its own items. | |
| 78 | for (const [i, r] of card.rows.entries()) { | |
| 79 | if (r.actions === undefined) continue; | |
| 80 | expect(r.actions.length).toBe(1); | |
| 81 | const args = r.actions[0].args; | |
| 82 | expect(args.items ?? [args.item]).toEqual(r.actions[0].tool === "asks_decide" ? [text.rows[i].items[0]] : text.rows[i].items); | |
| 83 | } | |
| 84 | expect(card.rows.some((r: any) => r.actions?.[0]?.tool === "asks_act")).toBe(true); | |
| 85 | expect(card.rows[0].value).toMatch(/^waiting /); | |
| 80 | 86 | // The tools/list entry names the card. |
| 81 | 87 | const list = await listVia(async (p) => (await fetch(`${servers.url}/mcp`, { method: "POST", |
| 82 | 88 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, |
| 83 | 89 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: p }) })).json()); |
| 84 | 90 | const by = (n: string) => list.result.tools.find((t: any) => t.name === n)?._meta?.ui?.resourceUri; |
| 85 | expect(by("asks")).toBe("ui://forge/decision"); | |
| 91 | expect(by("asks")).toBe("ui://forge/asks"); | |
| 86 | 92 | expect(by("milestones")).toBe("ui://forge/milestones"); |
| 87 | 93 | }); |
| 88 | 94 | |
| 95 | test("asks card: parked is Decide, an empty inbox is one calm line, and nothing in it is markup", () => { | |
| 96 | const row = (kind: string, items: string[], line: string) => ({ line, kind, items, repo: "a/b", number: 1, numberOf: "issue", since: "2020-01-01T00:00:00Z", words: { decision: "d", options: ["x"] } }); | |
| 97 | const card = cardFor("asks", { rows: [row("parked", ["parked:i1"], "<script>alert(1)</script>"), row("look", ["look:p1"], "l"), row("stopped", ["stopped:p2"], "s")] }, "https://forge.test")!; | |
| 98 | expect(card.rows.map((r) => r.actions?.[0]?.label)).toEqual(["Decide", "Ship", "Decline"]); | |
| 99 | expect(card.rows[0]!.actions![0]).toEqual({ label: "Decide", tool: "asks_decide", args: { item: "parked:i1" } }); | |
| 100 | expect(card.rows[0]!.label).toBe("<script>alert(1)</script>"); | |
| 101 | expect(SHELL_JS).not.toMatch(/innerHTML|insertAdjacentHTML|outerHTML/); | |
| 102 | expect(CARD_SHELL("asks")).not.toContain("<script>alert"); | |
| 103 | const empty = cardFor("asks", { rows: [] }, "https://forge.test")!; | |
| 104 | expect(empty.rows).toEqual([]); | |
| 105 | expect(empty.status).toBe("Nothing waits on you."); | |
| 106 | }); | |
| 107 | ||
| 89 | 108 | test("asks_act: a done action's receipt carries Undo with its operation; a plan has no card", () => { |
| 90 | 109 | expect(cardFor("asks_act", { wouldDismiss: ["x"] }, "https://forge.test")).toBeUndefined(); |
| 91 | 110 | const done = cardFor("asks_act", { id: "op1", receipt: "Dismissed 2 asks", undoOf: null, undoneBy: null }, "https://forge.test")!; |
test/mcp.test.ts +6 −6
| @@ -1287,9 +1287,9 @@ describe("the forge over MCP", () => { | ||
| 1287 | 1287 | expect(uris).toEqual([ |
| 1288 | 1288 | "ui://forge/app", |
| 1289 | 1289 | "ui://forge/asked", |
| 1290 | "ui://forge/asks", | |
| 1290 | 1291 | "ui://forge/board", |
| 1291 | 1292 | "ui://forge/crew", |
| 1292 | "ui://forge/decision", | |
| 1293 | 1293 | "ui://forge/explore", |
| 1294 | 1294 | "ui://forge/feed", |
| 1295 | 1295 | "ui://forge/flow", |
| @@ -1316,9 +1316,9 @@ describe("the forge over MCP", () => { | ||
| 1316 | 1316 | expect(meta("apps")).toBe("ui://forge/app"); |
| 1317 | 1317 | expect(meta("new_app")).toBe("ui://forge/app"); |
| 1318 | 1318 | expect(meta("now")).toBe("ui://forge/crew"); |
| 1319 | expect(meta("asks")).toBe("ui://forge/decision"); | |
| 1319 | expect(meta("asks")).toBe("ui://forge/asks"); | |
| 1320 | 1320 | expect(meta("milestones")).toBe("ui://forge/milestones"); |
| 1321 | expect(meta("asks_act")).toBe("ui://forge/decision"); | |
| 1321 | expect(meta("asks_act")).toBe("ui://forge/asks"); | |
| 1322 | 1322 | expect(meta("map")).toBe("ui://forge/map"); |
| 1323 | 1323 | expect(meta("boards")).toBe("ui://forge/board"); |
| 1324 | 1324 | expect(meta("feed")).toBe("ui://forge/feed"); |
| @@ -1433,7 +1433,7 @@ describe("the forge over MCP", () => { | ||
| 1433 | 1433 | expect(cardFor("issue", {}, "https://forge.test", { repo: "alice/game" })).toBeUndefined(); |
| 1434 | 1434 | }); |
| 1435 | 1435 | |
| 1436 | test("the decision card links each ask to its pull or issue, and to nothing unsafe", async () => { | |
| 1436 | test("the asks card links each ask to its pull or issue, and to nothing unsafe", async () => { | |
| 1437 | 1437 | const { cardFor } = await import("../src/web/studio/cards.ts"); |
| 1438 | 1438 | const words = { decision: "Ship it?", options: ["ship", "close"] }; |
| 1439 | 1439 | const rows = [ |
| @@ -1529,7 +1529,7 @@ describe("the forge over MCP", () => { | ||
| 1529 | 1529 | expect(card.kind).toBe("app"); |
| 1530 | 1530 | expect(card.rows.some((r: any) => r.label === "alice/game")).toBe(true); |
| 1531 | 1531 | const asks = await call(rw, "tools/call", { name: "asks", arguments: {} }); |
| 1532 | expect(asks.body.result.structuredContent.card.kind).toBe("decision"); | |
| 1532 | expect(asks.body.result.structuredContent.card.kind).toBe("asks"); | |
| 1533 | 1533 | }); |
| 1534 | 1534 | |
| 1535 | 1535 | test("ship and close card actions never apply at once and always meet the target tool's required fields", async () => { |
| @@ -1547,7 +1547,7 @@ describe("the forge over MCP", () => { | ||
| 1547 | 1547 | ]; |
| 1548 | 1548 | let actions = 0; |
| 1549 | 1549 | for (const card of cards) |
| 1550 | for (const a of card!.actions) { | |
| 1550 | for (const a of [...card!.actions, ...card!.rows.flatMap((r) => r.actions ?? [])]) { | |
| 1551 | 1551 | actions++; |
| 1552 | 1552 | expect(a.args["dryRun"]).not.toBe(false); |
| 1553 | 1553 | const t = tools.find((x: any) => x.name === a.tool); |
This does not render the card. A shell that wrote the title into the page would still pass. Render the shell and assert the title arrives only as text.