Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
119 changes: 119 additions & 0 deletions apps/agent-forge/server/__tests__/agentRequirements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* `GET /agents/:id/requirements` (AA13): the readiness rows of the saved
* spec v2 - 200 `Requirement[]`, 404 for an unknown id. `GET /agents/:id`
* carries the same list on `record.requirements` so the detail header and
* the Author's proposal cards can read it without a second call.
*/
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 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 { SecretsStore } from '../secretsStore';
import { createFsAgentStore } from '../../src/persistence/fsAgentStore';
import { AgentRepo } from '../storage/agentRepo';
import type { AgentStore } from '../../src/persistence/AgentStore';
import type { Requirement } from '../../shared/authorWire';

const SPEC: AgentSpecV2 = {
version: 2,
name: 'needs things',
model: 'anthropic/claude-x',
instructions: 'Check the queues.',
mcpServers: {
gh: { command: 'gh-mcp', env: { GITHUB_TOKEN: { secret: 'GITHUB_TOKEN' } } },
},
};

describe('GET /agents/:id/requirements', () => {
let baseDir: string;
let agentStore: AgentStore;
let runManager: RunManager;
let repo: AgentRepo;
let secrets: SecretsStore;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa13-reqs-'));
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),
});
secrets = new SecretsStore(baseDir);
// Deterministic env: nothing the spec could resolve leaks in from the host.
vi.stubEnv('ANTHROPIC_API_KEY', '');
vi.stubEnv('OPENAI_API_KEY', '');
});

afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(baseDir, { recursive: true, force: true });
});

const app = () => createApp({ agentStore, runManager, baseDir, secretsStore: secrets });
const putSpec = (id: string, spec: unknown = SPEC) => request(app()).put(`/agents/${id}`).send({ spec });

it('answers the spec\'s Requirement[] - missing rows first', async () => {
expect((await putSpec('ops')).status).toBe(204);
const res = await request(app()).get('/agents/ops/requirements');
expect(res.status).toBe(200);
const rows = res.body as Requirement[];
const map = new Map(rows.map((row) => [row.id, row]));
expect(map.get('provider:anthropic')).toMatchObject({
kind: 'provider_key',
status: 'missing',
label: 'Anthropic API key',
fix: { action: 'open_settings', target: '/settings' },
});
expect(map.get('secret:GITHUB_TOKEN')).toMatchObject({
kind: 'secret',
status: 'missing',
fix: { action: 'add_secret', target: 'GITHUB_TOKEN' },
});
expect(map.get('mcp:gh')).toMatchObject({
kind: 'mcp',
status: 'unknown',
fix: { action: 'probe', target: 'gh' },
});
// No secret value ever appears in the payload.
expect(JSON.stringify(rows)).not.toContain('ghp_');
});

it('marks refs satisfied by the store and the env as ok', async () => {
secrets.setSecret('GITHUB_TOKEN', 'ghp_route_test');
vi.stubEnv('ANTHROPIC_API_KEY', 'sk-route');
expect((await putSpec('ops')).status).toBe(204);
const res = await request(app()).get('/agents/ops/requirements');
const map = new Map((res.body as Requirement[]).map((row) => [row.id, row]));
expect(map.get('secret:GITHUB_TOKEN')?.status).toBe('ok');
expect(map.get('provider:anthropic')?.status).toBe('ok');
expect(JSON.stringify(res.body)).not.toContain('ghp_route_test');
});

it('GET /agents/:id carries the same list on record.requirements', async () => {
expect((await putSpec('ops')).status).toBe(204);
const res = await request(app()).get('/agents/ops');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.requirements)).toBe(true);
const map = new Map((res.body.requirements as Requirement[]).map((row) => [row.id, row]));
expect(map.get('provider:anthropic')?.status).toBe('missing');
expect(map.get('secret:GITHUB_TOKEN')?.status).toBe('missing');
});

it('404 { error: \'Agent not found.\' } for an unknown id', async () => {
const res = await request(app()).get('/agents/nope/requirements');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Agent not found.' });
});
});
5 changes: 3 additions & 2 deletions apps/agent-forge/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* POST /agents -> create blank/template/copy (body: CreateAgentRequest), 201 AgentRecord
* GET /templates -> TemplateSummary[] - the SDK's shipped templates (templates/templateCatalog.ts)
* GET /templates/:id -> TemplateDetail (summary + spec v2 + README) | 400/404
* GET /agents/:id -> AgentRecord | 404; ?view=legacy -> v1 AgentSpec
* GET /agents/:id -> AgentRecord (+ requirements) | 404; ?view=legacy -> v1 AgentSpec
* GET /agents/:id/requirements -> Requirement[] readiness rows of the spec | 404 (routes/agentRoutes.ts)
* PUT /agents/:id -> save the spec v2 working copy (body: { spec } or a bare v1 AgentSpec), 204
* PATCH /agents/:id/meta -> toggle enabled / set labels (body: PatchAgentMetaRequest), 200 AgentMeta
* PUT /agents/:id/layout -> save the canvas layout (body: CanvasLayout), 204
Expand Down Expand Up @@ -953,7 +954,7 @@ export function createApp(options: CreateAppOptions): Express {
// Saved-agent list/record/save/delete (routes/agentRoutes.ts - the v2
// wire shapes, with ?view=legacy for the still-v1 canvas; stores come
// from workspaceOf(req), the workspace seam).
registerAgentRoutes(app, { templates });
registerAgentRoutes(app, { templates, secrets });
// Draft routes: get/create/withdraw/approve/reject the per-agent pending
// ChangeDraft (server/routes/draftRoutes.ts - stores come from
// workspaceOf(req), the workspace seam).
Expand Down
Loading
Loading