Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/web/e2e/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,53 @@ export const ARCHETYPES = [
{ id: "custom", label: "Custom", icon: "PenLine", blurb: "Write your own — fill in a template.", bundle: null, soul: "# Identity\n\n_Describe your agent in one paragraph._" },
];

// GET /api/archetypes/{id}/preview — the read-only bundle peek (#2041). product-stack asks
// for a GitHub MCP server (needs a token) + a standalone Brave secret, so it exercises the
// enriched preview dialog AND the new-agent Configure step. Code-free ids return bundle:null.
export const ARCHETYPE_PREVIEWS = {
"product-stack": {
id: "product-stack",
bundle: {
kind: "bundle",
id: "product-stack",
name: "Product Manager",
description: "Research, strategy, and specs.",
enabled: ["github"],
members: [
{
id: "github",
builtin: false,
ref: "v1.2.0",
name: "GitHub",
version: "1.2.0",
description: "GitHub issues + PR tools.",
skills: [{ name: "pr-review", description: "Review a pull request" }],
},
],
mcp: [
{
id: "github",
name: "GitHub",
category: "Dev",
tagline: "GitHub issues + PRs over MCP.",
requires: "token",
template: {
name: "github",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${github_token}" },
},
inputs: [
{ key: "github_token", label: "GitHub token", placeholder: "ghp_…", secret: true, required: true },
],
},
],
secrets: [{ key: "BRAVE_API_KEY", label: "Brave API key", placeholder: "brv_…", secret: true, required: false }],
},
},
};

export const WORKFLOWS = [
{
name: "research-and-brief",
Expand Down
40 changes: 40 additions & 0 deletions apps/web/e2e/fleet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@ test("New agent → archetype picker → create returns to the list", async ({ p
await expect(page.locator(".fleet-row", { hasText: "newbot" })).toBeVisible();
});

test("New agent → configure a bundle's MCP inputs → create seeds them (#2041)", async ({ page }) => {
await openAgents(page);

// Capture the create payload — the Configure step must carry the operator's inputs.
let posted = null;
await page.route("**/api/fleet", async (route) => {
if (route.request().method() === "POST") posted = route.request().postDataJSON();
return route.continue();
});

await page.getByRole("button", { name: "New agent" }).click();
await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click();

// The picked bundle asks for a GitHub token (secret, masked) + declares a Brave secret;
// both surface in the inline Configure step (the preview peek supplies them).
const token = page.getByLabel("GitHub token");
await expect(token).toBeVisible();
await expect(page.getByLabel("Brave API key")).toBeVisible();
await token.fill("ghp_secret");

await page.getByLabel("Agent name").fill("ghbot");
await page.getByRole("button", { name: /Create/ }).click();

await expect(page.locator(".fleet-row", { hasText: "ghbot" })).toBeVisible();
expect(posted?.inputs).toEqual({ github_token: "ghp_secret" });
// The Brave secret was left blank → dropped (env-only fallback), not sent as an empty value.
expect(posted?.secrets ?? []).toEqual([]);
Comment thread
mabry1985 marked this conversation as resolved.
});

test("New agent preview dialog lists the bundle's MCP servers + secrets (#2041)", async ({ page }) => {
await openAgents(page);
await page.getByRole("button", { name: "New agent" }).click();
await page.locator(".pl-radiocard", { hasText: "Product Manager" }).click();
await page.getByRole("button", { name: /See what.s included/ }).click();

const dialog = page.locator(".pl-dialog", { hasText: "What's included" });
await expect(dialog.getByText("MCP servers: GitHub (needs token)")).toBeVisible();
await expect(dialog.getByText("Secrets: Brave API key")).toBeVisible();
});

test("stop a running agent flips its status dot", async ({ page }) => {
await openAgents(page);
const ava = page.locator(".fleet-row", { hasText: "ava" });
Expand Down
11 changes: 11 additions & 0 deletions apps/web/e2e/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url";
import {
ACTIVITY_HISTORY,
ARCHETYPES,
ARCHETYPE_PREVIEWS,
buildFrames,
DELEGATES,
DELEGATE_TYPES,
Expand Down Expand Up @@ -586,6 +587,16 @@ const server = createServer(async (req, res) => {
return sendJson(res, { enabled: true, goal, plan: goal ? GOAL_PLAN : "" });
}
}
{
// Archetype bundle peek (#2041) — the enriched preview (mcp + secrets) the
// preview dialog and the new-agent Configure step both read. Unknown/code-free
// ids fall back to bundle:null.
const m = pathname.match(/^\/api\/archetypes\/([^/]+)\/preview$/);
if (m) {
const id = decodeURIComponent(m[1]);
return sendJson(res, ARCHETYPE_PREVIEWS[id] ?? { id, bundle: null });
}
}
const payload = handleApiGet(pathname, fleetFor(req));
if (payload !== null) return sendJson(res, payload);
return sendJson(res, { detail: "not mocked" }, 404);
Expand Down
20 changes: 1 addition & 19 deletions apps/web/src/app/McpCatalogDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useMemo, useState } from "react";

import { api } from "../lib/api";
import { errMsg } from "../lib/format";
import { fillTemplate } from "../lib/mcpTemplate";
import { runtimeStatusQuery } from "../lib/queries";
import type { McpCatalogEntry } from "../lib/types";

Expand All @@ -19,25 +20,6 @@ import type { McpCatalogEntry } from "../lib/types";

const MCP_CATALOG_KEY = ["mcp-catalog"] as const;

// Substitute ${key} placeholders in every string of the template (args, env,
// url, headers) with the operator-supplied values.
function fillTemplate(
template: Record<string, unknown>,
values: Record<string, string>,
): Record<string, unknown> {
const sub = (v: unknown): unknown => {
if (typeof v === "string") return v.replace(/\$\{(\w+)\}/g, (_m, k: string) => values[k] ?? "");
if (Array.isArray(v)) return v.map(sub);
if (v && typeof v === "object") {
return Object.fromEntries(
Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, sub(val)]),
);
}
return v;
};
return sub(template) as Record<string, unknown>;
}

export function McpCatalogDialog({
open,
onClose,
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/app/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,35 @@ textarea {
.archetype-name-field {
max-width: 380px;
}

/* Inline Configure step (#2041) — the bundle's MCP inputs + declared secrets, collapsible. */
.archetype-configure {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 380px;
}
.archetype-configure-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: none;
color: var(--fg);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.archetype-configure-toggle .field-hint {
font-weight: 400;
}
.archetype-configure-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-hint {
font-size: 11px;
color: var(--fg-muted);
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,11 @@ export const api = {
port?: number;
start?: boolean;
shared_skills?: boolean;
// Operator-supplied bundle-seed values (#2041): `inputs` fill the bundle's MCP
// `${input}` placeholders (an entry seeds ENABLED when its required inputs are here),
// `secrets` carry values for the bundle's declared secrets. Omitted → env-only.
inputs?: Record<string, string>;
secrets?: { key: string; value: string }[];
}) {
return request<{ ok: boolean; agent: FleetAgent; installed: string[] }>("/api/fleet", {
method: "POST",
Expand Down
164 changes: 164 additions & 0 deletions apps/web/src/lib/archetypeConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, it, expect } from "vitest";

import {
archetypeConfigFields,
configMissingRequired,
fieldId,
hasConfigFields,
mcpItemLabel,
previewMcpSummary,
previewSecretsSummary,
splitConfigValues,
} from "./archetypeConfig";
import type { ArchetypePreview } from "./types";

// The new-agent Configure step (#2041 slice 3) and the enriched preview dialog both derive
// their behavior from these pure helpers, so they carry the real coverage: the form spec a
// bundle preview flattens to, the gate on required inputs, the split back into the two
// create() channels, and the read-only preview summaries.

// A bundle preview with one MCP server (a plain root input + a secret token) and one
// declared standalone secret — the GitHub-style case from the acceptance criteria.
function githubPreview(): ArchetypePreview {
return {
id: "product-stack",
bundle: {
kind: "bundle",
name: "Product stack",
members: [],
mcp: [
{
id: "github",
name: "GitHub",
requires: "token",
template: { env: { GITHUB_TOKEN: "${github_token}" }, args: ["--root", "${root}"] },
inputs: [
{ key: "root", label: "Repo root", placeholder: "/work", required: true },
{ key: "github_token", label: "GitHub token", secret: true, required: true },
],
},
],
secrets: [{ key: "BRAVE_API_KEY", label: "Brave API key", secret: true }],
},
};
}

describe("archetypeConfigFields — flatten a bundle preview into form fields", () => {
it("emits MCP inputs first, then declared secrets, tagged by origin", () => {
const fields = archetypeConfigFields(githubPreview());
expect(fields.map((f) => [f.origin, f.key])).toEqual([
["input", "root"],
["input", "github_token"],
["secret", "BRAVE_API_KEY"],
]);
});

it("masks secret MCP inputs and always masks declared secrets; plain inputs stay unmasked", () => {
const fields = archetypeConfigFields(githubPreview());
expect(fields.find((f) => f.key === "root")?.secret).toBe(false);
expect(fields.find((f) => f.key === "github_token")?.secret).toBe(true);
expect(fields.find((f) => f.key === "BRAVE_API_KEY")?.secret).toBe(true);
});

it("returns no fields for a code-free archetype (bundle: null) — backward compat", () => {
const empty: ArchetypePreview = { id: "basic", bundle: null };
expect(archetypeConfigFields(empty)).toEqual([]);
expect(hasConfigFields(empty)).toBe(false);
expect(archetypeConfigFields(undefined)).toEqual([]);
});

it("returns no fields for a bundle that declares neither mcp inputs nor secrets", () => {
const bare: ArchetypePreview = { id: "x", bundle: { kind: "bundle", members: [] } };
expect(hasConfigFields(bare)).toBe(false);
});
});

describe("configMissingRequired — required-input gate", () => {
const fields = archetypeConfigFields(githubPreview());

it("is true while any required field is blank or whitespace", () => {
expect(configMissingRequired(fields, {})).toBe(true);
expect(
configMissingRequired(fields, { [fieldId({ origin: "input", key: "root" })]: " " }),
).toBe(true);
});

it("is false once every required field has a value", () => {
const values = {
[fieldId({ origin: "input", key: "root" })]: "/work",
[fieldId({ origin: "input", key: "github_token" })]: "ghp_1",
};
// BRAVE_API_KEY is not required, so leaving it blank is fine.
expect(configMissingRequired(fields, values)).toBe(false);
});
});

describe("splitConfigValues — collected form values back into create() channels", () => {
const fields = archetypeConfigFields(githubPreview());

it("routes inputs to the map and declared secrets to the list, dropping blanks", () => {
const values = {
[fieldId({ origin: "input", key: "root" })]: "/work",
[fieldId({ origin: "input", key: "github_token" })]: " ghp_1 ",
[fieldId({ origin: "secret", key: "BRAVE_API_KEY" })]: "",
};
expect(splitConfigValues(fields, values)).toEqual({
inputs: { root: "/work", github_token: "ghp_1" },
secrets: [],
});
});

it("carries a filled declared secret into the secrets list", () => {
const values = { [fieldId({ origin: "secret", key: "BRAVE_API_KEY" })]: "brv_9" };
expect(splitConfigValues(fields, values)).toEqual({
inputs: {},
secrets: [{ key: "BRAVE_API_KEY", value: "brv_9" }],
});
});

it("keeps an MCP input and a declared secret sharing a key from colliding", () => {
const preview: ArchetypePreview = {
id: "clash",
bundle: {
kind: "bundle",
members: [],
mcp: [{ id: "s", name: "S", template: {}, inputs: [{ key: "TOKEN", label: "Input token" }] }],
secrets: [{ key: "TOKEN", label: "Secret token", secret: true }],
},
};
const clashFields = archetypeConfigFields(preview);
const values = {
[fieldId({ origin: "input", key: "TOKEN" })]: "from-input",
[fieldId({ origin: "secret", key: "TOKEN" })]: "from-secret",
};
expect(splitConfigValues(clashFields, values)).toEqual({
inputs: { TOKEN: "from-input" },
secrets: [{ key: "TOKEN", value: "from-secret" }],
});
});
});

describe("preview summaries — read-only display in ArchetypePreviewDialog", () => {
it("annotates each MCP server with what it needs", () => {
expect(mcpItemLabel({ id: "g", name: "GitHub", requires: "token", template: {} })).toBe(
"GitHub (needs token)",
);
expect(mcpItemLabel({ id: "n", name: "Notion", template: {} })).toBe("Notion");
expect(
previewMcpSummary([
{ id: "g", name: "GitHub", requires: "token", template: {} },
{ id: "b", name: "Brave Search", requires: "API key", template: {} },
]),
).toBe("GitHub (needs token), Brave Search (needs API key)");
});

it("lists secret labels", () => {
expect(
previewSecretsSummary([
{ key: "GH", label: "GitHub token" },
{ key: "BR", label: "Brave API key" },
]),
).toBe("GitHub token, Brave API key");
expect(previewSecretsSummary(undefined)).toBe("");
});
});
Loading
Loading