Official

market

The plugin marketplace: browse, install through an agent's scan, update and publish plugins, and the marketplace tools of agents in the Plugins workspace.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/market@0.1.0

Permissions in 0.1.0

  • Marketplace marketOfficial onlyUses the marketplace APIs. Only official plugins can have it.Search, read, install, update and publish marketplace plugins, and sign in to the marketplace
  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.Ask for the enable card of a plugin an agent installed
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Open the chat in the Plugins workspace where an agent scans, installs or updates a plugin
  • Provide agent tools tools.provideMediumGives tools to agents.Give agents in the Plugins workspace the marketplace tools
  • Read chats chats.readMediumReads your transcripts and chat lists.Find the agent, model and effort last used in the Plugins workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the marketplace button, with its update count, and the marketplace pageSlots: railcenter

Files

market.test.ts55.7 KB
// The marketplace plugin against fakes: its tools, its install, update
// and report flows, and its views. Run with
// `node --import ./testing/register.mjs --test plugins/market/market.test.ts`.
import assert from "node:assert/strict";
import { test } from "node:test";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import * as z from "zod";
import { ui } from "convergence";
import type {
  Api,
  AppState,
  AppStatePatch,
  HostEvent,
  HostMethod,
  HostParams,
  HostResult,
  RenderResult,
  SlotOptions,
  ToolContext,
  ViewContext,
} from "convergence";
import { App, Host, Notify, hostError } from "convergence/effect";
import type { EffectTool, PluginServices } from "convergence/effect";
import { testLayer } from "convergence/effect/testing";
import { activate, ROUTE } from "./main.ts";
import { CHOICE_KEY, DIRECT_KEY, defaultChoice, install, installDirect, update } from "./flows.ts";
import {
  attachFixTool,
  bugReportTool,
  installTool,
  publishTool,
  readTool,
  reportTool,
  requestEnableTool,
  searchTool,
} from "./tools.ts";
import {
  badgeCount,
  effortChoices,
  listingUrl,
  optionChanges,
  parseSpec,
  permissionRows,
  scopeText,
  resolveChoice,
  runsAnything,
  scanPrompt,
  siteOrigin,
  updatePrompt,
  checkedAttachments,
  FIX_NOTE,
  fixPrompt,
  suggestedPlugins,
  languageOf,
} from "./logic.ts";
import type { Listing } from "./remote.ts";

const REGISTRY = "https://api.divergence.archo.dev";
const WORKSPACE = { workspaceId: "w-plugins", path: "/Users/me/plugins" };

const OPTIONS = {
  codex: [
    {
      id: "model",
      name: "Model",
      category: "model",
      kind: "select",
      value: "gpt-5",
      choices: [
        {
          value: "gpt-5",
          name: "GPT-5",
          reasoningLevels: [
            { value: "low", name: "Low" },
            { value: "high", name: "High" },
          ],
        },
        { value: "gpt-5-mini", name: "GPT-5 mini", reasoningLevels: [{ value: "medium", name: "Medium" }] },
      ],
    },
    { id: "effort", name: "Effort", category: "reasoning", kind: "select", value: "low", choices: [] },
    { id: "mode", name: "Mode", category: "mode", kind: "select", value: "default", choices: [] },
  ],
  claude: [
    {
      id: "model",
      name: "Model",
      category: "model",
      kind: "select",
      value: "sonnet",
      choices: [
        { value: "sonnet", name: "Sonnet" },
        { value: "opus", name: "Opus" },
      ],
    },
    {
      id: "thinking",
      name: "Effort",
      category: "reasoning",
      kind: "select",
      value: "medium",
      choices: [
        { value: "medium", name: "Medium" },
        { value: "max", name: "Max" },
      ],
    },
  ],
};

const LISTING = {
  id: "alice/pr-tools",
  name: "pr-tools",
  description: "Pull requests in the sidebar.",
  publisher: { id: "p1", name: "alice", displayName: "Alice", verified: false },
  visibility: "public",
  latestVersion: "1.3.0",
  downloads: 1234,
  rating: { average: 4.5, count: 4 },
  createdAt: "2026-01-02T00:00:00Z",
  basedOn: null,
  mods: [{ id: "bob/pr-tools-plus", name: "pr-tools-plus", publisher: "bob" }],
  permissions: {
    net: { hosts: ["api.github.com"], reason: "Load pull requests" },
    "ui.slots": { slots: ["right"], reason: "Show the panel" },
  },
  versions: [
    {
      version: "1.3.0",
      permissions: { net: { hosts: ["api.github.com"], reason: "Load pull requests" } },
      yanked: false,
      malicious: false,
      publishedAt: "2026-02-01",
    },
    { version: "1.2.0", permissions: {}, yanked: true, malicious: false, publishedAt: "2026-01-02" },
  ],
  installed: null,
} as const;

// A fake `api`: every host call is recorded as `[method, params]` and
// answered from `answers` (a value, a function of the params, or an Error).
function fakeApi(answers: Record<string, unknown> = {}) {
  const calls: [string, unknown][] = [];
  const storage = new Map<string, unknown>(Object.entries((answers.storage ?? {}) as Record<string, unknown>));
  const listeners = new Map<string, ((event: HostEvent) => unknown)[]>();
  const views: Record<string, { render: (ctx: ViewContext<object>) => RenderResult; updates: number }> = {};
  const tools = new Map<
    string,
    { name: string; scope?: string; description?: string; inputSchema?: { type?: unknown } }
  >();
  // Selected chats reassign, so the outer api exposes the holder rather than
  // the copy a spread would take.
  const selectedHolder: { current: [string, string] | null } = { current: null };
  const api = {
    calls,
    storage,
    listeners,
    views,
    toolMap: tools,
    notices: [] as [string, string][],
    opened: [] as string[],
    images: [] as string[],
  };
  const routeState: AppState = { route: "chat", shared: {} };
  const appListeners = new Set<(state: AppState) => void>();
  const updateApp = (patch: AppStatePatch): void => {
    Object.assign(routeState, {
      ...patch,
      workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? routeState.workspaceId),
      chatId: patch.chatId === null ? undefined : (patch.chatId ?? routeState.chatId),
      route: patch.route ?? routeState.route,
      shared: { ...routeState.shared, ...patch.shared },
    });
    for (const listener of appListeners) listener(routeState);
  };
  const defaults: Record<string, unknown> = {
    "host/market.workspace": WORKSPACE,
    "host/market.whoami": { signedIn: false, registry: REGISTRY },
    "host/market.search": { results: [LISTING], page: 1, pageSize: 20, total: 1 },
    "host/market.listing": LISTING,
    "host/market.updates": { updates: [] },
    "host/agents.list": {
      agents: [
        { id: "codex", name: "Codex" },
        { id: "claude", name: "Claude" },
      ],
    },
    "host/agents.options": (params: { agentId: string }) => ({
      options: (OPTIONS as Record<string, unknown[]>)[params.agentId] ?? [],
    }),
    "host/chats.list": { chats: [] },
    "host/chats.create": (params: { agentId: string; title: string }) => ({
      id: "chat-1",
      workspaceId: WORKSPACE.workspaceId,
      agentId: params.agentId,
      title: params.title,
    }),
    "host/chats.options": () => ({ options: OPTIONS.codex }),
    "host/chats.set_option": {},
    "host/chats.send": {},
    "host/plugins.request_enable": { requestId: "req-1" },
  };
  const call = async (method: string, params: unknown): Promise<unknown> => {
    calls.push([method, params]);
    const answer = method in answers ? answers[method] : defaults[method];
    if (answer instanceof Error) throw answer;
    const value = typeof answer === "function" ? (answer as (params: unknown) => unknown)(params) : answer;
    return structuredClone(value ?? {});
  };
  // This fake stands in for the whole plugin host: untyped answers stand in
  // for the host's JSON, and missing APIs fail loudly.
  const full = {
    plugin: "market",
    id: "convergence/market",
    ui,
    host: {
      call,
      kernel: call,
      storage: {
        get: async ({ key }: { key: string }) => ({
          value: storage.has(key) ? structuredClone(storage.get(key)) : null,
        }),
        set: async ({ key, value }: { key: string; value: unknown }) => {
          storage.set(key, structuredClone(value));
          return {};
        },
      },
    },
    app: {
      state: () => routeState,
      update: updateApp,
      share: (key: string, value: unknown) => updateApp({ shared: { [key]: value } }),
      select: (chatId: string, workspaceId: string) => {
        selectedHolder.current = [chatId, workspaceId];
        updateApp({ route: "chat" });
      },
      route: (name: string) => updateApp({ route: name }),
      onChange: (listener: (state: AppState) => void) => {
        appListeners.add(listener);
        return () => {
          appListeners.delete(listener);
        };
      },
    },
    notify: (message: string, level = "info") => {
      api.notices.push([level, message]);
    },
    openUrl: async (url: string) => {
      api.opened.push(url);
      return { opened: true };
    },
    pickImages: async () => ({ paths: (answers.pickedImages ?? []) as string[] }),
    on: (event: string, fn: (event: HostEvent) => unknown) => {
      const list = listeners.get(event) ?? [];
      list.push(fn);
      listeners.set(event, list);
      return () => {};
    },
    emit: async (event: string, payload: Record<string, unknown>) => {
      for (const fn of listeners.get(event) ?? []) await fn({ event, ...payload } as HostEvent);
    },
    slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
      const handle = {
        render,
        updates: 0,
        update() {
          handle.updates++;
        },
      };
      views[slot] = handle;
      return handle;
    },
    commands: {} as Record<string, { run: () => unknown }>,
    command: (command: { id: string; run: () => unknown }) => {
      full.commands[command.id] = command;
      return { remove() {} };
    },
    tools: {
      register: (tool: { name: string; scope?: string; description?: string; inputSchema?: { type?: unknown } }) => {
        tools.set(tool.name, tool);
        return { remove() {} };
      },
    },
    focus: (_id: string) => {},
    settings: {
      get: (_key?: string) => null,
      onChange: () => () => {},
    },
    fs: {},
    net: {},
    process: {},
    env: {},
    services: {},
    methods: () => calls.map(([method]) => method),
    callsOf: (method: string) => calls.filter(([m]) => m === method).map(([, params]) => params),
  };
  return {
    ...api,
    ...full,
    routeState,
    updateApp,
    // Selected chats reassign, so expose the holder rather than the copy spread above.
    get selected() {
      return selectedHolder.current;
    },
  };
}

const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
async function settled(): Promise<void> {
  for (let i = 0; i < 40; i++) await settle();
}
async function waitFor(condition: () => boolean, what: string): Promise<void> {
  for (let i = 0; i < 200; i++) {
    if (condition()) return;
    await settle();
  }
  throw new Error(`timed out waiting for ${what}`);
}
function nodes(tree: unknown, out: Record<string, unknown>[] = []): Record<string, unknown>[] {
  if (!tree || typeof tree !== "object") return out;
  if (Array.isArray(tree)) {
    for (const child of tree) nodes(child, out);
    return out;
  }
  const node = tree as Record<string, unknown>;
  out.push(node);
  nodes(node.children, out);
  nodes(node.items, out);
  return out;
}
const byId = (tree: unknown, id: string): Record<string, unknown> & { onClick: () => unknown } => {
  const node = nodes(tree).find((candidate) => candidate.id === id);
  assert.ok(node, `Missing ${id}`);
  return node as Record<string, unknown> & { onClick: () => unknown };
};

// --- logic -----------------------------------------------------------------

test("permissions are listed riskiest first, with a broad file scope rated high and a reserved one blocked", () => {
  const rows = permissionRows({
    "ui.slots": { slots: ["right"], reason: "Panel" },
    "fs.read": { scopes: ["~/**"], reason: "Read everything" },
    "process.any": { reason: "Run tools" },
    market: { reason: "Marketplace" },
    net: { hosts: ["api.github.com"], reason: "Load PRs" },
  });
  assert.deepEqual(
    rows.map((row) => [row.permission, row.risk]),
    [
      ["process.any", "dangerous"],
      ["fs.read", "high"],
      ["net", "medium"],
      ["ui.slots", "low"],
      ["market", "reserved"],
    ],
  );
  assert.deepEqual(rows[2]?.scopes, ["api.github.com"]);
  assert.equal(rows.at(-1)?.blocked, true);
  assert.equal(permissionRows({ market: { reason: "x" } }, { official: true })[0]?.blocked, false);
  assert.equal(permissionRows({ "fs.read": { scopes: ["~/notes/*.md"], reason: "x" } })[0]?.risk, "medium");
  assert.equal(runsAnything({ "native.binaries": { reason: "x" } }), true);
  assert.equal(runsAnything({ net: {} }), false);
});

// MARKETPLACE.md §15.3 rule 6: a scope a setting fills shows the setting
// and its value now, not the template.
test("a scope a setting fills reads as the setting and its value", () => {
  const settings = {
    properties: {
      serverUrl: { type: "url", title: "API server", default: "https://API.github.com/v3" },
      cacheDir: { type: "path", kind: "folder" },
      binary: { type: "path", title: "OpenCode binary" },
    },
  };
  const permissions = {
    net: { hosts: ["${settings.serverUrl}", "api.example.com"], reason: "Talk to your API server" },
    "fs.read": { scopes: ["${settings.cacheDir}"], reason: "Cache" },
    process: { programs: ["${settings.binary}"], reason: "Run it" },
  };
  const rows = permissionRows(permissions, { describe: true, settings });
  const scopes = Object.fromEntries(rows.map((row) => [row.permission, row.scopes]));
  assert.deepEqual(scopes.net, ["the host in the setting API server (now api.github.com)", "api.example.com"]);
  assert.deepEqual(scopes["fs.read"], ["the path in the setting cacheDir (not set yet)"]);
  assert.deepEqual(scopes.process, ["the program in the setting OpenCode binary (not set yet)"]);
  assert.equal(
    scopeText("process", "${settings.binary}", settings, { binary: "/opt/bin/opencode" }),
    "the program in the setting OpenCode binary (now /opt/bin/opencode)",
  );
  assert.equal(
    scopeText("net", "${settings.serverUrl}", null),
    "the host in the setting serverUrl (not set yet)",
    "without the schema, the key",
  );
  assert.ok(
    permissionRows(permissions).some((row) => row.scopes.includes("${settings.serverUrl}")),
    "an agent's scan prompt keeps the templates",
  );
});

test("specs, links and sites", () => {
  assert.deepEqual(parseSpec("alice/pr-tools@1.2.0"), { id: "alice/pr-tools", version: "1.2.0" });
  assert.deepEqual(parseSpec("alice/pr-tools"), { id: "alice/pr-tools", version: null });
  for (const bad of ["alice", "Alice/x", "a/b@latest", "a/b/c", "-a/b"]) assert.equal(parseSpec(bad), null, bad);
  assert.equal(siteOrigin("https://api.divergence.archo.dev"), "https://divergence.archo.dev");
  assert.equal(siteOrigin("http://localhost:8787"), "http://localhost:8787");
  assert.equal(siteOrigin("/tmp/repo"), "https://divergence.archo.dev");
  assert.equal(listingUrl(REGISTRY, "alice/x"), "https://divergence.archo.dev/plugins/alice/x");
  assert.equal(languageOf("lib/a.mjs"), "javascript");
  assert.equal(languageOf("README"), "text");
  assert.equal(badgeCount([{ latest: "2.0.0" }, { malicious: true }, { yanked: true }]), 2);
});

test("the agent, model and effort menus keep a saved choice that still exists, else fall back", () => {
  const agents = [{ id: "codex" }, { id: "claude" }];
  assert.deepEqual(
    effortChoices(OPTIONS.codex, "gpt-5-mini").map((c) => c.value),
    ["medium"],
    "per-model efforts",
  );
  assert.deepEqual(
    effortChoices(OPTIONS.claude, "opus").map((c) => c.value),
    ["medium", "max"],
  );
  assert.deepEqual(resolveChoice({ agentId: "codex", model: "gpt-5-mini", effort: "medium" }, agents, OPTIONS.codex), {
    agentId: "codex",
    model: "gpt-5-mini",
    effort: "medium",
  });
  assert.deepEqual(
    resolveChoice({ agentId: "codex", model: "gone", effort: "max" }, agents, OPTIONS.codex),
    {
      agentId: "codex",
      model: "gpt-5",
      effort: "low",
    },
    "the option's current values",
  );
  assert.deepEqual(resolveChoice({ agentId: "removed" }, agents, OPTIONS.codex).agentId, "codex");
  assert.deepEqual(optionChanges(OPTIONS.codex, { model: "gpt-5-mini", effort: "medium" }), [
    ["model", "gpt-5-mini"],
    ["effort", "medium"],
  ]);
  assert.deepEqual(optionChanges(OPTIONS.codex, { model: "gpt-5", effort: "low" }), [], "nothing to change");
});

test("the scan prompt names the release and walks the checklist", () => {
  const prompt = scanPrompt({
    id: "alice/pr-tools",
    version: "1.3.0",
    listing: { ...LISTING, basedOn: { id: "convergence/git", version: "0.2.0" } },
  });
  for (const part of [
    "alice/pr-tools@1.3.0",
    'market_read { id: "alice/pr-tools", version: "1.3.0" }',
    "Compare the permissions with the code",
    "network endpoint",
    "obfuscated, encoded or minified",
    "bundled binaries",
    'market_read { id: "convergence/git", version: "0.2.0" }',
    'market_similar { id: "alice/pr-tools" }',
    "Never run",
    "market_request_enable",
    "highly confident",
    "net [api.github.com]",
  ]) {
    assert.ok(prompt.includes(part), `the prompt has ${part}:\n${prompt}`);
  }
  const update = updatePrompt({
    id: "alice/pr-tools",
    folder: "pr-tools",
    path: "/p/pr-tools",
    from: "1.2.0",
    to: "1.3.0",
    modified: true,
    changedFiles: ["main.js"],
    permissions: { added: ["net"], removed: [], changed: [] },
    newPermissions: LISTING.permissions,
  });
  assert.match(update, /from 1\.2\.0 to 1\.3\.0/);
  assert.match(update, /local changes against 1\.2\.0: main\.js/);
  assert.match(update, /The permissions changed: call market_request_enable/);
});

// --- tools -------------------------------------------------------------------

// A host behind the tools: recorded calls with table answers, as Effects.
function toolHost(answers: Record<string, unknown> = {}) {
  const calls: [string, unknown][] = [];
  const defaults: Record<string, unknown> = {
    "host/market.search": { results: [LISTING], page: 1, pageSize: 20, total: 1 },
    "host/market.updates": { updates: [] },
    "host/plugins.request_enable": { requestId: "req-1" },
  };
  const call = <M extends HostMethod>(method: M, params: HostParams<M>) => {
    calls.push([method, params]);
    // This table is the test's fixture: untyped JSON standing in for the host.
    const answer = (method in answers ? answers[method] : defaults[method]) as
      HostResult<M> | ((params: unknown) => unknown) | Error;
    if (answer instanceof Error) return Effect.fail(hostError(method, answer));
    const value = (
      typeof answer === "function" ? (answer as (params: unknown) => unknown)(params) : answer
    ) as HostResult<M>;
    return Effect.succeed(structuredClone(value ?? {}) as HostResult<M>);
  };
  return {
    calls,
    host: { call } as Host["Service"],
    callsOf: (method: string) => calls.filter(([m]) => m === method).map(([, params]) => params),
  };
}

const toolContext = (chatId = "c1"): ToolContext => ({
  chatId,
  signal: new AbortController().signal,
  subagent: { start: async () => ({}) } as ToolContext["subagent"],
});

const runTool = <I extends z.ZodType, O extends z.ZodType>(
  tool: EffectTool<I, O>,
  input: unknown,
  host: Host["Service"],
  ctx: ToolContext = toolContext(),
) =>
  Effect.runPromise(
    Effect.gen(function* () {
      const parsed = yield* Effect.succeed(tool.input.parse(input));
      return yield* tool.run(parsed, ctx);
    }).pipe(
      // The tools only call Host; the layer is cast because their declared
      // requirements are the whole plugin runtime.
      Effect.provide(Layer.succeed(Host)(host) as unknown as Layer.Layer<PluginServices>),
    ),
  );

test("the tools are the ten marketplace tools, offered only in the Plugins workspace", () => {
  const api = fakeApi();
  activate(api as unknown as Api);
  assert.deepEqual([...api.toolMap.keys()].sort(), [
    "market_attach_fix",
    "market_bug_report",
    "market_install",
    "market_publish",
    "market_read",
    "market_report",
    "market_request_enable",
    "market_search",
    "market_similar",
    "market_update",
  ]);
  for (const tool of api.toolMap.values()) {
    assert.equal(tool.scope, "plugins-workspace", tool.name);
    assert.ok((tool.description ?? "").length > 40, tool.name);
    assert.equal(tool.inputSchema?.type, "object", tool.name);
  }
});

test("reading and downloading need no card; the install lands not enabled and points at the enable card", async () => {
  const { host, callsOf } = toolHost({
    "host/market.install": {
      id: "alice/pr-tools",
      version: "1.3.0",
      folder: "pr-tools",
      enabled: false,
      permissions: LISTING.permissions,
      requestId: null,
    },
    "host/market.read": ({ path }: { path?: string }) =>
      path ? { path, text: "export function activate() {}" } : { id: "alice/pr-tools", files: [{ path: "main.js" }] },
  });
  const found = (await runTool(searchTool, { query: "pr" }, host)) as unknown as { results: Record<string, unknown>[] };
  assert.deepEqual(found.results[0], {
    id: "alice/pr-tools",
    description: "Pull requests in the sidebar.",
    publisher: "alice",
    official: false,
    latestVersion: "1.3.0",
    downloads: 1234,
    rating: { average: 4.5, count: 4 },
    visibility: "public",
  });
  assert.equal(
    ((await runTool(readTool, { id: "alice/pr-tools", path: "main.js" }, host)) as unknown as { text: string }).text,
    "export function activate() {}",
  );
  await assert.rejects(runTool(readTool, { id: "alice/pr-tools@1.0.0" }, host), /publisher\/name/);
  const installed = (await runTool(
    installTool,
    { id: "alice/pr-tools", version: "1.3.0" },
    host,
    toolContext("c1"),
  )) as unknown as {
    folder: string;
    next: string;
  };
  assert.equal(installed.folder, "pr-tools");
  assert.match(installed.next, /market_request_enable \{ name: "pr-tools" \}/);
  assert.deepEqual(
    callsOf("host/market.install"),
    [{ id: "alice/pr-tools", version: "1.3.0" }],
    "no chat: no card with the download",
  );

  const enable = (await runTool(requestEnableTool, { name: "pr-tools" }, host, toolContext("c1"))) as unknown as {
    requestId: string;
  };
  assert.equal(enable.requestId, "req-1");
  assert.deepEqual(callsOf("host/plugins.request_enable"), [{ name: "pr-tools", chatId: "c1" }]);
});

test("publishing and reporting only ask for the native row and card", async () => {
  const { host, callsOf } = toolHost({
    "host/market.request_publish": { requestId: "pub-1" },
    "host/market.request_report": { requestId: "rep-1" },
  });
  const published = (await runTool(
    publishTool,
    { name: "pr-tools", visibility: "shared", share: ["bob"] },
    host,
  )) as unknown as {
    requestId: string;
  };
  assert.equal(published.requestId, "pub-1");
  assert.deepEqual(callsOf("host/market.request_publish"), [
    { name: "pr-tools", chatId: "c1", visibility: "shared", share: ["bob"] },
  ]);
  await assert.rejects(
    runTool(publishTool, { name: "pr-tools" }, host, { ...toolContext(), chatId: undefined }),
    /needs the chat/,
  );

  await assert.rejects(
    runTool(reportTool, { id: "mallory/x", version: "1.0.0", reason: "bad", files: [] }, host),
    /files/,
  );
  await assert.rejects(
    runTool(reportTool, { id: "mallory/x", version: "1.0.0", reason: " ", files: ["a.js:1"] }, host),
    /reason/,
  );
  const reported = (await runTool(
    reportTool,
    { id: "mallory/x", version: "1.0.0", reason: "Uploads ~/.ssh", files: ["main.js:12"] },
    host,
  )) as unknown as { requestId: string };
  assert.equal(reported.requestId, "rep-1");
  assert.deepEqual(callsOf("host/market.request_report"), [
    { id: "mallory/x", version: "1.0.0", reason: "Uploads ~/.ssh", files: ["main.js:12"], kind: "agent", chatId: "c1" },
  ]);
  const direct = callsOf("host/market.publish").concat(callsOf("host/market.report"));
  assert.deepEqual(direct, [], "nothing is published or sent without the user's card");
});

// --- flows -------------------------------------------------------------------

// The services behind the flows: a recorded host, in-memory storage, and an
// app that remembers the selected chat.
function flowLayer(answers: Record<string, unknown> = {}) {
  const calls: [string, unknown][] = [];
  const selected: { chat: string | null; workspace: string | null } = { chat: null, workspace: null };
  const defaults: Record<string, unknown> = {
    "host/market.workspace": WORKSPACE,
    "host/agents.list": {
      agents: [
        { id: "codex", name: "Codex" },
        { id: "claude", name: "Claude" },
      ],
    },
    "host/agents.options": (params: { agentId: string }) => ({
      options: (OPTIONS as Record<string, unknown[]>)[params.agentId] ?? [],
    }),
    "host/chats.list": { chats: [] },
    "host/chats.create": (params: { agentId: string; title: string }) => ({
      id: "chat-1",
      workspaceId: WORKSPACE.workspaceId,
      agentId: params.agentId,
      title: params.title,
    }),
    "host/chats.options": () => ({ options: OPTIONS.codex }),
    "host/chats.set_option": {},
    "host/chats.send": {},
  };
  const call = <M extends HostMethod>(method: M, params: HostParams<M>) => {
    calls.push([method, params]);
    // This table is the test's fixture: untyped JSON standing in for the host.
    const answer = (method in answers ? answers[method] : defaults[method]) as
      HostResult<M> | ((params: unknown) => unknown) | Error;
    if (answer instanceof Error) return Effect.fail(hostError(method, answer));
    const value = (
      typeof answer === "function" ? (answer as (params: unknown) => unknown)(params) : answer
    ) as HostResult<M>;
    return Effect.succeed(structuredClone(value ?? {}) as HostResult<M>);
  };
  const base = testLayer({ host: { call } as Host["Service"] });
  const notices: [string, string][] = [];
  const layer = Layer.mergeAll(
    base,
    Layer.succeed(App)({
      state: Effect.succeed<AppState>({ route: "chat", shared: {} }),
      changes: Stream.empty,
      update: () => Effect.void,
      select: (chatId: string, workspaceId: string) =>
        Effect.sync(() => {
          selected.chat = chatId;
          selected.workspace = workspaceId;
        }),
      route: () => Effect.void,
    }),
    Layer.succeed(Notify)({
      info: (message: string) => Effect.sync(() => notices.push(["info", message])),
      warning: (message: string) => Effect.sync(() => notices.push(["warning", message])),
      error: (message: string) => Effect.sync(() => notices.push(["error", message])),
    }),
  );
  return {
    layer,
    calls,
    selected,
    notices,
    storage: base.storage,
    methods: () => calls.map(([method]) => method),
    callsOf: (method: string) => calls.filter(([m]) => m === method).map(([, params]) => params),
  };
}

const listing = LISTING as unknown as Listing;

test("Install opens a scan chat in the Plugins workspace with the chosen agent, model and effort", async () => {
  const fake = flowLayer();
  const choice = { agentId: "codex", model: "gpt-5-mini", effort: "medium" };
  const result = await Effect.runPromise(install(listing, "1.3.0", choice).pipe(Effect.provide(fake.layer)));
  assert.equal(result.direct, false);
  assert.deepEqual(fake.callsOf("host/chats.create"), [
    { workspaceId: "w-plugins", agentId: "codex", title: "Install alice/pr-tools" },
  ]);
  assert.deepEqual(fake.callsOf("host/chats.set_option"), [
    { chatId: "chat-1", optionId: "model", value: "gpt-5-mini" },
    { chatId: "chat-1", optionId: "effort", value: "medium" },
  ]);
  const [{ text }] = fake.callsOf("host/chats.send") as [{ text: string }];
  assert.match(text, /alice\/pr-tools@1\.3\.0/);
  assert.match(text, /scan checklist/);
  assert.deepEqual([fake.selected.chat, fake.selected.workspace], ["chat-1", "w-plugins"], "the user sees the chat");
  assert.equal(fake.methods().includes("host/market.install"), false, "the agent installs, after its scan");
  assert.deepEqual(fake.storage.get(CHOICE_KEY), choice, "remembered for next time");
  // The order: options before the prompt, so the turn runs with them.
  const order = fake.methods().filter((m) => m.startsWith("host/chats."));
  assert.deepEqual(order, [
    "host/chats.create",
    "host/chats.options",
    "host/chats.set_option",
    "host/chats.set_option",
    "host/chats.send",
  ]);
});

test("the menus start at the last combination used in the Plugins workspace", async () => {
  const saved = flowLayer();
  saved.storage.set(CHOICE_KEY, { agentId: "claude", model: "opus", effort: "max" });
  assert.deepEqual((await Effect.runPromise(defaultChoice().pipe(Effect.provide(saved.layer)))).choice, {
    agentId: "claude",
    model: "opus",
    effort: "max",
  });

  const fromChat = flowLayer({
    "host/chats.list": {
      chats: [
        { id: "old", agentId: "codex", updatedAt: "2026-01-01", options: {} },
        { id: "new", agentId: "claude", updatedAt: "2026-03-01", options: { model: "opus", thinking: "max" } },
      ],
    },
  });
  assert.deepEqual((await Effect.runPromise(defaultChoice().pipe(Effect.provide(fromChat.layer)))).choice, {
    agentId: "claude",
    model: "opus",
    effort: "max",
  });
  assert.deepEqual(fromChat.callsOf("host/chats.list"), [{ workspaceId: "w-plugins" }]);

  const none = flowLayer();
  assert.deepEqual((await Effect.runPromise(defaultChoice().pipe(Effect.provide(none.layer)))).choice, {
    agentId: "codex",
    model: "gpt-5",
    effort: "low",
  });
});

test("direct install skips the scan, but never for a plugin that can run any program", async () => {
  const fake = flowLayer({
    "host/market.install": { folder: "pr-tools", requestId: "req-9" },
  });
  fake.storage.set(DIRECT_KEY, true);
  const choice = { agentId: "codex", model: "gpt-5", effort: "low" };
  const result = await Effect.runPromise(install(listing, "1.3.0", choice).pipe(Effect.provide(fake.layer)));
  assert.equal(result.direct, true);
  assert.deepEqual(
    fake.callsOf("host/market.install"),
    [{ id: "alice/pr-tools", version: "1.3.0", chatId: "chat-1" }],
    "the card goes in the small chat",
  );
  assert.deepEqual(fake.callsOf("host/chats.send"), [], "no prompt: no agent runs");

  const risky = {
    ...listing,
    permissions: { "process.any": { reason: "Run anything" } },
    versions: [{ version: "1.3.0", permissions: { "process.any": { reason: "x" } } }],
  } as unknown as Listing;
  const scanned = await Effect.runPromise(install(risky, "1.3.0", choice).pipe(Effect.provide(fake.layer)));
  assert.equal(scanned.direct, false, "a plugin that runs anything is scanned first");
  await assert.rejects(
    Effect.runPromise(installDirect(risky, "1.3.0", choice).pipe(Effect.provide(fake.layer))),
    /can run any program/,
  );
});

test("Update replaces an unmodified official plugin, and opens a chat for anything else", async () => {
  const direct = flowLayer({
    "host/market.update": { updated: true, id: "convergence/git", folder: "git", from: "0.2.0", to: "0.3.0" },
  });
  const one = await Effect.runPromise(
    update({ folder: "git", id: "convergence/git" }, { agentId: "codex", model: null, effort: null }).pipe(
      Effect.provide(direct.layer),
    ),
  );
  assert.equal(one.updated, true);
  assert.deepEqual(direct.callsOf("host/chats.create"), []);
  assert.match(direct.notices[0]?.[1] ?? "", /git is updated to 0\.3\.0/);

  const merge = flowLayer({
    "host/market.update": {
      needsAgent: true,
      id: "alice/pr-tools",
      folder: "pr-tools",
      path: "/p/pr-tools",
      from: "1.2.0",
      to: "1.3.0",
      modified: true,
      changedFiles: ["main.js"],
      permissions: { added: [], removed: [], changed: [] },
      newPermissions: {},
    },
  });
  const two = await Effect.runPromise(
    update({ folder: "pr-tools", id: "alice/pr-tools" }, { agentId: "codex", model: "gpt-5", effort: "low" }).pipe(
      Effect.provide(merge.layer),
    ),
  );
  assert.equal(two.updated, false);
  const [{ title }] = merge.callsOf("host/chats.create") as [{ title: string }];
  assert.equal(title, "Update alice/pr-tools");
  assert.match(
    (merge.callsOf("host/chats.send")[0] as unknown as { text: string }).text,
    /Update the plugin pr-tools \(alice\/pr-tools\) from 1\.2\.0 to 1\.3\.0/,
  );
  assert.match(
    (merge.callsOf("host/chats.send")[0] as unknown as { text: string }).text,
    /do not ask for the enable card/,
  );
});

// --- views -------------------------------------------------------------------

const center = (api: ReturnType<typeof fakeApi>, shared: Record<string, unknown> = {}) =>
  api.views.center.render({ app: { route: ROUTE, shared }, state: {}, plugin: "market", update() {} });

test("the page is empty off its route, and a link opens its listing without starting anything", async () => {
  const api = fakeApi();
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  assert.equal(
    api.views.center.render({ app: { route: "chat", shared: {} }, state: {}, plugin: "market", update() {} }),
    null,
  );
  api.updateApp({ shared: { marketOpen: { id: "alice/pr-tools", version: "1.3.0", nonce: 1 } } });
  await waitFor(() => api.callsOf("host/market.listing").length > 0, "the listing to load");
  const tree = center(api, { marketOpen: { id: "alice/pr-tools", version: "1.3.0", nonce: 1 } });
  assert.deepEqual(api.callsOf("host/market.listing"), [{ id: "alice/pr-tools" }], "opened once per link");
  assert.ok(byId(tree, "market-install"), "the Install button");
  assert.ok(
    byId(tree, "market-agent") && byId(tree, "market-model") && byId(tree, "market-effort"),
    "the agent, model and effort menus",
  );
  assert.ok(byId(tree, "market-listing-tabs"));
  const text = JSON.stringify(tree);
  assert.match(text, /Connect to/, "permissions with their labels");
  assert.match(text, /Load pull requests/, "and reasons");
  assert.equal(
    api.methods().some((m) => m === "host/chats.create" || m === "host/chats.send" || m === "host/market.install"),
    false,
    "a link never starts an agent or an install",
  );
});

test("the Install button opens the scan chat; the file viewer and comments load on their tabs", async () => {
  const api = fakeApi({
    "host/market.read": ({ path }: { path?: string }) =>
      path
        ? { path, size: 30, text: "export function activate() {}", truncated: false }
        : {
            id: "alice/pr-tools",
            version: "1.3.0",
            files: [
              { path: "main.js", size: 30 },
              { path: "bin/helper", size: 9, binary: true },
            ],
          },
    "host/market.comments": {
      threads: [
        {
          id: "c1",
          user: { name: "bob" },
          body: "Works well",
          stars: 5,
          replies: [{ id: "c2", user: { name: "alice" }, body: "Thanks", replies: [] }],
        },
      ],
    },
    "host/market.categories": { categories: [{ id: "tools", title: "Agent tools" }] },
  });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  api.updateApp({ route: ROUTE });
  await waitFor(() => api.callsOf("host/market.search").length > 0, "the first search");
  // Open the listing through the first search result, as the page does.
  const browse = center(api);
  const row = nodes(browse).find((node) => node.onClick && JSON.stringify(node).includes('"alice/pr-tools"'));
  assert.ok(row?.onClick, "a search result opens its listing");
  (row.onClick as () => unknown)();
  await waitFor(() => api.callsOf("host/market.listing").length > 0, "the listing to load");
  await waitFor(() => nodes(center(api)).some((node) => node.id === "market-agent"), "the agent menus");
  let tree = center(api);
  await (byId(tree, "market-install").onClick as () => Promise<unknown>)();
  await waitFor(() => api.callsOf("host/chats.send").length > 0, "the scan chat to start");
  assert.equal(api.callsOf("host/chats.send").length, 1, "the scan chat started");
  await waitFor(() => api.selected !== null, "the chat to be selected");
  assert.deepEqual(api.selected, ["chat-1", "w-plugins"]);

  await (byId(center(api), "market-listing-tabs").onChange as (event: unknown) => unknown)({ value: "files" });
  // Rendering the files tab schedules the release read, as the view does.
  center(api);
  await waitFor(() => api.callsOf("host/market.read").length > 0, "the file tree to load");
  tree = center(api);
  const file = nodes(tree).find((node) => node.onClick && JSON.stringify(node).includes('"main.js"'));
  assert.ok(file?.onClick, "a file opens");
  (file?.onClick as () => unknown)();
  await waitFor(() => api.callsOf("host/market.read").length > 1, "the file to load");
  tree = center(api);
  const editor = nodes(tree).find((node) => node.type === "editor") as Record<string, unknown> | undefined;
  assert.equal(editor?.text, "export function activate() {}");
  assert.equal(editor?.readOnly, true);
  assert.equal(editor?.language, "javascript");

  await (byId(center(api), "market-listing-tabs").onChange as (event: unknown) => unknown)({ value: "comments" });
  // Rendering the comments tab schedules the comments read, as the view does.
  center(api);
  await waitFor(() => api.callsOf("host/market.comments").length > 0, "the comments to load");
  const comments = JSON.stringify(center(api));
  assert.match(comments, /Works well/);
  assert.match(comments, /Thanks/, "replies nest");
  // Through the host, which reads the registry the app uses: the plugin's
  // own fetch reached only the default registry its grant named.
  assert.deepEqual(api.callsOf("host/market.comments"), [{ id: "alice/pr-tools" }]);
  assert.equal(api.callsOf("host/market.categories").length, 1);
  assert.ok(!api.methods().includes("net.fetch"));
  await (byId(center(api), "market-comment-web").onClick as () => Promise<unknown>)();
  await waitFor(() => api.opened.length > 0, "the website to open");
  assert.deepEqual(api.opened, ["https://divergence.archo.dev/plugins/alice/pr-tools"], "posting is on the website");
});

test("updates show as a rail badge and an Update button per plugin", async () => {
  const api = fakeApi({ "host/market.update": { updated: true, folder: "git", to: "0.3.0" } });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  const rail = () =>
    api.views.rail.render({ app: { route: "chat", shared: {} }, state: {}, plugin: "market", update() {} });
  assert.equal((rail() as unknown as { badge?: string }).badge, undefined);
  await api.emit("market_changed", {
    updates: [
      {
        id: "convergence/git",
        folder: "git",
        installed: "0.2.0",
        latest: "0.3.0",
        official: true,
        direct: true,
        malicious: false,
        yanked: false,
      },
      {
        id: "mallory/x",
        folder: "x",
        installed: "1.0.0",
        official: false,
        direct: false,
        malicious: true,
        yanked: false,
        reason: "steals tokens",
      },
    ],
  });
  await settled();
  assert.equal((rail() as unknown as { badge?: string }).badge, "2");
  (rail() as unknown as unknown as { onClick: () => unknown }).onClick();
  await settled();
  assert.equal(api.routeState.route, ROUTE);

  await api.emit("market_changed", {
    updates: [
      {
        id: "convergence/git",
        folder: "git",
        installed: "0.2.0",
        latest: "0.3.0",
        official: true,
        direct: true,
        malicious: false,
        yanked: false,
      },
      {
        id: "mallory/x",
        folder: "x",
        installed: "1.0.0",
        official: false,
        direct: false,
        malicious: true,
        yanked: false,
        reason: "steals tokens",
      },
    ],
  });
  api.updateApp({ route: ROUTE });
  await settled();
  // The updates tab, through the page's own tabs.
  await (byId(center(api), "market-tabs").onChange as (event: unknown) => unknown)({ value: "updates" });
  await settled();
  const tree = center(api);
  assert.match(JSON.stringify(tree), /marked malicious \(steals tokens\)/);
  assert.equal(
    nodes(tree).find((node) => node.id === "market-update-x"),
    undefined,
    "a malicious version gets no Update button",
  );
  await (byId(tree, "market-update-git").onClick as () => Promise<unknown>)();
  await waitFor(() => api.callsOf("host/market.update").length > 0, "the update to run");
  assert.deepEqual(api.callsOf("host/market.update"), [{ name: "git" }]);
  assert.match(api.notices.at(-1)?.[1] ?? "", /git is updated to 0\.3\.0/);
});

test("an update that opens an agent chat still refreshes the updates list", async () => {
  const api = fakeApi({
    "host/market.update": {
      needsAgent: true,
      id: "alice/pr-tools",
      folder: "pr-tools",
      path: "/p/pr-tools",
      from: "1.2.0",
      to: "1.3.0",
      modified: false,
      permissions: { added: [], removed: [], changed: [] },
      newPermissions: {},
    },
  });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  const update = {
    id: "alice/pr-tools",
    folder: "pr-tools",
    installed: "1.2.0",
    latest: "1.3.0",
    official: false,
    direct: false,
    malicious: false,
    yanked: false,
  };
  await api.emit("market_changed", { updates: [update] });
  api.updateApp({ route: ROUTE });
  await settled();
  await (byId(center(api), "market-tabs").onChange as (event: unknown) => unknown)({ value: "updates" });
  await settled();
  await (byId(center(api), "market-update-pr-tools").onClick as () => Promise<unknown>)();
  await waitFor(() => api.callsOf("host/chats.create").length > 0, "the update chat to open");
  await settled();
  // The baseline read the updates again after every update, not only a
  // direct replace (the JavaScript `runUpdate` awaited `loadUpdates()`).
  const updatesCalls = api.callsOf("host/market.updates");
  assert.ok(
    updatesCalls.some((params, index) => index > 0 && params && (params as { cached?: boolean }).cached === true),
    "the updates list is read again after an agent update",
  );
});

test("sign-in shows the code and opens the page only on a click", async () => {
  const api = fakeApi({
    "host/market.login": {
      userCode: "ABCD-1234",
      verificationUri: "https://divergence.archo.dev/device",
      verificationUriComplete: "https://divergence.archo.dev/device?code=ABCD-1234",
    },
  });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  api.updateApp({ route: ROUTE });
  await waitFor(() => api.callsOf("host/market.search").length > 0, "the page to load");
  await (byId(center(api), "market-sign-in").onClick as () => Promise<unknown>)();
  await waitFor(() => nodes(center(api)).some((node) => node.id === "market-sign-in-open"), "the sign-in code");
  const tree = center(api);
  assert.match(JSON.stringify(tree), /ABCD-1234/);
  assert.deepEqual(api.opened, [], "nothing opens by itself");
  await (byId(tree, "market-sign-in-open").onClick as () => Promise<unknown>)();
  await waitFor(() => api.opened.length > 0, "the sign-in page to open");
  assert.deepEqual(api.opened, ["https://divergence.archo.dev/device?code=ABCD-1234"]);
  await api.emit("market_account", { signedIn: true });
  await waitFor(() => api.callsOf("host/market.whoami").length >= 2, "the account to be read again");
  assert.deepEqual(api.callsOf("host/market.whoami").length >= 2, true, "the account is read again");
});

test("the Report button prepares the native report card; it sends nothing itself", async () => {
  const api = fakeApi({ "host/market.request_report": { requestId: "rep-2" } });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  api.updateApp({ shared: { marketOpen: { id: "alice/pr-tools", nonce: 7 } } });
  await waitFor(() => api.callsOf("host/market.listing").length > 0, "the listing to load");
  await settled();
  await (byId(center(api), "market-report-open").onClick as () => unknown)();
  await (byId(center(api), "market-report-reason").onChange as (event: unknown) => unknown)({
    value: "It uploads my SSH keys",
  });
  await (byId(center(api), "market-report-files").onChange as (event: unknown) => unknown)({
    value: "main.js:12, lib/net.js:40",
  });
  await (byId(center(api), "market-report-next").onClick as () => Promise<unknown>)();
  await waitFor(() => api.callsOf("host/market.request_report").length > 0, "the report card");
  assert.deepEqual(api.callsOf("host/market.request_report"), [
    {
      id: "alice/pr-tools",
      version: "1.3.0",
      reason: "It uploads my SSH keys",
      files: ["main.js:12", "lib/net.js:40"],
      kind: "user",
    },
  ]);
  assert.equal(api.methods().includes("host/market.report"), false);
});

// --- bug reports and fixes (MARKETPLACE.md §14) ------------------------------

const DRAFT = {
  plugins: [
    {
      name: "chat",
      id: "convergence/chat",
      version: "0.2.0",
      official: true,
      changed: true,
      suggested: true,
      reasons: ["you changed it"],
    },
    {
      name: "pr-tools",
      id: "alice/pr-tools",
      version: "1.3.0",
      official: false,
      changed: false,
      suggested: false,
      reasons: [],
    },
  ],
  note: FIX_NOTE,
  signedIn: false,
  registry: REGISTRY,
};

const PREVIEW = ({ plugins, fullSource }: { plugins: string[]; fullSource?: string[] }) => ({
  plugins: plugins.map((name) => ({ folder: name, id: name === "chat" ? "convergence/chat" : "alice/pr-tools" })),
  attachments: [
    ...(plugins.includes("chat")
      ? [
          {
            id: "diff:chat",
            kind: "diff",
            title: "Your changes to convergence/chat against 0.2.0",
            name: "chat.diff",
            size: 40,
            defaultOn: true,
            removed: [],
            text: "--- a/chat/main.js\n+++ b/chat/main.js\n",
          },
        ]
      : []),
    ...((fullSource ?? []).includes("pr-tools")
      ? [
          {
            id: "source:pr-tools",
            kind: "source",
            title: "The full source of alice/pr-tools 1.3.0",
            name: "pr-tools-source.txt",
            size: 10,
            defaultOn: true,
            removed: [],
            text: "==> main.js <==",
          },
        ]
      : []),
    {
      id: "config",
      kind: "config",
      title: "Config",
      name: "config.txt",
      size: 30,
      defaultOn: true,
      removed: [{ line: 3, kind: "GitHub token" }],
      text: "token: [removed: GitHub token]",
    },
    {
      id: "diagnostics",
      kind: "diagnostics",
      title: "Diagnostics",
      name: "diagnostics.txt",
      size: 30,
      defaultOn: true,
      removed: [],
      text: "Divergence 0.1.2",
    },
  ],
  note: FIX_NOTE,
});

const REPORT = {
  id: "bug_1",
  number: 7,
  text: "The chat drops my message",
  plugins: [{ id: "convergence/chat", version: "0.2.0", folder: "chat", official: true }],
};

test("the fix prompt names the report and the attach row; the form's helpers", () => {
  const prompt = fixPrompt(REPORT);
  assert.match(prompt, /Fix bug report #7 \(bug_1\)/);
  assert.match(prompt, /convergence\/chat 0\.2\.0 \(folder chat\), official/);
  assert.match(prompt, /The chat drops my message/);
  assert.match(prompt, /Attach fix to report #7/);
  assert.match(prompt, /do not call market_publish/);
  assert.deepEqual(checkedAttachments([{ id: "a" }, { id: "b" }], new Set(["b"])), ["a"]);
  assert.deepEqual(suggestedPlugins(DRAFT.plugins), ["chat"]);
  assert.equal(
    FIX_NOTE,
    "You can most likely fix this bug yourself by changing your plugins. If your fix works, attach it to this report, and the maintainers can add it to the main version.",
  );
});

test("an agent previews a bug report, then only asks for the send card; a fix only asks for the attach card", async () => {
  const { host, callsOf } = toolHost({
    "host/market.bug_preview": PREVIEW,
    "host/market.request_bug_report": { requestId: "bug-req" },
    "host/market.request_attach_fix": { requestId: "fix-req", plugins: [{ folder: "chat", id: "convergence/chat" }] },
  });
  const preview = (await runTool(bugReportTool, { plugins: ["chat"], preview: true }, host)) as unknown as {
    attachments: { id: string; sentByDefault: boolean; secretsRemoved: number }[];
    next: string;
  };
  assert.deepEqual(
    preview.attachments.map((a) => [a.id, a.sentByDefault, a.secretsRemoved]),
    [
      ["diff:chat", true, 0],
      ["config", true, 1],
      ["diagnostics", true, 0],
    ],
  );
  assert.match(preview.next, /ask them to check the text and choose/);
  assert.deepEqual(callsOf("host/market.request_bug_report"), [], "a preview shows no card");

  await assert.rejects(runTool(bugReportTool, { plugins: [] }, host), /plugins/);
  await assert.rejects(runTool(bugReportTool, { plugins: ["chat"], text: " " }, host), /text/);
  await assert.rejects(
    runTool(bugReportTool, { plugins: ["chat"], text: "x" }, host, { ...toolContext(), chatId: undefined }),
    /needs the chat/,
  );
  const card = (await runTool(
    bugReportTool,
    { plugins: ["chat"], text: " It drops messages ", attachments: ["diff:chat"] },
    host,
  )) as unknown as { requestId: string };
  assert.equal(card.requestId, "bug-req");
  assert.deepEqual(callsOf("host/market.request_bug_report"), [
    {
      text: "It drops messages",
      plugins: ["chat"],
      attachments: ["diff:chat"],
      fullSource: [],
      contactEmail: undefined,
      kind: "agent",
      chatId: "c1",
    },
  ]);

  await assert.rejects(runTool(attachFixTool, { report: "" }, host), /report/);
  const fix = (await runTool(attachFixTool, { report: "#7" }, host)) as unknown as { requestId: string };
  assert.equal(fix.requestId, "fix-req");
  assert.deepEqual(callsOf("host/market.request_attach_fix"), [{ report: "#7", folders: [], chatId: "c1" }]);
  assert.equal(
    callsOf("host/market.send_bug_report").concat(callsOf("host/market.attach_fix_from")).length,
    0,
    "nothing is sent without the cards",
  );
});

test("the report form suggests plugins, previews every attachment and sends only through the card", async () => {
  const api = fakeApi({
    "host/market.bug_draft": DRAFT,
    "host/market.bug_preview": PREVIEW,
    "host/market.request_bug_report": { requestId: "bug-req" },
    "host/market.bug_reports": { reports: [REPORT] },
    "host/market.link_fix_chat": { reportId: "bug_1", number: 7 },
    pickedImages: ["/Users/me/shot.png"],
  });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  // The palette's command opens the form.
  await api.commands["market.reportBug"]?.run();
  await settled();
  assert.equal(api.routeState.route, ROUTE);
  await waitFor(() => api.callsOf("host/market.bug_draft").length > 0, "the draft to load");
  await waitFor(() => nodes(center(api)).some((node) => node.id === "bug-plugin-chat"), "the plugin list");
  let tree = center(api);
  assert.equal(
    (byId(tree, "bug-plugin-chat") as unknown as { checked: boolean }).checked,
    true,
    "the suggested plugin is chosen",
  );
  assert.equal((byId(tree, "bug-plugin-pr-tools") as unknown as { checked: boolean }).checked, false);
  assert.match(JSON.stringify(tree), /You can most likely fix this bug yourself/);
  assert.ok(byId(tree, "bug-email"), "signed out: the contact email field");
  assert.deepEqual(api.callsOf("host/market.bug_preview").at(-1), {
    plugins: ["chat"],
    fullSource: [],
    screenshots: [],
  });

  // A third-party plugin sends id and version, or its full source when chosen.
  await (byId(tree, "bug-plugin-pr-tools") as unknown as { onChange: (event: unknown) => unknown }).onChange({
    value: true,
  });
  await waitFor(() => nodes(center(api)).some((node) => node.id === "bug-source-pr-tools"), "the full-source choice");
  tree = center(api);
  await (byId(tree, "bug-source-pr-tools") as unknown as { onChange: (event: unknown) => unknown }).onChange({
    value: true,
  });
  await settled();
  await (byId(center(api), "bug-screenshots") as unknown as { onClick: () => unknown }).onClick();
  await waitFor(
    () => JSON.stringify(api.callsOf("host/market.bug_preview").at(-1) ?? {}).includes("/Users/me/shot.png"),
    "the screenshots to attach",
  );
  assert.deepEqual(api.callsOf("host/market.bug_preview").at(-1), {
    plugins: ["chat", "pr-tools"],
    fullSource: ["pr-tools"],
    screenshots: ["/Users/me/shot.png"],
  });
  tree = center(api);
  // Every attachment shows its content on demand, and the secrets removed.
  assert.match(JSON.stringify(tree), /Secrets removed: line 3 \(GitHub token\)/);
  await (byId(tree, "bug-show-config") as unknown as { onClick: () => unknown }).onClick();
  tree = center(api);
  assert.match(JSON.stringify(tree), /token: \[removed: GitHub token\]/);
  await (byId(tree, "bug-attach-diagnostics") as unknown as { onChange: (event: unknown) => unknown }).onChange({
    value: false,
  });

  await (byId(center(api), "bug-text") as unknown as { onChange: (event: unknown) => unknown }).onChange({
    value: "The chat drops my message",
  });
  await (byId(center(api), "bug-email") as unknown as { onChange: (event: unknown) => unknown }).onChange({
    value: "me@example.org",
  });
  await (byId(center(api), "bug-review") as unknown as { onClick: () => unknown }).onClick();
  await waitFor(() => api.callsOf("host/market.request_bug_report").length > 0, "the send card");
  assert.deepEqual(api.callsOf("host/market.request_bug_report"), [
    {
      text: "The chat drops my message",
      plugins: ["chat", "pr-tools"],
      contactEmail: "me@example.org",
      attachments: ["diff:chat", "source:pr-tools", "config"],
      fullSource: ["pr-tools"],
      screenshots: ["/Users/me/shot.png"],
      kind: "user",
    },
  ]);
  assert.equal(
    (byId(center(api), "bug-review") as unknown as { disabled: boolean }).disabled,
    true,
    "while the card waits",
  );

  // The card sent it: the confirmation offers the fix chat.
  await api.emit("market_bug_report", { requestId: "bug-req", reportId: "bug_1", number: 7 });
  await settled();
  tree = center(api);
  assert.match(JSON.stringify(tree), /Bug report #7 is sent/);
  await (byId(tree, "bug-fix-now") as unknown as { onClick: () => unknown }).onClick();
  await waitFor(() => api.callsOf("host/chats.send").length > 0, "the fix chat");
  const order = api
    .methods()
    .filter((m) => ["host/chats.create", "host/market.link_fix_chat", "host/chats.send"].includes(m));
  assert.deepEqual(
    order,
    ["host/chats.create", "host/market.link_fix_chat", "host/chats.send"],
    "linked before its first turn",
  );
  assert.deepEqual(api.callsOf("host/market.link_fix_chat"), [{ chatId: "chat-1", report: "bug_1" }]);
  assert.equal((api.callsOf("host/chats.create")[0] as unknown as { title: string }).title, "Fix bug report #7");
  assert.match((api.callsOf("host/chats.send")[0] as unknown as { text: string }).text, /Fix bug report #7/);
  await waitFor(() => api.selected !== null, "the fix chat to be selected");
  assert.deepEqual(api.selected, ["chat-1", "w-plugins"]);
});

test("cvg bug and Settings open the form with their plugins; a cancelled card frees the form", async () => {
  const api = fakeApi({
    "host/market.bug_draft": DRAFT,
    "host/market.bug_preview": PREVIEW,
    "host/market.request_bug_report": { requestId: "r9" },
  });
  activate(api as unknown as Api);
  // The program's subscriptions establish asynchronously after activate returns.
  await settled();
  await api.emit("market_bug_form", { plugins: ["alice/pr-tools"], text: "From the terminal" });
  await settled();
  assert.equal(api.routeState.route, ROUTE);
  await waitFor(() => api.callsOf("host/market.bug_draft").length > 0, "the draft to load");
  await waitFor(() => nodes(center(api)).some((node) => node.id === "bug-plugin-chat"), "the plugin list");
  const tree = center(api);
  const plugins = nodes(tree)
    .filter((node) => typeof node.id === "string" && node.id.startsWith("bug-plugin-"))
    .filter((node) => (node as unknown as { checked?: boolean }).checked)
    .map((node) => (node.id as string).replace("bug-plugin-", ""));
  assert.deepEqual(plugins.sort(), ["chat", "pr-tools"], "an id is chosen by its folder");
  assert.match(JSON.stringify(tree), /From the terminal/);
  await (byId(tree, "bug-review-fix") as unknown as { onClick: () => unknown }).onClick();
  await waitFor(() => api.callsOf("host/market.request_bug_report").length > 0, "the send card");
  assert.equal((byId(center(api), "bug-review") as unknown as { disabled: boolean }).disabled, true);
  await api.emit("security_resolved", { id: "r9", outcome: "cancelled" });
  await settled();
  assert.equal((byId(center(api), "bug-review") as unknown as { disabled: boolean }).disabled, false);
  assert.deepEqual(api.callsOf("host/chats.create"), [], "no fix chat without a sent report");
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <344.9 KB6 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.