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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user