From c2e16cf129f187f96710555c3c551998f0d14182 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 12 Nov 2026 01:52:13 +0300 Subject: [PATCH] feat(studio): L07 export an agent as YAML or as a bundle GET /agents/:id/export?format=yaml serves the working-copy spec as a text/yaml attachment; format=bundle serves a lousho.agent-bundle JSON with the spec, labels, the prompt-registry text spec.promptRef names and the eval datasets it gates on - each pulled from the observe daemon with an 8s deadline, degrading to a X-Lousho-Export-Warnings header rather than failing when the daemon cannot serve a piece. Secret values never appear: the spec only ever holds { env }/{ secret } references. The header overflow menu's Export YAML now downloads through the route (client-side serializeSpec is gone for it) and gains Export bundle; warnings surface as warning toasts, failures as error toasts. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../server/__tests__/export.test.ts | 197 ++++++++++++++++++ apps/agent-forge/server/app.ts | 5 + apps/agent-forge/server/lifecycle/export.ts | 194 +++++++++++++++++ apps/agent-forge/shared/wireTypes.ts | 18 ++ .../src/persistence/importExport.ts | 23 +- apps/agent-forge/src/runtime/runtimeClient.ts | 27 +++ .../screens/agentDetail/AgentActionsMenu.tsx | 41 ++-- .../__tests__/AgentActionsMenu.test.tsx | 10 +- .../agentDetail/__tests__/exportMenu.test.tsx | 143 +++++++++++++ 9 files changed, 632 insertions(+), 26 deletions(-) create mode 100644 apps/agent-forge/server/__tests__/export.test.ts create mode 100644 apps/agent-forge/server/lifecycle/export.ts create mode 100644 apps/agent-forge/src/screens/agentDetail/__tests__/exportMenu.test.tsx diff --git a/apps/agent-forge/server/__tests__/export.test.ts b/apps/agent-forge/server/__tests__/export.test.ts new file mode 100644 index 000000000..44df01ce6 --- /dev/null +++ b/apps/agent-forge/server/__tests__/export.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import request from 'supertest'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo } from '../storage/agentRepo'; +import { SecretsStore } from '../secretsStore'; +import { studioPathsFor } from '../workspace/studioPaths'; +import type { LegacyAgentStore } from '../../src/persistence/AgentStore'; +import type { AgentBundle } from '../../shared/wireTypes'; + +const V2_SPEC: AgentSpecV2 = { + version: 2, + name: 'ops', + description: 'Watches the queues.', + model: 'mock/mock-1', + instructions: 'Check the queues.', +}; + +/** Writes a spec file verbatim (bypasses writeSpec's strict parse - pending sections like `evals` stay). */ +function writeSpecFile(repo: AgentRepo, id: string, spec: unknown): void { + fs.mkdirSync(path.dirname(repo.specPath(id)), { recursive: true }); + fs.writeFileSync(repo.specPath(id), stringifyYaml(spec)); +} + +/** A fetch stub answering the observe daemon's prompt/dataset routes; anything else is a 404. */ +function daemonFetch(handlers: Record): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + for (const [suffix, body] of Object.entries(handlers)) { + if (url.includes(suffix)) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + } + } + return new Response('not found', { status: 404 }); + }) as unknown as typeof fetch; +} + +describe('GET /agents/:id/export', () => { + let baseDir: string; + let studioHome: string; + let agentStore: LegacyAgentStore; + let runManager: RunManager; + let repo: AgentRepo; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-l07-export-')); + studioHome = path.join(baseDir, '.lousho'); + agentStore = createFsAgentStore(baseDir); + repo = new AgentRepo(baseDir); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const app = () => + createApp({ + agentStore, + runManager, + baseDir, + studioHome, + secretsStore: new SecretsStore(studioPathsFor(studioHome)), + }); + + it('format=yaml downloads the working-copy spec as YAML that round-trips', async () => { + writeSpecFile(repo, 'ops', V2_SPEC); + const res = await request(app()).get('/agents/ops/export?format=yaml'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/yaml'); + expect(res.headers['content-disposition']).toBe('attachment; filename="ops.yaml"'); + expect(res.text).toContain('# Exported from Lousho Studio'); + const parsed = parseYaml(res.text) as AgentSpecV2; + expect(parsed).toMatchObject({ version: 2, name: 'ops', model: 'mock/mock-1', instructions: 'Check the queues.' }); + }); + + it('format=bundle downloads a lousho.agent-bundle with spec, labels, prompt and evals', async () => { + writeSpecFile(repo, 'ops', { + ...V2_SPEC, + promptRef: 'support@prod', + evals: [{ dataset: 'ds1', minPassRate: 0.9 }], + }); + await request(app()).patch('/agents/ops/meta').send({ labels: ['prod', 'tier-1'] }); + vi.stubGlobal( + 'fetch', + daemonFetch({ + '/api/v1/prompts/support/prod': { prompt: { name: 'support', version: 3, text: 'Be nice.' } }, + '/api/v1/datasets/ds1': { + examples: [ + { input: '{"q":"hi"}', expected: '{"a":"yo"}' }, + { input: { q: 'raw' }, expected: null }, + ], + }, + }) + ); + const res = await request(app()).get('/agents/ops/export?format=bundle'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(res.headers['content-disposition']).toBe('attachment; filename="ops.lousho-agent.json"'); + expect(res.headers['x-lousho-export-warnings']).toBeUndefined(); + const bundle = res.body as AgentBundle; + expect(bundle.kind).toBe('lousho.agent-bundle'); + expect(bundle.version).toBe(1); + expect(bundle.spec).toMatchObject({ name: 'ops', promptRef: 'support@prod' }); + expect(bundle.labels).toEqual(['prod', 'tier-1']); + expect(bundle.prompts).toEqual([{ name: 'support', version: 3, content: 'Be nice.' }]); + expect(bundle.evals).toEqual([ + { + dataset: 'ds1', + examples: [ + { input: { q: 'hi' }, expected: { a: 'yo' } }, + { input: { q: 'raw' }, expected: null }, + ], + }, + ]); + expect(typeof bundle.exportedAt).toBe('string'); + }); + + it('bundle still downloads with the daemon down, carrying the warnings header', async () => { + writeSpecFile(repo, 'ops', { ...V2_SPEC, promptRef: 'support@prod' }); + vi.stubGlobal('fetch', daemonFetch({})); + const res = await request(app()).get('/agents/ops/export?format=bundle'); + expect(res.status).toBe(200); + const bundle = res.body as AgentBundle; + expect(bundle.prompts).toEqual([]); + const warnings = JSON.parse(decodeURIComponent(res.headers['x-lousho-export-warnings'] as string)) as string[]; + expect(warnings).toEqual([ + "Prompt 'support@prod' could not be read from the observe daemon; the bundle uses the inline instructions only.", + ]); + }); + + it('a failing dataset fetch warns and omits that dataset', async () => { + writeSpecFile(repo, 'ops', { ...V2_SPEC, evals: [{ dataset: 'missing' }] }); + vi.stubGlobal('fetch', daemonFetch({})); + const res = await request(app()).get('/agents/ops/export?format=bundle'); + expect(res.status).toBe(200); + const bundle = res.body as AgentBundle; + expect(bundle.evals).toBeUndefined(); + const warnings = JSON.parse(decodeURIComponent(res.headers['x-lousho-export-warnings'] as string)) as string[]; + expect(warnings).toContain("Dataset 'missing' could not be read; its examples are not in the bundle."); + }); + + it('a spec without promptRef or evals produces an empty prompts list and no evals key', async () => { + writeSpecFile(repo, 'ops', V2_SPEC); + const res = await request(app()).get('/agents/ops/export?format=bundle'); + expect(res.status).toBe(200); + const bundle = res.body as AgentBundle; + expect(bundle.prompts).toEqual([]); + expect('evals' in bundle).toBe(false); + }); + + it('400s on a missing or unknown format and 404s an unknown agent', async () => { + writeSpecFile(repo, 'ops', V2_SPEC); + expect((await request(app()).get('/agents/ops/export')).status).toBe(400); + expect((await request(app()).get('/agents/ops/export?format=json')).status).toBe(400); + const bad = await request(app()).get('/agents/ops/export?format=json'); + expect(bad.body).toEqual({ error: "format must be 'yaml' or 'bundle'" }); + expect((await request(app()).get('/agents/nope/export?format=yaml')).status).toBe(404); + const missing = await request(app()).get('/agents/nope/export?format=yaml'); + expect(missing.body).toEqual({ error: "No agent 'nope'" }); + }); + + it('never leaks a stored secret value into either export', async () => { + writeSpecFile(repo, 'ops', { + ...V2_SPEC, + mcpServers: { gh: { url: 'https://mcp.example.com', headers: { Authorization: { secret: 'GITHUB_TOKEN' } } } }, + }); + const secrets = new SecretsStore(studioPathsFor(studioHome)); + secrets.setSecret('GITHUB_TOKEN', 'ghp_example'); + const yaml = await request(app()).get('/agents/ops/export?format=yaml'); + expect(yaml.status).toBe(200); + expect(yaml.text).not.toContain('ghp_example'); + expect(yaml.text).toContain('GITHUB_TOKEN'); + vi.stubGlobal('fetch', daemonFetch({})); + const bundle = await request(app()).get('/agents/ops/export?format=bundle'); + expect(bundle.status).toBe(200); + expect(JSON.stringify(bundle.body)).not.toContain('ghp_example'); + }); +}); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index 8c2cabb65..d7a51257b 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -13,6 +13,7 @@ * PATCH /agents/:id/meta -> toggle enabled / set labels (body: PatchAgentMetaRequest), 200 AgentMeta * PUT /agents/:id/layout -> save the canvas layout (body: CanvasLayout), 204 * DELETE /agents/:id -> remove the saved spec, 204 + * GET /agents/:id/export?format=yaml|bundle -> spec YAML or a lousho.agent-bundle JSON download (lifecycle/export.ts) * GET/POST/DELETE /agents/:id/draft -> the pending ChangeDraft (routes/draftRoutes.ts) * POST /agents/:id/draft/approve · /reject -> settle the pending draft * GET/POST /agents/:id/versions -> VersionListItem[] newest first · explicit save (routes/versionRoutes.ts) @@ -131,6 +132,7 @@ import { AuthorTurnRunner } from './author/turnRunner'; import { createAuthorServices } from './author/services'; import { registerSecretRoutes } from './routes/secretRoutes'; import { registerSuggestionRoutes } from './routes/suggestionRoutes'; +import { registerExportRoutes } from './lifecycle/export'; import { TemplateCatalog, registerTemplateRoutes } from './templates/templateCatalog'; import { sdkRootDir } from './sdkRoot'; @@ -1005,6 +1007,9 @@ export function createApp(options: CreateAppOptions): Express { // routes/suggestionRoutes.ts - stores come from workspaceOf(req), the // workspace seam, like drafts and versions). registerSuggestionRoutes(app); + // Agent export: `GET /agents/:id/export?format=yaml|bundle` (lifecycle/ + // export.ts - the repo comes from workspaceOf(req), the workspace seam). + registerExportRoutes(app); registerWorkspaceRoute(app); // The workspace registry's routes (`GET/POST /workspaces`, // `DELETE /workspaces/:ws`) - Studio-global like the settings routes: diff --git a/apps/agent-forge/server/lifecycle/export.ts b/apps/agent-forge/server/lifecycle/export.ts new file mode 100644 index 000000000..8d1df5f79 --- /dev/null +++ b/apps/agent-forge/server/lifecycle/export.ts @@ -0,0 +1,194 @@ +/** + * Agent export (L07) - `GET /agents/:id/export?format=yaml|bundle`. + * + * YAML is the working-copy spec verbatim; the bundle (`lousho.agent-bundle`) + * adds what lives outside the YAML: labels, the prompt-registry text + * `spec.promptRef` points at, and the eval datasets the spec gates on - + * each fetched from the observe daemon, with a warning (never a failure) + * when the daemon can't serve it. Secret values never appear: the spec + * only ever holds `{ env }`/`{ secret }` references. + */ +import type { Express, Request, Response } from 'express'; +import { stringify as stringifyYaml } from 'yaml'; +import type { AgentSpecV2 } from 'lousho'; +import type { AgentBundle } from '../../shared/wireTypes'; +import { asyncRoute, paramId, sendError } from '../http/routeUtils'; +import { daemonHeaders, observeTarget } from '../observeProxy'; +import { workspaceOf } from '../workspace/middleware'; + +/** What `buildBundle` needs of an agent: the spec and its labels. */ +export interface ExportSource { + spec: AgentSpecV2; + labels: string[]; +} + +/** The daemon pieces a bundle pulls in, injectable for tests. */ +export interface BundleDeps { + fetch?: typeof fetch; +} + +const DAEMON_TIMEOUT_MS = 8000; + +/** `yaml.stringify(spec)` under a Studio-export header comment. */ +// fallow-ignore-next-line unused-export -- contract surface (L07); L09b's export sheet consumes it +export function exportYaml(spec: AgentSpecV2): string { + return `# Exported from Lousho Studio ${new Date().toISOString()}\n${stringifyYaml(spec, { lineWidth: 0 })}`; +} + +/** `name@ref` split; `undefined` when the ref has no `@` (the schema already rejects those). */ +function splitPromptRef(promptRef: string): { name: string; ref: string } | undefined { + const at = promptRef.indexOf('@'); + if (at <= 0 || at === promptRef.length - 1) return undefined; + return { name: promptRef.slice(0, at), ref: promptRef.slice(at + 1) }; +} + +/** A daemon `fetch` with the 8 s export deadline and the daemon's auth headers. */ +function daemonFetch(deps: BundleDeps): typeof fetch { + return deps.fetch ?? fetch; +} + +async function daemonGet(deps: BundleDeps, path: string): Promise { + const res = await daemonFetch(deps)(`${observeTarget()}${path}`, { + headers: daemonHeaders(false), + signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS), + }); + if (!res.ok) return undefined; + return res.json().catch(() => undefined); +} + +/** The prompt-registry entry `spec.promptRef` names, or a warning when the daemon can't serve it. */ +async function promptFor(spec: AgentSpecV2, deps: BundleDeps, warnings: string[]): Promise { + if (spec.promptRef === undefined) return []; + const ref = splitPromptRef(spec.promptRef); + if (ref === undefined) return []; + const body = (await daemonGet(deps, `/api/v1/prompts/${encodeURIComponent(ref.name)}/${encodeURIComponent(ref.ref)}`)) as + | { prompt?: { name?: unknown; version?: unknown; text?: unknown } } + | undefined; + const prompt = body?.prompt; + if (typeof prompt?.name !== 'string' || typeof prompt.version !== 'number' || typeof prompt.text !== 'string') { + warnings.push( + `Prompt '${spec.promptRef}' could not be read from the observe daemon; the bundle uses the inline instructions only.` + ); + return []; + } + return [{ name: prompt.name, version: prompt.version, content: prompt.text }]; +} + +/** `input`/`expected` as stored: parsed JSON when the daemon sent a JSON string, else the raw value. */ +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** One bundle eval entry's `examples` list. */ +type BundleExamples = NonNullable[number]['examples']; + +/** The examples of one eval dataset, or `[]` (with a warning) when the daemon can't serve it. */ +async function datasetExamples(name: string, deps: BundleDeps, warnings: string[]): Promise { + const body = (await daemonGet(deps, `/api/v1/datasets/${encodeURIComponent(name)}?limit=200`)) as + | { examples?: Array<{ input?: unknown; expected?: unknown }> } + | undefined; + if (!Array.isArray(body?.examples)) { + warnings.push(`Dataset '${name}' could not be read; its examples are not in the bundle.`); + return []; + } + return body.examples.map((example) => ({ + input: parseMaybeJson(example.input), + expected: example.expected === undefined || example.expected === null ? null : parseMaybeJson(example.expected), + })); +} + +/** The distinct dataset names `spec.evals` gates on (the section is pending in spec v2; read defensively). */ +function evalDatasetNames(spec: AgentSpecV2): string[] { + const evals = (spec as { evals?: unknown }).evals; + if (!Array.isArray(evals)) return []; + const names = new Set(); + for (const entry of evals) { + const name = (entry as { dataset?: unknown } | null)?.dataset; + if (typeof name === 'string' && name !== '') names.add(name); + } + return [...names]; +} + +/** The eval datasets section of the bundle; `undefined` when the spec gates on none. */ +async function evalsFor(spec: AgentSpecV2, deps: BundleDeps, warnings: string[]): Promise { + const names = evalDatasetNames(spec); + if (names.length === 0) return undefined; + const evals: NonNullable = []; + for (const name of names) { + const examples = await datasetExamples(name, deps, warnings); + if (examples.length > 0) evals.push({ dataset: name, examples }); + } + return evals.length > 0 ? evals : undefined; +} + +/** + * The `lousho.agent-bundle` for one agent: spec + labels + the daemon-side + * prompt/eval pieces, with a `warnings` line per piece the daemon could + * not serve. Daemon failures never fail the export. + */ +// fallow-ignore-next-line unused-export -- contract surface (L07); L09b's export sheet consumes it +export async function buildBundle( + source: ExportSource, + deps: BundleDeps = {} +): Promise<{ bundle: AgentBundle; warnings: string[] }> { + const warnings: string[] = []; + const [prompts, evals] = await Promise.all([promptFor(source.spec, deps, warnings), evalsFor(source.spec, deps, warnings)]); + const bundle: AgentBundle = { + kind: 'lousho.agent-bundle', + version: 1, + spec: source.spec, + labels: source.labels, + prompts, + ...(evals !== undefined ? { evals } : {}), + exportedAt: new Date().toISOString(), + }; + return { bundle, warnings }; +} + +function attachment(res: Response, filename: string): void { + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); +} + +/** + * `GET /agents/:id/export?format=yaml|bundle`. Handlers read the workspace's + * `AgentRepo` off `workspaceOf(req)` (the workspace seam); the observe + * daemon is reached through the global `fetch`. + */ +export function registerExportRoutes(app: Express, deps: BundleDeps = {}): void { + app.get( + '/agents/:id/export', + asyncRoute(async (req: Request, res: Response) => { + const format = req.query.format; + if (format !== 'yaml' && format !== 'bundle') { + sendError(res, 400, "format must be 'yaml' or 'bundle'"); + return; + } + const ws = workspaceOf(req); + const id = paramId(req); + const spec = await ws.repo.readSpec(id); + if (spec === undefined) { + sendError(res, 404, `No agent '${id}'`); + return; + } + if (format === 'yaml') { + res.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + attachment(res, `${id}.yaml`); + res.send(exportYaml(spec)); + return; + } + const meta = await ws.repo.readMeta(id); + const { bundle, warnings } = await buildBundle({ spec, labels: meta.labels }, deps); + if (warnings.length > 0) { + res.setHeader('X-Lousho-Export-Warnings', encodeURIComponent(JSON.stringify(warnings))); + } + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + attachment(res, `${id}.lousho-agent.json`); + res.send(JSON.stringify(bundle, null, 2)); + }) + ); +} diff --git a/apps/agent-forge/shared/wireTypes.ts b/apps/agent-forge/shared/wireTypes.ts index 27580066a..083d05463 100644 --- a/apps/agent-forge/shared/wireTypes.ts +++ b/apps/agent-forge/shared/wireTypes.ts @@ -972,3 +972,21 @@ export interface McpProbeTool { * 200, never HTTP errors). */ export type McpProbeResponse = { ok: true; tools: McpProbeTool[] } | { ok: false; error: string }; + +/** + * `GET /agents/:id/export?format=bundle`'s body (L07): the working-copy + * spec plus the pieces that live outside the YAML - labels, the prompt + * registry text `spec.promptRef` points at, and the eval datasets the + * spec gates on. Secret values never appear: the spec only ever holds + * `{ env }`/`{ secret }` references. + */ +export interface AgentBundle { + kind: 'lousho.agent-bundle'; + version: 1; + spec: AgentSpecV2; + labels: string[]; + prompts: Array<{ name: string; version: number; content: string }>; + evals?: Array<{ dataset: string; examples: Array<{ input: unknown; expected: unknown | null }> }>; + /** ISO time the bundle was built. */ + exportedAt: string; +} diff --git a/apps/agent-forge/src/persistence/importExport.ts b/apps/agent-forge/src/persistence/importExport.ts index aa532f2dc..6bc0ad895 100644 --- a/apps/agent-forge/src/persistence/importExport.ts +++ b/apps/agent-forge/src/persistence/importExport.ts @@ -37,11 +37,19 @@ function formatFromFilename(filename: string): ExportFormat { /** * Triggers a browser download of `text` as `filename` with MIME `type`, via * a Blob object URL (no server round-trip). `document`-dependent, so only - * usable in a browser/jsdom environment. Shared by {@link downloadSpec}, - * {@link downloadYaml} and the Code tab's Download buttons (A08). + * usable in a browser/jsdom environment. Shared by {@link downloadSpec} + * and the Code tab's Download buttons (A08). */ export function downloadText(text: string, filename: string, type: string): void { - const blob = new Blob([text], { type }); + downloadBlob(new Blob([text], { type }), filename); +} + +/** + * Triggers a browser download of an already-built `Blob` (the export + * routes' YAML/bundle responses, L07) via a temporary `` + + * object URL - the same mechanism {@link downloadText} uses. + */ +export function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); try { const anchor = document.createElement('a'); @@ -66,15 +74,6 @@ export function downloadSpec(spec: AgentSpecV2, filename: string): void { downloadText(text, filename, format === 'json' ? 'application/json' : 'text/yaml'); } -/** - * Triggers a browser download of `spec` as YAML text, whatever the filename's - * extension - the detail header's "Export YAML" (A12) until L07's export - * sheet replaces it. - */ -export function downloadYaml(spec: AgentSpecV2, filename: string): void { - downloadText(stringifyYaml(spec), filename, 'text/yaml'); -} - /** * Reads a `File`/`Blob`'s contents as text. Prefers the standard * `.text()` method (real browsers, modern Node); falls back to diff --git a/apps/agent-forge/src/runtime/runtimeClient.ts b/apps/agent-forge/src/runtime/runtimeClient.ts index e1d59a9dd..0e337efef 100644 --- a/apps/agent-forge/src/runtime/runtimeClient.ts +++ b/apps/agent-forge/src/runtime/runtimeClient.ts @@ -664,6 +664,33 @@ class RuntimeClient implements AgentApi { return body as DeployResult; } + /** + * `GET /agents/:id/export?format=yaml|bundle` (L07) as a Blob plus the + * server's `Content-Disposition` filename and the bundle's + * `X-Lousho-Export-Warnings` list (empty for YAML and a clean bundle). + */ + async downloadExport(agentId: string, format: 'yaml' | 'bundle'): Promise<{ blob: Blob; filename: string; warnings: string[] }> { + const res = await this.fetch(`/agents/${encodeURIComponent(agentId)}/export?format=${format}`, { method: 'GET' }); + if (!res.ok) { + const body = await res.json().catch(() => undefined); + throw apiError(body, `Export of '${agentId}' failed with ${res.status}`, res.status); + } + const disposition = res.headers.get('Content-Disposition') ?? ''; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] + ?? (format === 'yaml' ? `${agentId}.yaml` : `${agentId}.lousho-agent.json`); + const warningsHeader = res.headers.get('X-Lousho-Export-Warnings'); + let warnings: string[] = []; + if (warningsHeader !== null) { + try { + const parsed: unknown = JSON.parse(decodeURIComponent(warningsHeader)); + if (Array.isArray(parsed)) warnings = parsed.filter((w): w is string => typeof w === 'string'); + } catch { + // A malformed warnings header degrades to no warnings, never a failed download. + } + } + return { blob: await res.blob(), filename, warnings }; + } + /** * Subscribes to `WS /agents/:id/stream`. Returns an unsubscribe function. * Reconnects are NOT handled here - a dropped connection just stops diff --git a/apps/agent-forge/src/screens/agentDetail/AgentActionsMenu.tsx b/apps/agent-forge/src/screens/agentDetail/AgentActionsMenu.tsx index 6abe31bf2..1995770bc 100644 --- a/apps/agent-forge/src/screens/agentDetail/AgentActionsMenu.tsx +++ b/apps/agent-forge/src/screens/agentDetail/AgentActionsMenu.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Copy, Download, Sparkles, Trash2 } from 'lucide-react'; +import { Copy, FileDown, Package, Sparkles, Trash2 } from 'lucide-react'; import { openAuthor } from '../../author/authorEntry'; import type { AgentRecord } from '../../../shared/wireTypes'; @@ -7,7 +7,7 @@ import { navigate } from '../../router/router'; import { runtimeClient } from '../../runtime/runtimeClient'; import { useAppState } from '../../state/AppState'; import { errorMessage } from '../../components/errorMessage'; -import { downloadYaml } from '../../persistence/importExport'; +import { downloadBlob } from '../../persistence/importExport'; import { ConfirmDialog } from '../../ui/Dialog'; import { Menu, type MenuEntry } from '../../ui/Menu'; import { useToast } from '../../ui/Toast'; @@ -17,8 +17,8 @@ import { agentTabPath } from '../agents/agentTabs'; * The detail header's overflow menu (PLAN §11, A12): Change with AI (AA19's * Author entry point focused on this agent), Duplicate (a * `POST /agents { from: { copyOf } }` copy that opens its Overview), Export - * YAML (a client-side `` of the spec until L07's export sheet) - * and Delete agent… behind a ConfirmDialog. Passed to `AgentDetailHeader`'s + * YAML and Export bundle (L07's `GET /agents/:id/export` downloads) and + * Delete agent… behind a ConfirmDialog. Passed to `AgentDetailHeader`'s * `menu` prop by `AgentDetailRoute`. */ export function AgentActionsMenu({ record }: { record: AgentRecord }): JSX.Element { @@ -26,12 +26,13 @@ export function AgentActionsMenu({ record }: { record: AgentRecord }): JSX.Eleme const toast = useToast(); const { agentId, switchAgent } = useAppState(); const [duplicating, setDuplicating] = useState(false); + const [exporting, setExporting] = useState<'yaml' | 'bundle' | undefined>(undefined); const [confirmOpen, setConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(undefined); /** The reason every item is off while an action is in flight; `undefined` when idle. */ - const busyReason = duplicating ? 'Duplicating…' : deleting ? 'Deleting…' : undefined; + const busyReason = duplicating ? 'Duplicating…' : deleting ? 'Deleting…' : exporting !== undefined ? 'Exporting…' : undefined; async function duplicate(): Promise { setDuplicating(true); @@ -46,10 +47,18 @@ export function AgentActionsMenu({ record }: { record: AgentRecord }): JSX.Eleme } } - function exportYaml(): void { - const filename = `${id}.yaml`; - downloadYaml(spec, filename); - toast.show({ message: `Exported ${filename}`, tone: 'success' }); + async function downloadExport(format: 'yaml' | 'bundle'): Promise { + setExporting(format); + try { + const { blob, filename, warnings } = await runtimeClient.downloadExport(id, format); + downloadBlob(blob, filename); + for (const warning of warnings) toast.show({ message: warning, tone: 'warning' }); + if (warnings.length === 0) toast.show({ message: `Exported ${filename}`, tone: 'success' }); + } catch (error) { + toast.show({ message: `Export failed: ${errorMessage(error)}`, tone: 'error' }); + } finally { + setExporting(undefined); + } } async function confirmDelete(): Promise { @@ -89,12 +98,20 @@ export function AgentActionsMenu({ record }: { record: AgentRecord }): JSX.Eleme onSelect: () => void duplicate(), }, { - id: 'export', + id: 'export-yaml', label: 'Export YAML', - icon: Download, + icon: FileDown, + disabled: busyReason !== undefined, + disabledReason: busyReason, + onSelect: () => void downloadExport('yaml'), + }, + { + id: 'export-bundle', + label: 'Export bundle', + icon: Package, disabled: busyReason !== undefined, disabledReason: busyReason, - onSelect: exportYaml, + onSelect: () => void downloadExport('bundle'), }, { id: 'sep-actions', separator: true }, { diff --git a/apps/agent-forge/src/screens/agentDetail/__tests__/AgentActionsMenu.test.tsx b/apps/agent-forge/src/screens/agentDetail/__tests__/AgentActionsMenu.test.tsx index 9132e9ac1..39df26d55 100644 --- a/apps/agent-forge/src/screens/agentDetail/__tests__/AgentActionsMenu.test.tsx +++ b/apps/agent-forge/src/screens/agentDetail/__tests__/AgentActionsMenu.test.tsx @@ -8,12 +8,14 @@ import { ToastProvider } from '../../../ui/Toast'; const mocks = vi.hoisted(() => ({ createAgentRecord: vi.fn(), deleteAgent: vi.fn(), + downloadExport: vi.fn(), })); vi.mock('../../../runtime/runtimeClient', () => ({ runtimeClient: { createAgentRecord: mocks.createAgentRecord, deleteAgent: mocks.deleteAgent, + downloadExport: mocks.downloadExport, }, })); @@ -91,6 +93,8 @@ function toastTexts(): string { beforeEach(() => { mocks.createAgentRecord.mockReset(); mocks.deleteAgent.mockReset(); + mocks.downloadExport.mockReset(); + mocks.downloadExport.mockResolvedValue({ blob: new Blob(['spec']), filename: 'support-bot.yaml', warnings: [] }); takePendingOpen(); // drain any queued Author open appState.agentId = 'other-agent'; window.history.replaceState(null, '', '/#/agents/support-bot/overview'); @@ -105,7 +109,8 @@ describe('AgentActionsMenu', () => { expect(labels[0]).toContain('Change with AI'); expect(labels[1]).toContain('Duplicate'); expect(labels[2]).toContain('Export YAML'); - expect(labels[3]).toContain('Delete agent…'); + expect(labels[3]).toContain('Export bundle'); + expect(labels[4]).toContain('Delete agent…'); expect(menu.querySelectorAll('[role="separator"]').length).toBe(1); expect(menuItem('Delete agent…').classList.contains('menu-item-danger')).toBe(true); }); @@ -135,7 +140,7 @@ describe('AgentActionsMenu', () => { expect(window.location.hash).toBe('#/agents/support-bot-copy/overview'); }); - it('exports the spec as .yaml and toasts the filename', async () => { + it('exports the spec as .yaml via the export route and toasts the filename', async () => { const originalCreate = URL.createObjectURL; const originalRevoke = URL.revokeObjectURL; const originalClick = HTMLAnchorElement.prototype.click; @@ -157,6 +162,7 @@ describe('AgentActionsMenu', () => { HTMLAnchorElement.prototype.click = originalClick; } + expect(mocks.downloadExport).toHaveBeenCalledWith('support-bot', 'yaml'); expect(downloads).toEqual(['support-bot.yaml']); expect(toastTexts()).toContain('Exported support-bot.yaml'); }); diff --git a/apps/agent-forge/src/screens/agentDetail/__tests__/exportMenu.test.tsx b/apps/agent-forge/src/screens/agentDetail/__tests__/exportMenu.test.tsx new file mode 100644 index 000000000..3ca3ebdec --- /dev/null +++ b/apps/agent-forge/src/screens/agentDetail/__tests__/exportMenu.test.tsx @@ -0,0 +1,143 @@ +import { act } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentRecord } from '../../../../shared/wireTypes'; +import { render } from '../../../ui/__tests__/render'; +import { ToastProvider } from '../../../ui/Toast'; + +const mocks = vi.hoisted(() => ({ + downloadExport: vi.fn(), +})); + +vi.mock('../../../runtime/runtimeClient', () => ({ + runtimeClient: { + downloadExport: mocks.downloadExport, + }, +})); + +const appState = vi.hoisted(() => ({ + agentId: 'support-bot', + agents: [{ id: 'support-bot' }], + switchAgent: vi.fn<[string], Promise>(() => Promise.resolve()), +})); +vi.mock('../../../state/AppState', () => ({ useAppState: () => appState })); + +import { AgentActionsMenu } from '../AgentActionsMenu'; + +const record: AgentRecord = { + id: 'support-bot', + spec: { version: 2, name: 'Support bot', model: 'mock/mock-1', instructions: 'Be helpful.' }, + meta: { + schemaVersion: 1, + enabled: true, + disabledAt: null, + disabledReason: null, + failureStreak: 0, + labels: [], + publishedVersion: null, + createdAt: '2026-10-29T09:00:00Z', + updatedAt: '2026-10-29T09:00:00Z', + }, + layout: { schemaVersion: 1, nodes: {}, notes: [] }, + updatedAt: '2026-10-29T10:00:00Z', + latestVersion: null, +}; + +function renderMenu(): void { + render( + + + , + ); +} + +function openMenu(): void { + const trigger = document.querySelector('button[aria-haspopup="menu"]')!; + act(() => trigger.click()); +} + +function menuItem(label: string): HTMLElement { + const menu = document.querySelector('[role="menu"]')!; + return [...menu.querySelectorAll('[role^="menuitem"]')].find((el) => el.textContent?.includes(label))!; +} + +async function clickItem(label: string): Promise { + await act(async () => { + menuItem(label).click(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function toastTexts(): string { + return [...document.querySelectorAll('.toast-message')].map((el) => el.textContent ?? '').join(' | '); +} + +/** Records anchor downloads the way jsdom cannot. */ +function captureDownloads(): { downloads: string[]; restore: () => void } { + const downloads: string[] = []; + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + const originalClick = HTMLAnchorElement.prototype.click; + URL.createObjectURL = () => 'blob:mock'; + URL.revokeObjectURL = () => {}; + HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + downloads.push(this.download); + }; + return { + downloads, + restore: () => { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + HTMLAnchorElement.prototype.click = originalClick; + }, + }; +} + +beforeEach(() => { + mocks.downloadExport.mockReset(); + mocks.downloadExport.mockResolvedValue({ blob: new Blob(['{}']), filename: 'support-bot.lousho-agent.json', warnings: [] }); +}); + +describe('export menu items (L07)', () => { + it('"Export bundle" downloads via downloadExport(id, "bundle") under the bundle filename', async () => { + const { downloads, restore } = captureDownloads(); + try { + renderMenu(); + openMenu(); + await clickItem('Export bundle'); + } finally { + restore(); + } + expect(mocks.downloadExport).toHaveBeenCalledWith('support-bot', 'bundle'); + expect(downloads).toEqual(['support-bot.lousho-agent.json']); + expect(toastTexts()).toContain('Exported support-bot.lousho-agent.json'); + }); + + it('shows each bundle warning as a warning toast, one line each', async () => { + mocks.downloadExport.mockResolvedValue({ + blob: new Blob(['{}']), + filename: 'support-bot.lousho-agent.json', + warnings: ['Prompt \'support@prod\' could not be read.', 'Dataset \'ds1\' could not be read.'], + }); + const { restore } = captureDownloads(); + try { + renderMenu(); + openMenu(); + await clickItem('Export bundle'); + } finally { + restore(); + } + expect(toastTexts()).toContain("Prompt 'support@prod' could not be read."); + expect(toastTexts()).toContain("Dataset 'ds1' could not be read."); + expect(toastTexts()).not.toContain('Exported support-bot.lousho-agent.json'); + }); + + it('shows an error toast when the export fails', async () => { + mocks.downloadExport.mockRejectedValue(new Error('disk full')); + renderMenu(); + openMenu(); + await clickItem('Export YAML'); + expect(toastTexts()).toContain('Export failed: disk full'); + }); +});