diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index dce78f87..286d30f4 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -143,6 +143,14 @@ import { // schema itself; the twin follows it by construction + pin. import { validateWarrantBounds, boundsRefusal } from "./warrant_bounds"; +// Issue #799 — the widget-authoring twin executes against the SAME service +// helper the core table calls (src/amicode_service/widgets.ts). The plugin +// transport is retired from the runtime config (#700 A3 — this file is the +// behavioral reference the parity test pins), so the ../src import rides the +// vitest graph where the parity test exercises it; the runtime MCP server +// bundles the core's own identical call. +import { authorWidget } from "../src/amicode_service/widgets"; + // Load line goes to STDERR, not stdout: `opencode debug config` imports plugin // modules before printing the resolved config as JSON on stdout (verified on // v1.17.3) — a stdout log here corrupts that JSON and breaks any caller that @@ -399,6 +407,137 @@ export const AmicodeTools = async (input: unknown) => { }, }, + // Issue #799 — the widget-authoring twin (the fork's registry delta ported + // into the harness-neutral floor; see the core table's entry for the full + // record). The description below is the fork's widget-author.txt CARRIED + // VERBATIM (byte-pinned to src/widget-author.txt and to the core table by + // the parity test). Bare MCP wire name: author_widget; the UI sentinel + // seam (AMICODE_WIDGET {id,name,size,height,hash,warnings}) parses the + // returned LAST line. + amicode_author_widget: { + description: `Author or update a widget on the user's Amicode home dashboard, from this conversation. + +Use this when the user asks to add, build, create, redesign, or change a home tile/widget +("add a tile showing my recent runs", "make a fidelity leaderboard", "make that bigger"). +The widget is written to the user's widget folder and a LIVE PREVIEW renders in this chat +immediately; the user clicks "Pin to dashboard" to place it on home. Calling this tool again +with the SAME \`id\` UPDATES that widget in place and the preview hot-reloads — use that to +refine after feedback rather than making a new widget. + +You supply the widget body as an ES module in \`js\`. Exact contract: + + export default { + mount: function (el, amico) { + // Build the widget's DOM inside \`el\` (its root element). Vanilla DOM only. + // Leave \`el\` empty (no child elements, no text) to signal an EMPTY STATE — + // the host then hides the tile until there is something to show. + } + } + +The \`amico\` client passed to mount: + amico.fetch(path) -> Promise ONLY these routes are allowed (anything else rejects): + /amicode/profile {ok, you:{name, stats:{problems,runs}, ...}} + /amicode/problems {ok, active, problems:[{slug,name,status,score,recorded,entity_kinds}]} + /amicode/problem?slug=.. one problem's detail + /amicode/run-status?slug=.. {ok, runs:[{run_id, status, fidelity, iteration}]} + status is solving|stalled|finished|failed; \`fidelity\` is the objective value + (~ infidelity 1-F, LOWER is better). + /amicode/run-series?run=..&lab=.. one run's per-iteration series + /amicode/run-cards shareable run cards (the showcase gallery) + /amicode/library uploaded papers + learnings + amico.action(verb, payload) -> Promise verbs: resume-session, warm-start, open-gallery, + open-external, upload-library, save-profile, lookup-institution, resolve-logo + amico.prompt(text) start a new chat turn with \`text\` (good for click-to-ask) + amico.open(entity) open an entity view + amico.config amico.context ({resume, liveRun, library}) amico.theme amico.density ('normal'|'compact'|'tight') + amico.onConfig(cb) / amico.onTheme(cb) / amico.onContext(cb) re-render hooks + +STYLE — use the host theme tokens (CSS custom properties). NEVER hard-code colors: + colors: --amc-bg --amc-layer --amc-layer2 --amc-border --amc-text --amc-text-muted + --amc-text-faint --amc-accent --amc-success --amc-warning --amc-danger + fonts: --amc-font-sans --amc-font-mono + padding: --amc-pad (hero) --amc-pad-tile (tile) +A card should fill its box: border 1px solid var(--amc-border); border-radius 10px; +background var(--amc-layer); padding var(--amc-pad-tile). Give data an UPPERCASE eyebrow +label in --amc-text-faint. Use font-variant-numeric: tabular-nums wherever digits line up. +Show physics quantities the way the user writes them (infidelity as 2.1e-4, etc.). + +SIZE: "tile" = compact card in the tile row; "hero" = wide card in the top grid. +\`height\` is the resting pixel height (the frame grows to content): tiles ~96-180, heroes ~180-320. + +Fetch is async — render a nothing/loading state first, then fill in on the promise. Handle +fetch failure by rendering an empty state (leave \`el\` empty), not an error dump. Tell the user +in one sentence what you built; the preview and Pin button appear on their own. If the tool +returns an error, fix \`js\`/the fields and call it again. +`, + args: { + id: { + type: "string", + description: "kebab-case widget id (also its folder name). Reuse an id to UPDATE that widget in place.", + }, + name: { + type: "string", + description: "human title shown in the widget header / edit controls", + }, + size: { + type: "string", + enum: ["tile", "hero"], + description: '"tile" = compact card in the tile row; "hero" = wide card in the top grid', + }, + height: { + type: "number", + description: "resting pixel height (40..2000); the frame grows to content", + }, + description: { + type: ["string", "null"], + description: "one-line description of the widget. Null for none.", + }, + js: { + type: "string", + description: "the widget.js ES module body: export default { mount: function (el, amico) { ... } }", + }, + }, + async execute(a: { + id: string; + name: string; + size: string; + height: number; + description?: string | null; + js: string; + }) { + // Same helper, same never-reject discipline: a bad field returns + // {ok:false} with a precise error — refuse honestly, write nothing. + const r = authorWidget({ + id: a.id, + name: a.name, + size: a.size, + height: a.height, + description: a.description ?? undefined, + js: a.js, + }); + if (!r.ok) { + return ( + `Widget rejected: ${a.id}. The widget was NOT written. ${r.error}. ` + + `Fix the input and call amicode_author_widget again.` + ); + } + const sentinel = JSON.stringify({ + id: r.id, + name: r.name, + size: r.size, + height: r.height, + hash: r.hash, + warnings: r.warnings, + }); + const warnLine = r.warnings.length ? ` Warnings: ${r.warnings.join("; ")}.` : ""; + return ( + `Authored widget "${r.id}" — "${r.name}" (${r.size}) ✓.${warnLine} ` + + `A live preview is shown below — the user can Pin it to their dashboard.\n` + + `AMICODE_WIDGET ${sentinel}` + ); + }, + }, + amicode_problem: { description: "Open or create the Problem workspace the design state belongs to (spec A). " + diff --git a/packages/extension/src/amicode_tools_core.ts b/packages/extension/src/amicode_tools_core.ts index 919ded21..ed185086 100644 --- a/packages/extension/src/amicode_tools_core.ts +++ b/packages/extension/src/amicode_tools_core.ts @@ -133,6 +133,14 @@ import { auditRegimePriorApplications, type CensusStamp, } from "../opencode-plugin/regime_priors"; +// Issue #799 — the widget-authoring tool executes against the extension +// service's widget server helper (src/amicode_service/widgets.ts, already +// ported): the helper is pure filesystem (manifest + widget.js under +// widgetsRoot()), which is exactly the floor's harness-neutral contract — +// the dashboard inventory (GET /amicode/widgets, /amicode/dashboard) reads +// the same directory, so a tool call lands in the inventory end-to-end +// without an HTTP hop and without touching the harness server. +import { authorWidget } from "./amicode_service/widgets"; @@ -328,7 +336,68 @@ const RYDBERG_SCOPE_NOTE = -// ── The tool table (the one source of truth) ────────────────────────────────── +// Issue #799 — the widget-authoring tool's prompt, CARRIED VERBATIM from the +// fork's packages/opencode/src/tool/widget-author.txt (copied byte-for-byte to +// ./widget-author.txt beside this module; the test suite pins the constant to +// the file — carried, never rewritten). Backticks are the only escape needed +// (the text carries no ${ and no backslash). +const WIDGET_AUTHOR_PROMPT = `Author or update a widget on the user's Amicode home dashboard, from this conversation. + +Use this when the user asks to add, build, create, redesign, or change a home tile/widget +("add a tile showing my recent runs", "make a fidelity leaderboard", "make that bigger"). +The widget is written to the user's widget folder and a LIVE PREVIEW renders in this chat +immediately; the user clicks "Pin to dashboard" to place it on home. Calling this tool again +with the SAME \`id\` UPDATES that widget in place and the preview hot-reloads — use that to +refine after feedback rather than making a new widget. + +You supply the widget body as an ES module in \`js\`. Exact contract: + + export default { + mount: function (el, amico) { + // Build the widget's DOM inside \`el\` (its root element). Vanilla DOM only. + // Leave \`el\` empty (no child elements, no text) to signal an EMPTY STATE — + // the host then hides the tile until there is something to show. + } + } + +The \`amico\` client passed to mount: + amico.fetch(path) -> Promise ONLY these routes are allowed (anything else rejects): + /amicode/profile {ok, you:{name, stats:{problems,runs}, ...}} + /amicode/problems {ok, active, problems:[{slug,name,status,score,recorded,entity_kinds}]} + /amicode/problem?slug=.. one problem's detail + /amicode/run-status?slug=.. {ok, runs:[{run_id, status, fidelity, iteration}]} + status is solving|stalled|finished|failed; \`fidelity\` is the objective value + (~ infidelity 1-F, LOWER is better). + /amicode/run-series?run=..&lab=.. one run's per-iteration series + /amicode/run-cards shareable run cards (the showcase gallery) + /amicode/library uploaded papers + learnings + amico.action(verb, payload) -> Promise verbs: resume-session, warm-start, open-gallery, + open-external, upload-library, save-profile, lookup-institution, resolve-logo + amico.prompt(text) start a new chat turn with \`text\` (good for click-to-ask) + amico.open(entity) open an entity view + amico.config amico.context ({resume, liveRun, library}) amico.theme amico.density ('normal'|'compact'|'tight') + amico.onConfig(cb) / amico.onTheme(cb) / amico.onContext(cb) re-render hooks + +STYLE — use the host theme tokens (CSS custom properties). NEVER hard-code colors: + colors: --amc-bg --amc-layer --amc-layer2 --amc-border --amc-text --amc-text-muted + --amc-text-faint --amc-accent --amc-success --amc-warning --amc-danger + fonts: --amc-font-sans --amc-font-mono + padding: --amc-pad (hero) --amc-pad-tile (tile) +A card should fill its box: border 1px solid var(--amc-border); border-radius 10px; +background var(--amc-layer); padding var(--amc-pad-tile). Give data an UPPERCASE eyebrow +label in --amc-text-faint. Use font-variant-numeric: tabular-nums wherever digits line up. +Show physics quantities the way the user writes them (infidelity as 2.1e-4, etc.). + +SIZE: "tile" = compact card in the tile row; "hero" = wide card in the top grid. +\`height\` is the resting pixel height (the frame grows to content): tiles ~96-180, heroes ~180-320. + +Fetch is async — render a nothing/loading state first, then fill in on the promise. Handle +fetch failure by rendering an empty state (leave \`el\` empty), not an error dump. Tell the user +in one sentence what you built; the preview and Pin button appear on their own. If the tool +returns an error, fix \`js\`/the fields and call it again. +`; + + export const AMICODE_TOOLS: Record = { // Capability warrant request (spec-20260727-164748 §9.5 / G-9). The CARD is the @@ -434,6 +503,94 @@ export const AMICODE_TOOLS: Record = { }, }, + // Issue #799 — the fork's widget-authoring tool (its registry delta, one of + // the two load-bearing deltas blocking fork retirement) ported into the + // harness-neutral floor. Bare MCP wire name: `author_widget` — with the + // server registered as "amicode", opencode renders `amicode_author_widget`, + // the name the UI's sentinel seam keys on (widget-preview.ts parses the + // AMICODE_WIDGET {id,name,size,height,hash,warnings} LAST line into the + // live preview + Pin card). Execution calls the extension service's widget + // server helper (authorWidget — see the import note above); the helper + // validates everything (kebab id, size, height 40..2000, mount contract), + // assembles the manifest (the model never hand-writes TOML), and writes + // under widgetsRoot() — the same directory the dashboard inventory serves. + // NOT a problem-stage tool: ungated (no guardAndRecordStage), chat-level + // authoring like amicode_profile/amicode_recommend. + amicode_author_widget: { + description: WIDGET_AUTHOR_PROMPT, + args: { + id: { + type: "string", + description: "kebab-case widget id (also its folder name). Reuse an id to UPDATE that widget in place.", + }, + name: { + type: "string", + description: "human title shown in the widget header / edit controls", + }, + size: { + type: "string", + enum: ["tile", "hero"], + description: '"tile" = compact card in the tile row; "hero" = wide card in the top grid', + }, + height: { + type: "number", + description: "resting pixel height (40..2000); the frame grows to content", + }, + description: { + type: ["string", "null"], + description: "one-line description of the widget. Null for none.", + }, + js: { + type: "string", + description: "the widget.js ES module body: export default { mount: function (el, amico) { ... } }", + }, + }, + async execute(a: { + id: string; + name: string; + size: string; + height: number; + description?: string | null; + js: string; + }) { + // Validation lives in the service helper (the same never-reject + // discipline as every service route): a bad field returns {ok:false} + // with a precise error — refuse honestly, write nothing. + const r = authorWidget({ + id: a.id, + name: a.name, + size: a.size, + height: a.height, + description: a.description ?? undefined, + js: a.js, + }); + if (!r.ok) { + return ( + `Widget rejected: ${a.id}. The widget was NOT written. ${r.error}. ` + + `Fix the input and call amicode_author_widget again.` + ); + } + // The UI sentinel (the RunWindow/AMICODE_DIFF precedent): the card + // parses this LAST line for {id, hash} to build the preview frame src. + // Re-calling with the same id overwrites in place → new content hash → + // the preview hot-reloads. + const sentinel = JSON.stringify({ + id: r.id, + name: r.name, + size: r.size, + height: r.height, + hash: r.hash, + warnings: r.warnings, + }); + const warnLine = r.warnings.length ? ` Warnings: ${r.warnings.join("; ")}.` : ""; + return ( + `Authored widget "${r.id}" — "${r.name}" (${r.size}) ✓.${warnLine} ` + + `A live preview is shown below — the user can Pin it to their dashboard.\n` + + `AMICODE_WIDGET ${sentinel}` + ); + }, + }, + amicode_problem: { description: "Open or create the Problem workspace the design state belongs to (spec A). " + diff --git a/packages/extension/src/widget-author.txt b/packages/extension/src/widget-author.txt new file mode 100644 index 00000000..f41be40d --- /dev/null +++ b/packages/extension/src/widget-author.txt @@ -0,0 +1,54 @@ +Author or update a widget on the user's Amicode home dashboard, from this conversation. + +Use this when the user asks to add, build, create, redesign, or change a home tile/widget +("add a tile showing my recent runs", "make a fidelity leaderboard", "make that bigger"). +The widget is written to the user's widget folder and a LIVE PREVIEW renders in this chat +immediately; the user clicks "Pin to dashboard" to place it on home. Calling this tool again +with the SAME `id` UPDATES that widget in place and the preview hot-reloads — use that to +refine after feedback rather than making a new widget. + +You supply the widget body as an ES module in `js`. Exact contract: + + export default { + mount: function (el, amico) { + // Build the widget's DOM inside `el` (its root element). Vanilla DOM only. + // Leave `el` empty (no child elements, no text) to signal an EMPTY STATE — + // the host then hides the tile until there is something to show. + } + } + +The `amico` client passed to mount: + amico.fetch(path) -> Promise ONLY these routes are allowed (anything else rejects): + /amicode/profile {ok, you:{name, stats:{problems,runs}, ...}} + /amicode/problems {ok, active, problems:[{slug,name,status,score,recorded,entity_kinds}]} + /amicode/problem?slug=.. one problem's detail + /amicode/run-status?slug=.. {ok, runs:[{run_id, status, fidelity, iteration}]} + status is solving|stalled|finished|failed; `fidelity` is the objective value + (~ infidelity 1-F, LOWER is better). + /amicode/run-series?run=..&lab=.. one run's per-iteration series + /amicode/run-cards shareable run cards (the showcase gallery) + /amicode/library uploaded papers + learnings + amico.action(verb, payload) -> Promise verbs: resume-session, warm-start, open-gallery, + open-external, upload-library, save-profile, lookup-institution, resolve-logo + amico.prompt(text) start a new chat turn with `text` (good for click-to-ask) + amico.open(entity) open an entity view + amico.config amico.context ({resume, liveRun, library}) amico.theme amico.density ('normal'|'compact'|'tight') + amico.onConfig(cb) / amico.onTheme(cb) / amico.onContext(cb) re-render hooks + +STYLE — use the host theme tokens (CSS custom properties). NEVER hard-code colors: + colors: --amc-bg --amc-layer --amc-layer2 --amc-border --amc-text --amc-text-muted + --amc-text-faint --amc-accent --amc-success --amc-warning --amc-danger + fonts: --amc-font-sans --amc-font-mono + padding: --amc-pad (hero) --amc-pad-tile (tile) +A card should fill its box: border 1px solid var(--amc-border); border-radius 10px; +background var(--amc-layer); padding var(--amc-pad-tile). Give data an UPPERCASE eyebrow +label in --amc-text-faint. Use font-variant-numeric: tabular-nums wherever digits line up. +Show physics quantities the way the user writes them (infidelity as 2.1e-4, etc.). + +SIZE: "tile" = compact card in the tile row; "hero" = wide card in the top grid. +`height` is the resting pixel height (the frame grows to content): tiles ~96-180, heroes ~180-320. + +Fetch is async — render a nothing/loading state first, then fill in on the promise. Handle +fetch failure by rendering an empty state (leave `el` empty), not an error dump. Tell the user +in one sentence what you built; the preview and Pin button appear on their own. If the tool +returns an error, fix `js`/the fields and call it again. diff --git a/packages/extension/test/amicode_tools_core.test.ts b/packages/extension/test/amicode_tools_core.test.ts index 2e8e66dc..8813f7ab 100644 --- a/packages/extension/test/amicode_tools_core.test.ts +++ b/packages/extension/test/amicode_tools_core.test.ts @@ -27,6 +27,7 @@ const PLUGIN = await import("../opencode-plugin/amicode_tools"); const EXPECTED_TOOLS = [ "amicode_request_approval", "amicode_ask", + "amicode_author_widget", "amicode_problem", "amicode_pick_system", "amicode_set_model", @@ -69,6 +70,137 @@ describe("AMICODE_TOOLS (the core tool table)", () => { }); }); +// Issue #799 — the widget-authoring tool (the fork's registry delta ported +// into the harness-neutral floor). The prompt is CARRIED from the fork's +// widget-author.txt (never rewritten), execution drives the extension +// service's widget server helper end-to-end, and the returned LAST line is +// the AMICODE_WIDGET sentinel the UI's preview card parses. +describe("amicode_author_widget (issue #799)", () => { + const def = () => CORE.AMICODE_TOOLS["amicode_author_widget"] as AmicodeToolDef; + + it("the description is the fork's widget-author.txt, carried byte-for-byte", async () => { + const { readFileSync } = await import("node:fs"); + const carried = readFileSync(join(__dirname, "..", "src", "widget-author.txt"), "utf8"); + expect(carried.length).toBeGreaterThan(1000); + expect(def().description).toBe(carried); + }); + + it("the schema is fork-equivalent (id/name/size/height/description/js, tile|hero enum)", () => { + const args = def().args as Record; + expect(Object.keys(args).sort()).toEqual(["description", "height", "id", "js", "name", "size"]); + expect(args.size.enum).toEqual(["tile", "hero"]); + expect(args.id.description).toMatch(/UPDATE that widget in place/); + expect(args.js.description).toMatch(/export default \{ mount/); + }); + + it("a tool call drives the service helper end-to-end: the widget lands in the dashboard inventory", async () => { + const { mkdtempSync, existsSync, readFileSync } = await import("node:fs"); + const userDir = mkdtempSync(join(tmpdir(), "amico-widgets-799-")); + const saved = process.env.AMICODE_WIDGETS_DIR; + process.env.AMICODE_WIDGETS_DIR = userDir; + try { + const out = await def().execute( + { + id: "fidelity-leaderboard", + name: "Fidelity leaderboard", + size: "hero", + height: 220, + description: "Best F per problem", + js: "export default { mount: function (el, amico) { el.textContent = 'hi' } }", + }, + { carrier: "mcp" }, + ); + // the tool return carries the UI sentinel as its LAST line + const lines = out.split("\n"); + expect(lines[lines.length - 1]).toMatch(/^AMICODE_WIDGET /); + const sentinel = JSON.parse(lines[lines.length - 1].slice("AMICODE_WIDGET ".length)); + expect(sentinel).toMatchObject({ id: "fidelity-leaderboard", name: "Fidelity leaderboard", size: "hero", height: 220 }); + expect(typeof sentinel.hash).toBe("string"); + expect(sentinel.hash.length).toBeGreaterThan(0); + expect(Array.isArray(sentinel.warnings)).toBe(true); + expect(out).toMatch(/Pin it to their dashboard/); + // the helper wrote the widget under the widgets root + expect(existsSync(join(userDir, "fidelity-leaderboard", "manifest.toml"))).toBe(true); + expect(existsSync(join(userDir, "fidelity-leaderboard", "widget.js"))).toBe(true); + expect(readFileSync(join(userDir, "fidelity-leaderboard", "widget.js"), "utf8")).toContain("mount: function"); + + // dashboard inventory end-to-end: the extension service's widget route + // (which reads the same registry) now serves the authored widget. (The + // dashboard LAYOUT deliberately keeps user widgets opt-in until the + // user Pins — the tool's contract ends at the registry inventory.) + const { createAmicodeService } = await import("../src/amicode_service"); + const service = createAmicodeService(); + const base = (await service.start()).toString().replace(/\/$/, ""); + try { + const headers = { Authorization: service.authHeader }; + const w = await (await fetch(`${base}/amicode/widgets`, { headers })).json(); + const entry = (w.widgets as Array<{ id: string; builtin: boolean; hash: string }>).find( + (x) => x.id === "fidelity-leaderboard", + ); + expect(entry, "GET /amicode/widgets serves the authored widget").toBeTruthy(); + expect(entry!.builtin).toBe(false); + expect(entry!.hash).toBe(sentinel.hash); + } finally { + await service.stop(); + } + } finally { + if (saved === undefined) delete process.env.AMICODE_WIDGETS_DIR; + else process.env.AMICODE_WIDGETS_DIR = saved; + } + }); + + it("re-calling with the SAME id updates in place (new content → new hash)", async () => { + const { mkdtempSync, readFileSync } = await import("node:fs"); + const userDir = mkdtempSync(join(tmpdir(), "amico-widgets-799b-")); + const saved = process.env.AMICODE_WIDGETS_DIR; + process.env.AMICODE_WIDGETS_DIR = userDir; + try { + const call = (js: string) => + def().execute( + { id: "my-tile", name: "My tile", size: "tile", height: 120, description: null, js }, + {}, + ); + const first = await call("export default { mount: function (el) { el.textContent = 'v1' } }"); + const second = await call("export default { mount: function (el) { el.textContent = 'v2' } }"); + const h1 = JSON.parse(first.split("\n").pop()!.slice("AMICODE_WIDGET ".length)).hash; + const h2 = JSON.parse(second.split("\n").pop()!.slice("AMICODE_WIDGET ".length)).hash; + expect(h1).not.toBe(h2); + expect(readFileSync(join(userDir, "my-tile", "widget.js"), "utf8")).toContain("'v2'"); + } finally { + if (saved === undefined) delete process.env.AMICODE_WIDGETS_DIR; + else process.env.AMICODE_WIDGETS_DIR = saved; + } + }); + + it("REFUSES a bad widget honestly — nothing is written, the error names the field", async () => { + const { mkdtempSync, existsSync, readdirSync } = await import("node:fs"); + const userDir = mkdtempSync(join(tmpdir(), "amico-widgets-799c-")); + const saved = process.env.AMICODE_WIDGETS_DIR; + process.env.AMICODE_WIDGETS_DIR = userDir; + try { + const out = await def().execute( + { id: "Not_Kebab", name: "X", size: "tile", height: 120, description: null, js: "export default {}" }, + {}, + ); + expect(out).toMatch(/Widget rejected/); + expect(out).toMatch(/The widget was NOT written/); + expect(out).toMatch(/bad_id/); + expect(existsSync(join(userDir, "Not_Kebab"))).toBe(false); + expect(readdirSync(userDir)).toEqual([]); + // and a missing mount contract is refused too (the helper's js gate) + const out2 = await def().execute( + { id: "ok-id", name: "X", size: "tile", height: 120, description: null, js: "export default {}" }, + {}, + ); + expect(out2).toMatch(/bad_js/); + expect(existsSync(join(userDir, "ok-id"))).toBe(false); + } finally { + if (saved === undefined) delete process.env.AMICODE_WIDGETS_DIR; + else process.env.AMICODE_WIDGETS_DIR = saved; + } + }); +}); + // SEAM 6 (#703) — the tool-surface half of the autonomy datum: the // amicode_request_approval tool's `bounds` are validated against the SCHEMA // PACKAGE's warrant-bounds enum (@amicode/schema's $defs.bounds via diff --git a/packages/extension/test/mcp_amico_parity.test.ts b/packages/extension/test/mcp_amico_parity.test.ts index fb4fbc24..73bbc851 100644 --- a/packages/extension/test/mcp_amico_parity.test.ts +++ b/packages/extension/test/mcp_amico_parity.test.ts @@ -45,7 +45,7 @@ describe("MCP tools/list ≡ the plugin's registrations (drift guard)", () => { expect(mcp.sort()).toEqual([...core].sort().map((n: string) => CORE.mcpBareName(n))); }); - it("the PRODUCT-IDENTICAL view: opencode's rendered MCP name ≡ the plugin's registered name, for all 17", () => { + it("the PRODUCT-IDENTICAL view: opencode's rendered MCP name ≡ the plugin's registered name, for all 18", () => { const canonical = Object.keys(CORE.AMICODE_TOOLS); const rendered = SERVER.listAmicodeMcpTools().map((t: McpTool) => `${OPENCODE_SERVER_NAME}_${t.name}`); // bijective: what opencode renders after namespacing the bare names IS the diff --git a/packages/extension/test/mcp_amico_roundtrip.test.ts b/packages/extension/test/mcp_amico_roundtrip.test.ts index 24b2eefd..beba2758 100644 --- a/packages/extension/test/mcp_amico_roundtrip.test.ts +++ b/packages/extension/test/mcp_amico_roundtrip.test.ts @@ -82,7 +82,8 @@ describe("MCP round-trip against the spawned server", () => { const names = tools.map((t) => t.name); expect(names).toContain("pick_system"); // bare: the client (opencode) namespaces by server expect(names).not.toContain("amicode_pick_system"); // the server does not double-prefix - expect(names.length).toBeGreaterThanOrEqual(17); + expect(names).toContain("author_widget"); // #799: the widget-authoring tool rides the floor + expect(names.length).toBeGreaterThanOrEqual(18); }); it("tools/call pick_system records the SAME entity, events, and return as the plugin path", async () => {