test(FN-1415): add API tests for agent runs and fix validation behavior

- Add comprehensive test suite for agent runs API endpoints (routes, wrappers)
- Fix run-audit API wrapper tests to match correct validation behavior
- Add test coverage for wake context validation and on-demand run creation
- Update project memory with API test learnings
This commit is contained in:
gsxdsm
2026-04-10 08:27:56 -07:00
parent 97afba2f7e
commit 05bd2f6623
5 changed files with 538 additions and 0 deletions

View File

@@ -40,6 +40,8 @@ import {
fetchProjectTasks,
fetchProjectConfig,
fetchExecutorStats,
fetchAgentRunAudit,
fetchAgentRunTimeline,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
@@ -2941,3 +2943,178 @@ describe("resilient SSE reconnect", () => {
expect(stream.readyState).toBe(ControlledEventSource.OPEN);
});
});
describe("fetchAgentRunAudit", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("fetches run audit events with correct URL encoding", async () => {
const mockResponse = {
runId: "run-001",
events: [],
filters: {},
totalCount: 0,
hasMore: false,
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
const result = await fetchAgentRunAudit("agent-001", "run-001");
expect(result).toEqual(mockResponse);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/audit",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("passes projectId as query param", async () => {
const mockResponse = { runId: "run-001", events: [], filters: {}, totalCount: 0, hasMore: false };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
await fetchAgentRunAudit("agent-001", "run-001", undefined, "my-project");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/audit?projectId=my-project",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("includes filter params in query string", async () => {
const mockResponse = { runId: "run-001", events: [], filters: {}, totalCount: 0, hasMore: false };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
await fetchAgentRunAudit("agent-001", "run-001", {
domain: "git",
taskId: "FN-001",
limit: 50,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/audit?taskId=FN-001&domain=git&limit=50",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("throws on 404 with 'Run not found' message", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Run not found" }, 404)
);
await expect(fetchAgentRunAudit("agent-001", "run-nonexistent")).rejects.toThrow("Run not found");
});
it("throws on 400 for blank runId before calling fetch", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { runId: "run-001", events: [], filters: {}, totalCount: 0, hasMore: false }));
// Blank runId should throw synchronously before fetch is called
expect(() => fetchAgentRunAudit("agent-001", "")).toThrow("runId is required");
expect(() => fetchAgentRunAudit("agent-001", " ")).toThrow("runId is required");
// Note: URL-encoded values like "%20" are valid runId values (they're decoded at the URL level, not parameter level)
// Verify fetch was never called for blank runId
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
describe("fetchAgentRunTimeline", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("fetches run timeline with correct URL encoding", async () => {
const mockResponse = {
run: { id: "run-001", agentId: "agent-001", startedAt: "2026-01-01T00:00:00Z", status: "active" },
auditByDomain: { database: [], git: [], filesystem: [] },
counts: { auditEvents: 0, logEntries: 0 },
timeline: [],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
const result = await fetchAgentRunTimeline("agent-001", "run-001");
expect(result).toEqual(mockResponse);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/timeline",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("passes projectId as query param", async () => {
const mockResponse = {
run: { id: "run-001", agentId: "agent-001", startedAt: "2026-01-01T00:00:00Z", status: "active" },
auditByDomain: { database: [], git: [], filesystem: [] },
counts: { auditEvents: 0, logEntries: 0 },
timeline: [],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
await fetchAgentRunTimeline("agent-001", "run-001", undefined, "my-project");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/timeline?projectId=my-project",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("includes options in query string", async () => {
const mockResponse = {
run: { id: "run-001", agentId: "agent-001", startedAt: "2026-01-01T00:00:00Z", status: "active" },
auditByDomain: { database: [], git: [], filesystem: [] },
counts: { auditEvents: 0, logEntries: 0 },
timeline: [],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockResponse));
await fetchAgentRunTimeline("agent-001", "run-001", {
domain: "filesystem",
taskId: "FN-001",
includeLogs: false,
limit: 100,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs/run-001/timeline?taskId=FN-001&domain=filesystem&includeLogs=false&limit=100",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("throws on 404 with 'Run not found' message", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Run not found" }, 404)
);
await expect(fetchAgentRunTimeline("agent-001", "run-nonexistent")).rejects.toThrow("Run not found");
});
it("throws on 400 for blank runId before calling fetch", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
run: { id: "run-001", agentId: "agent-001", startedAt: "2026-01-01T00:00:00Z", status: "active" },
auditByDomain: { database: [], git: [], filesystem: [] },
counts: { auditEvents: 0, logEntries: 0 },
timeline: [],
}));
// Blank runId should throw synchronously before fetch is called
expect(() => fetchAgentRunTimeline("agent-001", "")).toThrow("runId is required");
expect(() => fetchAgentRunTimeline("agent-001", " ")).toThrow("runId is required");
// Note: URL-encoded values like "%20" are valid runId values (they're decoded at the URL level, not parameter level)
// Verify fetch was never called for blank runId
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -2307,6 +2307,7 @@ export interface RunTimelineResponse {
* @param filters - Optional filter parameters
* @param projectId - Optional project ID for multi-project workspaces
* @returns Promise resolving to RunAuditResponse with normalized events
* @throws Error if runId is blank or whitespace-only
*/
export function fetchAgentRunAudit(
agentId: string,
@@ -2314,6 +2315,11 @@ export function fetchAgentRunAudit(
filters?: RunAuditFilters,
projectId?: string,
): Promise<RunAuditResponse> {
// Validate runId before making API call
if (!runId || runId.trim().length === 0) {
throw new Error("runId is required");
}
const params = new URLSearchParams();
if (filters?.taskId) params.set("taskId", filters.taskId);
if (filters?.domain) params.set("domain", filters.domain);
@@ -2340,6 +2346,7 @@ export function fetchAgentRunAudit(
* @param options.limit - Maximum audit events to return
* @param projectId - Optional project ID for multi-project workspaces
* @returns Promise resolving to RunTimelineResponse with merged timeline
* @throws Error if runId is blank or whitespace-only
*/
export function fetchAgentRunTimeline(
agentId: string,
@@ -2354,6 +2361,11 @@ export function fetchAgentRunTimeline(
},
projectId?: string,
): Promise<RunTimelineResponse> {
// Validate runId before making API call
if (!runId || runId.trim().length === 0) {
throw new Error("runId is required");
}
const params = new URLSearchParams();
if (options?.taskId) params.set("taskId", options.taskId);
if (options?.domain) params.set("domain", options.domain);

View File

@@ -789,5 +789,337 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
expect(response.body.auditByDomain.filesystem.length).toBe(1);
expect(response.body.counts.auditEvents).toBe(3);
});
it("returns 400 for blank runId (URL-encoded space)", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%20/audit");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 400 for whitespace-only runId", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%09%09/audit");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 404 with exact 'Run not found' message for unknown runId", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-nonexistent/audit");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Run not found");
});
it("handles legacy/partial contextSnapshot (no taskId) without falling back to agent.taskId", async () => {
// Create a run with no contextSnapshot (legacy/partial context)
const mockRun = createMockRun({
id: "run-legacy",
contextSnapshot: undefined, // No contextSnapshot at all
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([
{
id: "audit-legacy-1",
timestamp: "2026-01-01T00:01:00.000Z",
agentId: "agent-001",
runId: "run-legacy",
domain: "database",
mutationType: "task:update",
target: "FN-001",
taskId: undefined, // No taskId in event either
},
]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-legacy/audit");
expect(response.status).toBe(200);
expect(response.body.runId).toBe("run-legacy");
expect(Array.isArray(response.body.events)).toBe(true);
expect(response.body.events.length).toBe(1);
// Verify the event has undefined taskId, not a fallback value
expect(response.body.events[0].taskId).toBeUndefined();
expect(response.body.filters.taskId).toBeUndefined();
});
it("handles empty contextSnapshot object (no taskId field) without falling back to agent.taskId", async () => {
// Create a run with empty contextSnapshot object
const mockRun = createMockRun({
id: "run-empty-context",
contextSnapshot: {}, // Empty object, no taskId
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-empty-context/audit");
expect(response.status).toBe(200);
expect(response.body.runId).toBe("run-empty-context");
expect(response.body.events).toEqual([]);
expect(response.body.filters.taskId).toBeUndefined();
});
it("asserts deterministic ordering for timeline with duplicate timestamps using sortKey", async () => {
const mockRun = createMockRun({
id: "run-dup-ts",
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Create events with IDENTICAL millisecond timestamps but different IDs
// This tests the sortKey tie-breaking logic
const sameTimestamp = "2026-01-01T00:05:00.000Z";
const mockAuditEvents = [
{
id: "audit-d-1",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "database",
mutationType: "task:update",
target: "FN-001",
},
{
id: "audit-d-2",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "git",
mutationType: "git:commit",
target: "branch-name",
},
{
id: "audit-d-3",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "filesystem",
mutationType: "file:write",
target: "src/file.ts",
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
// Run multiple times to verify deterministic ordering
const orderings: string[][] = [];
for (let i = 0; i < 5; i++) {
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-dup-ts/timeline");
expect(response.status).toBe(200);
const ids = response.body.timeline.map((entry: { audit?: { id: string }; log?: unknown }) =>
entry.audit?.id ?? "log"
);
orderings.push(ids);
}
// All orderings should be identical (deterministic)
for (const ordering of orderings) {
expect(ordering).toEqual(orderings[0]);
}
// Verify sortKey-based tie-breaking: audit events with same timestamp should sort by ID
const timeline = orderings[0];
const auditIds = timeline.filter((id: string) => id.startsWith("audit-d-"));
expect(auditIds.length).toBe(3);
// The IDs should be in ascending order based on the sortKey (A_timestamp_id format)
expect(auditIds).toEqual(["audit-d-1", "audit-d-2", "audit-d-3"]);
});
it("traces filesystem/file-change audit records in correlated timeline response", async () => {
const mockRun = createMockRun({
id: "run-fs-trace",
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Filesystem event with metadata for traceability
const mockAuditEvents = [
{
id: "audit-fs-1",
timestamp: "2026-01-01T00:03:00.000Z",
agentId: "agent-001",
runId: "run-fs-trace",
domain: "filesystem",
mutationType: "file:write",
target: "src/task-impl.ts",
metadata: {
filesChanged: 2,
paths: ["src/task-impl.ts", "src/task-impl.test.ts"],
operation: "create",
},
},
{
id: "audit-fs-2",
timestamp: "2026-01-01T00:03:01.000Z",
agentId: "agent-001",
runId: "run-fs-trace",
domain: "filesystem",
mutationType: "file:modify",
target: "src/task-impl.ts",
metadata: {
filesChanged: 1,
paths: ["src/task-impl.ts"],
operation: "modify",
},
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-fs-trace/timeline");
expect(response.status).toBe(200);
// Verify filesystem domain grouping
expect(response.body.auditByDomain.filesystem.length).toBe(2);
// Verify traceability: each filesystem event should have runId and domain in response
const fsEntries = response.body.timeline.filter(
(entry: { type: string; audit?: { domain: string; runId: string } }) =>
entry.type === "audit" && entry.audit?.domain === "filesystem"
);
expect(fsEntries.length).toBe(2);
// Verify metadata is preserved in the response
const fsEntry1 = fsEntries.find(
(entry: { audit?: { id: string } }) => entry.audit?.id === "audit-fs-1"
);
expect(fsEntry1).toBeDefined();
expect(fsEntry1.audit.metadata).toEqual({
filesChanged: 2,
paths: ["src/task-impl.ts", "src/task-impl.test.ts"],
operation: "create",
});
// Verify runId correlation is present
expect(fsEntry1.audit.domain).toBe("filesystem");
// Verify timeline ordering (filesystem events should be in correct order)
expect(fsEntries[0].audit.id).toBe("audit-fs-1");
expect(fsEntries[1].audit.id).toBe("audit-fs-2");
});
it("normalizes filesystem events with file-change mutation type", async () => {
const mockRun = createMockRun({ id: "run-file-change" });
mockGetRunDetail.mockResolvedValue(mockRun);
const mockAuditEvents = [
{
id: "audit-filechange-1",
timestamp: "2026-01-01T00:04:00.000Z",
agentId: "agent-001",
runId: "run-file-change",
domain: "filesystem",
mutationType: "file:change",
target: "src/main.ts",
taskId: "FN-001",
metadata: {
filesChanged: 5,
paths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"],
operation: "batch-modify",
},
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-file-change/audit");
expect(response.status).toBe(200);
expect(response.body.events.length).toBe(1);
expect(response.body.events[0].domain).toBe("filesystem");
expect(response.body.events[0].mutationType).toBe("file:change");
expect(response.body.events[0].metadata).toEqual({
filesChanged: 5,
paths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"],
operation: "batch-modify",
});
// Summary should include filesystem domain prefix
expect(response.body.events[0].summary).toContain("FS");
expect(response.body.events[0].summary).toContain("change");
});
});
// ── Timeline endpoint additional tests ──────────────────────────────────
describe("GET /api/agents/:id/runs/:runId/timeline (extended)", () => {
it("returns 400 for blank runId (URL-encoded space)", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%20/timeline");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 404 with exact 'Run not found' message for unknown runId", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-nonexistent/timeline");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Run not found");
});
it("handles legacy/partial contextSnapshot (no taskId) without falling back to agent.taskId", async () => {
// Create a run with no contextSnapshot - timeline should still work
const mockRun = createMockRun({
id: "run-legacy-tl",
contextSnapshot: undefined,
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([]);
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-legacy-tl/timeline");
expect(response.status).toBe(200);
expect(response.body.run.id).toBe("run-legacy-tl");
// taskId should be undefined (no fallback to current agent state)
expect(response.body.run.taskId).toBeUndefined();
expect(response.body.timeline).toEqual([]);
});
it("verifies timeline sortKey tie-breaking with mixed audit and log entries at same timestamp", async () => {
const sameTimestamp = "2026-01-01T00:06:00.000Z";
const mockRun = createMockRun({
id: "run-mixed-ts",
startedAt: sameTimestamp,
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Audit event at timestamp
const mockAuditEvents = [
{
id: "audit-mixed-a",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-mixed-ts",
domain: "database",
mutationType: "task:update",
target: "FN-001",
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
// Log entry at EXACT same timestamp (using timestamp as unique key)
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([
{ timestamp: sameTimestamp, type: "info", message: "Log at exact same time" },
]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-mixed-ts/timeline");
expect(response.status).toBe(200);
expect(response.body.timeline.length).toBe(2);
// Verify deterministic ordering: audit (A prefix) should come before log (L prefix)
// SortKey format: "A_timestamp_id" vs "L_timestamp_timestamp"
// A < L alphabetically, so audit comes first at same timestamp
expect(response.body.timeline[0].type).toBe("audit");
expect(response.body.timeline[1].type).toBe("log");
// Run multiple times to verify determinism
for (let i = 0; i < 3; i++) {
const response2 = await request(app, "GET", "/api/agents/agent-001/runs/run-mixed-ts/timeline");
expect(response2.body.timeline[0].type).toBe("audit");
expect(response2.body.timeline[1].type).toBe("log");
}
});
});
});

View File

@@ -10422,6 +10422,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
// Validate runId is not blank/whitespace
const runId = req.params.runId;
if (!runId || runId.trim().length === 0) {
throw badRequest("runId is required");
}
// Verify the run exists
const run = await agentStore.getRunDetail(req.params.id, req.params.runId);
if (!run) {
@@ -10500,6 +10506,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
// Validate runId is not blank/whitespace
const runId = req.params.runId;
if (!runId || runId.trim().length === 0) {
throw badRequest("runId is required");
}
// Verify the run exists
const run = await agentStore.getRunDetail(req.params.id, req.params.runId);
if (!run) {