457 lines
21 KiB
TypeScript
457 lines
21 KiB
TypeScript
/**
|
|
* Agent entity REST API Route Tests (UX-Refactor Phase 3, S09/S18 / gate B3).
|
|
*
|
|
* Covers the 7 routes in routes/agents.ts:
|
|
* GET /api/agents list + derived status/lastRunAt/successRate
|
|
* POST /api/agents create (UNGATED — CLAUDE.md §1 moat: agents
|
|
* are free on every tier; blueprint hard-gate
|
|
* validation + elevated-surface audit)
|
|
* GET /api/agents/:id one agent
|
|
* PATCH /api/agents/:id partial update (immutable id/createdAt,
|
|
* blank-field rejection, audit on
|
|
* connector/MCP changes)
|
|
* POST /api/agents/:id/run C23 one-shot delegation to /api/fleet/spawn
|
|
* POST /api/agents/:id/pause acts ONLY on the agent's OWN recorded run
|
|
* GET /api/agents/:id/traces execution_traces read via the agent:{id} tag
|
|
*
|
|
* The fleet spawn target is a STUB route registered on the test server — the
|
|
* tests assert the delegation wiring (payload mapping + trace tagging), not the
|
|
* agent loop itself. Trace derivation uses a real ExecutionTraceStore over an
|
|
* in-memory mind. The REAL agentRoutes plugin (routes/agent.ts) registers
|
|
* BEFORE agentEntityRoutes — same order as local/index.ts — so the static
|
|
* /api/agents/active precedence is asserted against the real route, not a stub.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { MindDB, ExecutionTraceStore } from '@waggle/core';
|
|
import { agentRoutes } from '../../src/local/routes/agent.js';
|
|
import { agentEntityRoutes } from '../../src/local/routes/agents.js';
|
|
import type { WorkspaceSession } from '../../src/local/workspace-sessions.js';
|
|
|
|
interface FakeSession {
|
|
workspaceId: string;
|
|
personaId?: string | null;
|
|
/** Typed from the REAL session union ('active' | 'paused' | 'error') so the
|
|
* fake cannot drift from workspace-sessions.ts. */
|
|
status: WorkspaceSession['status'];
|
|
}
|
|
|
|
function createTestServer(opts: {
|
|
dataDir: string;
|
|
traceStore: ExecutionTraceStore;
|
|
sessions?: FakeSession[];
|
|
pausedIds?: string[];
|
|
spawnCalls?: Array<Record<string, unknown>>;
|
|
auditRecords?: Array<Record<string, unknown>>;
|
|
}) {
|
|
const server = Fastify({ logger: false });
|
|
server.decorate('localConfig', { dataDir: opts.dataDir });
|
|
server.decorate('traceStore', opts.traceStore);
|
|
server.decorate('sessionManager', {
|
|
getActive: () => opts.sessions ?? [],
|
|
pause: (wsId: string) => {
|
|
opts.pausedIds?.push(wsId);
|
|
return true;
|
|
},
|
|
});
|
|
server.decorate('auditStore', {
|
|
record: (input: Record<string, unknown>) => {
|
|
opts.auditRecords?.push(input);
|
|
return input;
|
|
},
|
|
});
|
|
// Minimal agentState so the REAL agentRoutes plugin registers (it reads
|
|
// costTracker at register time). No subagentOrchestrator → /api/agents/active
|
|
// returns the empty orchestrator state.
|
|
server.decorate('agentState', {
|
|
costTracker: {
|
|
getStats: () => ({ totalInputTokens: 0, totalOutputTokens: 0, estimatedCost: 0, turns: 0 }),
|
|
formatSummary: () => '',
|
|
},
|
|
currentModel: 'test-model',
|
|
sessionHistories: new Map(),
|
|
});
|
|
// Stub of the real executor path POST /api/fleet/spawn (fleet.ts).
|
|
server.post('/api/fleet/spawn', async (request) => {
|
|
const body = request.body as Record<string, unknown>;
|
|
opts.spawnCalls?.push(body);
|
|
return {
|
|
id: body.parentWorkspaceId ?? 'default-workspace',
|
|
workspaceId: body.parentWorkspaceId ?? 'default-workspace',
|
|
sessionId: `spawn-${Date.now()}`,
|
|
status: 'active',
|
|
task: body.task,
|
|
model: body.model,
|
|
};
|
|
});
|
|
// Same order as local/index.ts: agentRoutes (static /api/agents/active)
|
|
// first, then the /:id param plugin.
|
|
server.register(agentRoutes);
|
|
server.register(agentEntityRoutes);
|
|
return server;
|
|
}
|
|
|
|
const VALID_BODY = {
|
|
name: 'Research Scout',
|
|
goal: 'Track competitor launches weekly',
|
|
model: 'claude-haiku-4-5',
|
|
autonomyLevel: 'guided',
|
|
memoryScopes: ['workspace'],
|
|
type: 'workspace',
|
|
personaId: 'researcher',
|
|
workspaceIds: ['ws-a'],
|
|
};
|
|
|
|
describe('Agent entity routes (Phase 3)', () => {
|
|
let db: MindDB;
|
|
let traceStore: ExecutionTraceStore;
|
|
let dataDir: string;
|
|
let server: ReturnType<typeof Fastify>;
|
|
let spawnCalls: Array<Record<string, unknown>>;
|
|
let pausedIds: string[];
|
|
let auditRecords: Array<Record<string, unknown>>;
|
|
let sessions: FakeSession[];
|
|
|
|
beforeEach(() => {
|
|
dataDir = path.join(os.tmpdir(), `waggle-agents-${randomUUID()}`);
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
db = new MindDB(':memory:');
|
|
traceStore = new ExecutionTraceStore(db);
|
|
spawnCalls = [];
|
|
pausedIds = [];
|
|
auditRecords = [];
|
|
sessions = [];
|
|
server = createTestServer({ dataDir, traceStore, sessions, pausedIds, spawnCalls, auditRecords });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await server.close();
|
|
db.close();
|
|
try {
|
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
} catch { /* Windows handle lingering — ignore */ }
|
|
});
|
|
|
|
async function createAgent(body: Record<string, unknown> = VALID_BODY) {
|
|
const res = await server.inject({ method: 'POST', url: '/api/agents', payload: body });
|
|
expect(res.statusCode).toBe(201);
|
|
return res.json().agent;
|
|
}
|
|
|
|
/** One-shot run helper — also pushes the spawn's session into the fake
|
|
* session manager so liveStatus/pause have something to act on. */
|
|
async function runAgent(agent: { id: string }, payload: Record<string, unknown> = {}) {
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload });
|
|
expect(res.statusCode).toBe(200);
|
|
return res.json() as { sessionId: string; workspaceId: string };
|
|
}
|
|
|
|
it('boots and lists an empty index', async () => {
|
|
const res = await server.inject({ method: 'GET', url: '/api/agents' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ agents: [], count: 0 });
|
|
});
|
|
|
|
it('POST creates an agent with defaults + agents.json persisted', async () => {
|
|
const agent = await createAgent();
|
|
expect(agent.id).toMatch(/^agent_/);
|
|
expect(agent.status).toBe('idle');
|
|
expect(agent.type).toBe('workspace');
|
|
expect(agent.goal).toBe(VALID_BODY.goal);
|
|
expect(typeof agent.createdAt).toBe('string');
|
|
// The full effective surface is the stored record (no hidden access).
|
|
const file = JSON.parse(fs.readFileSync(path.join(dataDir, 'agents.json'), 'utf-8'));
|
|
expect(file.agents).toHaveLength(1);
|
|
expect(file.agents[0].memoryScopes).toEqual(['workspace']);
|
|
// successRate/lastRunAt are NEVER persisted (B3).
|
|
expect('successRate' in file.agents[0]).toBe(false);
|
|
expect('lastRunAt' in file.agents[0]).toBe(false);
|
|
});
|
|
|
|
it('POST enforces the blueprint hard gate (goal/model/memoryScopes/autonomyLevel)', async () => {
|
|
const cases: Array<Record<string, unknown>> = [
|
|
{ ...VALID_BODY, goal: undefined },
|
|
{ ...VALID_BODY, model: undefined },
|
|
{ ...VALID_BODY, memoryScopes: undefined },
|
|
{ ...VALID_BODY, memoryScopes: [] },
|
|
{ ...VALID_BODY, memoryScopes: ['enterprise'] }, // C36: not a Scope
|
|
{ ...VALID_BODY, autonomyLevel: undefined },
|
|
{ ...VALID_BODY, autonomyLevel: 'yolo' },
|
|
{ ...VALID_BODY, type: 'bogus' },
|
|
{ ...VALID_BODY, name: ' ' },
|
|
];
|
|
for (const payload of cases) {
|
|
const res = await server.inject({ method: 'POST', url: '/api/agents', payload });
|
|
expect(res.statusCode).toBe(400);
|
|
}
|
|
});
|
|
|
|
it('POST is NOT tier-gated — agents are free on every tier (CLAUDE.md §1 moat)', async () => {
|
|
// FREE-tier config present: creation must still succeed — the executor
|
|
// (fleet spawn) is free for all tiers, so a paid-tier gate here would be
|
|
// an incoherent surface.
|
|
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify({ tier: 'FREE' }), 'utf-8');
|
|
const res = await server.inject({ method: 'POST', url: '/api/agents', payload: VALID_BODY });
|
|
expect(res.statusCode).toBe(201);
|
|
});
|
|
|
|
it('POST records an elevated-surface audit entry when connectors/MCPs are claimed (never riskLevel critical — M2)', async () => {
|
|
await createAgent({ ...VALID_BODY, connectorIds: ['slack'], mcpIds: ['github-mcp'] });
|
|
expect(auditRecords).toHaveLength(1);
|
|
expect(auditRecords[0].approvalClass).toBe('elevated');
|
|
expect(auditRecords[0].riskLevel).toBe('medium');
|
|
// No connectors/mcps → no audit entry.
|
|
await createAgent({ ...VALID_BODY, name: 'Plain' });
|
|
expect(auditRecords).toHaveLength(1);
|
|
});
|
|
|
|
it('GET /:id returns the agent; unknown id 404s', async () => {
|
|
const agent = await createAgent();
|
|
const one = await server.inject({ method: 'GET', url: `/api/agents/${agent.id}` });
|
|
expect(one.statusCode).toBe(200);
|
|
expect(one.json().agent.id).toBe(agent.id);
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents/agent_unknown' })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('GET /api/agents/active is NOT shadowed — the real agentRoutes orchestrator state answers, not /:id', async () => {
|
|
await createAgent();
|
|
const res = await server.inject({ method: 'GET', url: '/api/agents/active' });
|
|
expect(res.statusCode).toBe(200);
|
|
// The orchestrator-state shape from routes/agent.ts (no orchestrator
|
|
// running → empty arrays) — NOT a 404 and NOT an AgentRecord envelope.
|
|
expect(res.json()).toEqual({ workers: [], active: [] });
|
|
});
|
|
|
|
it('PATCH updates fields and preserves immutable id/createdAt', async () => {
|
|
const agent = await createAgent();
|
|
const res = await server.inject({
|
|
method: 'PATCH', url: `/api/agents/${agent.id}`,
|
|
payload: { name: 'Renamed', autonomyLevel: 'high', status: 'archived' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const updated = res.json().agent;
|
|
expect(updated.name).toBe('Renamed');
|
|
expect(updated.autonomyLevel).toBe('high');
|
|
expect(updated.status).toBe('archived');
|
|
expect(updated.id).toBe(agent.id);
|
|
expect(updated.createdAt).toBe(agent.createdAt);
|
|
});
|
|
|
|
it('PATCH rejects invalid enum values, blanked required fields + unknown id', async () => {
|
|
const agent = await createAgent();
|
|
expect((await server.inject({ method: 'PATCH', url: `/api/agents/${agent.id}`, payload: { autonomyLevel: 'bogus' } })).statusCode).toBe(400);
|
|
expect((await server.inject({ method: 'PATCH', url: `/api/agents/${agent.id}`, payload: { status: 'bogus' } })).statusCode).toBe(400);
|
|
// Required fields may change but never blank out (mirrors create).
|
|
expect((await server.inject({ method: 'PATCH', url: `/api/agents/${agent.id}`, payload: { goal: ' ' } })).statusCode).toBe(400);
|
|
expect((await server.inject({ method: 'PATCH', url: '/api/agents/agent_unknown', payload: { name: 'X' } })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('PATCH records an elevated-surface audit entry when connector/MCP claims change', async () => {
|
|
const agent = await createAgent(); // created clean — no audit entry yet
|
|
expect(auditRecords).toHaveLength(0);
|
|
|
|
// create-clean-then-patch-in must not bypass the audit trail.
|
|
const res = await server.inject({
|
|
method: 'PATCH', url: `/api/agents/${agent.id}`,
|
|
payload: { connectorIds: ['slack'] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(auditRecords).toHaveLength(1);
|
|
expect(auditRecords[0].approvalClass).toBe('elevated');
|
|
expect(auditRecords[0].riskLevel).toBe('medium');
|
|
expect(String(auditRecords[0].detail)).toContain('updated');
|
|
|
|
// PATCHing the SAME lists again is not a surface change — no new entry.
|
|
await server.inject({
|
|
method: 'PATCH', url: `/api/agents/${agent.id}`,
|
|
payload: { connectorIds: ['slack'] },
|
|
});
|
|
expect(auditRecords).toHaveLength(1);
|
|
});
|
|
|
|
it('run delegates to the real fleet spawn with the agent→spawn body mapping (C23)', async () => {
|
|
const agent = await createAgent();
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload: {} });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().sessionId).toMatch(/^spawn-/);
|
|
expect(spawnCalls).toHaveLength(1);
|
|
expect(spawnCalls[0]).toMatchObject({
|
|
task: VALID_BODY.goal, // no input → the agent's goal is the task
|
|
persona: 'researcher',
|
|
model: 'claude-haiku-4-5',
|
|
parentWorkspaceId: 'ws-a', // single workspace → auto-selected
|
|
});
|
|
});
|
|
|
|
it('run tags an execution trace with agent:{id} (B3 derived-at-read key)', async () => {
|
|
const agent = await createAgent();
|
|
await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload: { input: 'Scan today' } });
|
|
const traces = traceStore.queryParsed({ limit: 10 });
|
|
expect(traces).toHaveLength(1);
|
|
expect(traces[0].payload.tags).toContain(`agent:${agent.id}`);
|
|
expect(traces[0].payload.input).toBe('Scan today');
|
|
expect(traces[0].outcome).toBe('pending'); // spawn loop has no completion hook
|
|
});
|
|
|
|
it('run 400s on workspace ambiguity and on a non-assigned workspace (C23)', async () => {
|
|
const agent = await createAgent({ ...VALID_BODY, workspaceIds: ['ws-a', 'ws-b'] });
|
|
const ambiguous = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload: {} });
|
|
expect(ambiguous.statusCode).toBe(400);
|
|
expect(ambiguous.json().error).toBe('workspace_ambiguous');
|
|
expect(ambiguous.json().workspaceIds).toEqual(['ws-a', 'ws-b']);
|
|
|
|
const wrong = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload: { workspaceId: 'ws-c' } });
|
|
expect(wrong.statusCode).toBe(400);
|
|
expect(wrong.json().error).toBe('workspace_not_assigned');
|
|
|
|
const picked = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/run`, payload: { workspaceId: 'ws-b' } });
|
|
expect(picked.statusCode).toBe(200);
|
|
expect(spawnCalls.at(-1)?.parentWorkspaceId).toBe('ws-b');
|
|
});
|
|
|
|
it('run 404s for an unknown agent; rejects a traversal workspaceId', async () => {
|
|
expect((await server.inject({ method: 'POST', url: '/api/agents/agent_unknown/run', payload: {} })).statusCode).toBe(404);
|
|
const agent = await createAgent();
|
|
const res = await server.inject({
|
|
method: 'POST', url: `/api/agents/${agent.id}/run`,
|
|
payload: { workspaceId: '../../etc' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('pause stops the agent\'s OWN recorded run only (no workspace+persona heuristic)', async () => {
|
|
const agent = await createAgent();
|
|
await runAgent(agent);
|
|
sessions.push({ workspaceId: 'ws-a', personaId: 'researcher', status: 'active' });
|
|
// An unrelated active session in ANOTHER workspace must never be touched.
|
|
sessions.push({ workspaceId: 'ws-other', personaId: 'researcher', status: 'active' });
|
|
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ ok: true, paused: 1 });
|
|
expect(pausedIds).toEqual(['ws-a']);
|
|
|
|
// The run is consumed — a second pause has nothing to act on.
|
|
expect((await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('pause never touches a session the agent did not spawn — even a same-workspace+persona co-tenant', async () => {
|
|
const agent = await createAgent();
|
|
// The OLD heuristic matched workspace+persona and would have aborted this
|
|
// co-tenant chat session. With explicit run tracking: no recorded run → 404.
|
|
sessions.push({ workspaceId: 'ws-a', personaId: 'researcher', status: 'active' });
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` });
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.json().error).toMatch(/no recorded run/i);
|
|
expect(pausedIds).toEqual([]);
|
|
});
|
|
|
|
it('agent with no workspaceIds is pausable after /run (default-workspace fallback)', async () => {
|
|
const agent = await createAgent({ ...VALID_BODY, name: 'NoWs', workspaceIds: undefined });
|
|
const run = await runAgent(agent);
|
|
expect(run.workspaceId).toBe('default-workspace'); // fleet fallback
|
|
sessions.push({ workspaceId: 'default-workspace', personaId: 'researcher', status: 'active' });
|
|
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pausedIds).toEqual(['default-workspace']);
|
|
});
|
|
|
|
it('pause 404s when the recorded run has no active session left', async () => {
|
|
const agent = await createAgent();
|
|
await runAgent(agent);
|
|
// No session in the fake manager at all → nothing to pause.
|
|
expect((await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('GET list overlays live status from the agent\'s OWN recorded run (B3 read-derived)', async () => {
|
|
const agent = await createAgent();
|
|
// A co-tenant active session WITHOUT a recorded run must NOT read as running.
|
|
sessions.push({ workspaceId: 'ws-a', personaId: 'researcher', status: 'active' });
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents' })).json().agents[0].status).toBe('idle');
|
|
|
|
await runAgent(agent);
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents' })).json().agents[0].status).toBe('running');
|
|
sessions[0].status = 'paused';
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents' })).json().agents[0].status).toBe('paused');
|
|
// Stored status comes back once the session is gone.
|
|
sessions.length = 0;
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents' })).json().agents[0].status).toBe(agent.status);
|
|
});
|
|
|
|
it('errored session: stored status shows and pause skips it (real status union incl. error)', async () => {
|
|
const agent = await createAgent();
|
|
await runAgent(agent);
|
|
sessions.push({ workspaceId: 'ws-a', personaId: 'researcher', status: 'error' });
|
|
|
|
// liveStatus: 'error' is neither active nor paused → stored status applies.
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents' })).json().agents[0].status).toBe('idle');
|
|
|
|
// pause: no ACTIVE session → 404, and the errored session is untouched.
|
|
const res = await server.inject({ method: 'POST', url: `/api/agents/${agent.id}/pause` });
|
|
expect(res.statusCode).toBe(404);
|
|
expect(pausedIds).toEqual([]);
|
|
});
|
|
|
|
it('derives successRate and lastRunAt from tagged execution traces only (B3 + tagLike filter)', async () => {
|
|
const agent = await createAgent();
|
|
const tag = [`agent:${agent.id}`];
|
|
const t1 = traceStore.start({ sessionId: 's1', input: 'a', tags: tag });
|
|
traceStore.finalize(t1, { outcome: 'success', output: 'ok' });
|
|
const t2 = traceStore.start({ sessionId: 's2', input: 'b', tags: tag });
|
|
traceStore.finalize(t2, { outcome: 'success', output: 'ok' });
|
|
const t3 = traceStore.start({ sessionId: 's3', input: 'c', tags: tag });
|
|
traceStore.finalize(t3, { outcome: 'corrected', output: 'meh' });
|
|
traceStore.start({ sessionId: 's4', input: 'd', tags: tag }); // pending — excluded from rate
|
|
|
|
// Unrelated traffic must not pollute the derivation: an untagged chat
|
|
// trace and ANOTHER agent's failing trace (both would skew the rate if
|
|
// the agent:{id} tag filter were not applied).
|
|
const chat = traceStore.start({ sessionId: 'chat-1', input: 'unrelated chat' });
|
|
traceStore.finalize(chat, { outcome: 'abandoned', output: '' });
|
|
const other = traceStore.start({ sessionId: 's9', input: 'other agent', tags: ['agent:agent_other'] });
|
|
traceStore.finalize(other, { outcome: 'abandoned', output: '' });
|
|
|
|
const res = await server.inject({ method: 'GET', url: '/api/agents' });
|
|
const view = res.json().agents[0];
|
|
expect(view.successRate).toBeCloseTo(2 / 3, 5);
|
|
expect(typeof view.lastRunAt).toBe('string');
|
|
// B6: route-boundary timestamps are ISO-8601 UTC (SQLite 'YYYY-MM-DD
|
|
// HH:MM:SS' would parse as LOCAL time in a browser).
|
|
expect(view.lastRunAt).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
|
|
});
|
|
|
|
it('GET /:id/traces returns only this agent\'s tagged traces, newest first', async () => {
|
|
const agent = await createAgent();
|
|
const other = await createAgent({ ...VALID_BODY, name: 'Other' });
|
|
const t1 = traceStore.start({ sessionId: 's1', input: 'mine', tags: [`agent:${agent.id}`] });
|
|
traceStore.finalize(t1, {
|
|
outcome: 'success', output: 'ok',
|
|
toolCalls: [{ tool: 'web_search', args: {}, result: 'r', ok: true, durationMs: 5, timestamp: new Date().toISOString() }],
|
|
});
|
|
traceStore.start({ sessionId: 's2', input: 'theirs', tags: [`agent:${other.id}`] });
|
|
|
|
const res = await server.inject({ method: 'GET', url: `/api/agents/${agent.id}/traces` });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.count).toBe(1);
|
|
expect(body.traces[0].sessionId).toBe('s1');
|
|
expect(body.traces[0].outcome).toBe('success');
|
|
expect(body.traces[0].tools).toEqual(['web_search']);
|
|
expect(body.traces[0].ts).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/); // B6 normalized
|
|
expect((await server.inject({ method: 'GET', url: '/api/agents/agent_unknown/traces' })).statusCode).toBe(404);
|
|
});
|
|
|
|
it('survives a corrupt agents.json (degrades to empty, not 500)', async () => {
|
|
fs.writeFileSync(path.join(dataDir, 'agents.json'), '{not json', 'utf-8');
|
|
const res = await server.inject({ method: 'GET', url: '/api/agents' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().count).toBe(0);
|
|
});
|
|
});
|