feat(FN-1726): add saturated-slot regression tests for utility AI routes
- Add heartbeat wake saturation regression tests covering timer, assignment, and on-demand triggers - Add mission interview saturated-slot regression tests for milestone and slice interviews - Add saturated-slot regression tests for planning and subtask breakdown routes - Tests verify 503 responses and correct slot-checking behavior under maxConcurrent saturation
This commit is contained in:
@@ -4316,7 +4316,7 @@ describe("Mission API", () => {
|
|||||||
*/
|
*/
|
||||||
describe("Mission interview routes are independent of task-lane saturation", () => {
|
describe("Mission interview routes are independent of task-lane saturation", () => {
|
||||||
// Helper to create a mock AI session store for interview routes
|
// Helper to create a mock AI session store for interview routes
|
||||||
function createMockAiSessionStore() {
|
function createMockAiSessionStore(options?: { lockConflict?: boolean }) {
|
||||||
const store = new Map<string, any>();
|
const store = new Map<string, any>();
|
||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
@@ -4324,13 +4324,18 @@ describe("Mission interview routes are independent of task-lane saturation", ()
|
|||||||
get: vi.fn((id) => store.get(id) ?? null),
|
get: vi.fn((id) => store.get(id) ?? null),
|
||||||
delete: vi.fn((id) => store.delete(id)),
|
delete: vi.fn((id) => store.delete(id)),
|
||||||
listRecoverable: vi.fn(() => Array.from(store.values())),
|
listRecoverable: vi.fn(() => Array.from(store.values())),
|
||||||
acquireLock: vi.fn().mockReturnValue({ acquired: true, currentHolder: null }),
|
acquireLock: vi.fn().mockImplementation((_id: string, _tabId: string) => {
|
||||||
|
if (options?.lockConflict) {
|
||||||
|
return { acquired: false, currentHolder: "tab-owner" };
|
||||||
|
}
|
||||||
|
return { acquired: true, currentHolder: null };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to build an app with saturated settings
|
// Helper to build an app with saturated settings
|
||||||
function buildAppWithSaturatedSettings() {
|
function buildAppWithSaturatedSettings(options?: { aiSessionStore?: ReturnType<typeof createMockAiSessionStore> }) {
|
||||||
const aiSessionStore = createMockAiSessionStore();
|
const aiSessionStore = options?.aiSessionStore ?? createMockAiSessionStore();
|
||||||
const { app, missionStore } = buildApp({ aiSessionStore });
|
const { app, missionStore } = buildApp({ aiSessionStore });
|
||||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
@@ -4343,78 +4348,269 @@ describe("Mission interview routes are independent of task-lane saturation", ()
|
|||||||
return { app, missionStore: ms, aiSessionStore };
|
return { app, missionStore: ms, aiSessionStore };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("POST /api/missions/interview/start succeeds under saturated settings", async () => {
|
describe("start endpoints", () => {
|
||||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
it("POST /api/missions/interview/start succeeds under saturated settings", async () => {
|
||||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
// Mock createMissionInterviewSession to return a session
|
// Mock createMissionInterviewSession to return a session
|
||||||
const createSessionMock = vi.fn().mockResolvedValue("mission-saturation-test-session");
|
const createSessionMock = vi.fn().mockResolvedValue("mission-saturation-test-session");
|
||||||
vi.spyOn(missionInterviewModule, "createMissionInterviewSession").mockImplementation(createSessionMock);
|
vi.spyOn(missionInterviewModule, "createMissionInterviewSession").mockImplementation(createSessionMock);
|
||||||
|
|
||||||
const res = await request(
|
const res = await request(
|
||||||
app,
|
app,
|
||||||
"POST",
|
"POST",
|
||||||
"/api/missions/interview/start",
|
"/api/missions/interview/start",
|
||||||
JSON.stringify({ missionTitle: "Build auth system" }),
|
JSON.stringify({ missionTitle: "Build auth system" }),
|
||||||
{ "content-type": "application/json" },
|
{ "content-type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.sessionId).toBe("mission-saturation-test-session");
|
expect(res.body.sessionId).toBe("mission-saturation-test-session");
|
||||||
// Verify no saturation error was introduced
|
// Verify no saturation error was introduced
|
||||||
expect(res.body.error).toBeUndefined();
|
expect(res.body.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/milestones/:milestoneId/interview/start succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a milestone
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
|
||||||
|
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
||||||
|
const createSessionMock = vi.fn().mockResolvedValue("milestone-saturation-test-session");
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/milestones/${milestone.id}/interview/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.sessionId).toBe("milestone-saturation-test-session");
|
||||||
|
// Verify no saturation error was introduced
|
||||||
|
expect(res.body.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/slices/:sliceId/interview/start succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a slice
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||||
|
|
||||||
|
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
||||||
|
const createSessionMock = vi.fn().mockResolvedValue("slice-saturation-test-session");
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/slices/${slice.id}/interview/start`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.sessionId).toBe("slice-saturation-test-session");
|
||||||
|
// Verify no saturation error was introduced
|
||||||
|
expect(res.body.error).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/missions/milestones/:milestoneId/interview/start succeeds under saturated settings", async () => {
|
describe("respond endpoints", () => {
|
||||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
it("POST /api/missions/interview/respond succeeds under saturated settings", async () => {
|
||||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
const { app } = buildAppWithSaturatedSettings();
|
||||||
|
|
||||||
// Create a milestone
|
// Mock submitMissionInterviewResponse to return a valid response
|
||||||
const mission = ms.createMission({ title: "Test Mission" });
|
const respondMock = vi.fn().mockResolvedValue({
|
||||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
type: "question",
|
||||||
|
data: { id: "q-2", type: "text", question: "Next question?" },
|
||||||
|
});
|
||||||
|
vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse").mockImplementation(respondMock);
|
||||||
|
|
||||||
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
const res = await request(
|
||||||
const createSessionMock = vi.fn().mockResolvedValue("milestone-saturation-test-session");
|
app,
|
||||||
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
"POST",
|
||||||
|
"/api/missions/interview/respond",
|
||||||
|
JSON.stringify({ sessionId: "test-session", responses: { "q-1": "answer" } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
const res = await request(
|
// UTILITY PATH: Respond must NOT be gated on maxConcurrent
|
||||||
app,
|
expect(res.status).toBe(200);
|
||||||
"POST",
|
expect(res.body.type).toBe("question");
|
||||||
`/api/missions/milestones/${milestone.id}/interview/start`,
|
});
|
||||||
JSON.stringify({}),
|
|
||||||
{ "content-type": "application/json" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
it("preserves lock-conflict 409 semantics for respond under saturation", async () => {
|
||||||
expect(res.body.sessionId).toBe("milestone-saturation-test-session");
|
const aiSessionStore = createMockAiSessionStore({ lockConflict: true });
|
||||||
// Verify no saturation error was introduced
|
const { app } = buildAppWithSaturatedSettings({ aiSessionStore });
|
||||||
expect(res.body.error).toBeUndefined();
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/interview/respond",
|
||||||
|
JSON.stringify({ sessionId: "locked-session", responses: { "q-1": "answer" }, tabId: "tab-other" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
error: "Session locked by another tab",
|
||||||
|
lockedByTab: "tab-owner",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/milestones/:milestoneId/interview/respond succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a milestone
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
|
||||||
|
// Mock submitTargetInterviewResponse to return a valid response
|
||||||
|
const respondMock = vi.fn().mockResolvedValue({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "ms-q-2", type: "text", question: "Milestone question?" },
|
||||||
|
});
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "submitTargetInterviewResponse").mockImplementation(respondMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/milestones/${milestone.id}/interview/respond`,
|
||||||
|
JSON.stringify({ sessionId: "milestone-test-session", responses: { "ms-q-1": "answer" } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.type).toBe("question");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/slices/:sliceId/interview/respond succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a slice
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||||
|
|
||||||
|
// Mock submitTargetInterviewResponse to return a valid response
|
||||||
|
const respondMock = vi.fn().mockResolvedValue({
|
||||||
|
type: "complete",
|
||||||
|
data: { title: "Slice Plan", description: "Done" },
|
||||||
|
});
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "submitTargetInterviewResponse").mockImplementation(respondMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/slices/${slice.id}/interview/respond`,
|
||||||
|
JSON.stringify({ sessionId: "slice-test-session", responses: { "sl-q-1": "answer" } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.type).toBe("complete");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/missions/slices/:sliceId/interview/start succeeds under saturated settings", async () => {
|
describe("retry endpoints", () => {
|
||||||
const { app, missionStore } = buildAppWithSaturatedSettings();
|
it("POST /api/missions/interview/:sessionId/retry succeeds under saturated settings", async () => {
|
||||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
const { app } = buildAppWithSaturatedSettings();
|
||||||
|
|
||||||
// Create a slice
|
// Mock retryMissionInterviewSession to succeed
|
||||||
const mission = ms.createMission({ title: "Test Mission" });
|
const retryMock = vi.fn().mockResolvedValue(undefined);
|
||||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockImplementation(retryMock);
|
||||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
|
||||||
|
|
||||||
// Mock createTargetInterviewSession to return a session (from milestone-slice-interview module)
|
const res = await request(
|
||||||
const createSessionMock = vi.fn().mockResolvedValue("slice-saturation-test-session");
|
app,
|
||||||
vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock);
|
"POST",
|
||||||
|
"/api/missions/interview/failed-session/retry",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
const res = await request(
|
// UTILITY PATH: Retry must NOT be gated on maxConcurrent
|
||||||
app,
|
expect(res.status).toBe(200);
|
||||||
"POST",
|
expect(res.body.success).toBe(true);
|
||||||
`/api/missions/slices/${slice.id}/interview/start`,
|
});
|
||||||
JSON.stringify({}),
|
|
||||||
{ "content-type": "application/json" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
it("preserves lock-conflict 409 for mission retry under saturation", async () => {
|
||||||
expect(res.body.sessionId).toBe("slice-saturation-test-session");
|
const aiSessionStore = createMockAiSessionStore({ lockConflict: true });
|
||||||
// Verify no saturation error was introduced
|
const { app } = buildAppWithSaturatedSettings({ aiSessionStore });
|
||||||
expect(res.body.error).toBeUndefined();
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/missions/interview/locked-retry-session/retry",
|
||||||
|
JSON.stringify({ tabId: "tab-conflict" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
error: "Session locked by another tab",
|
||||||
|
lockedByTab: "tab-owner",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/milestones/:milestoneId/interview/:sessionId/retry succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a milestone
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
|
||||||
|
// Mock retryTargetInterviewSession to succeed
|
||||||
|
const retryMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "retryTargetInterviewSession").mockImplementation(retryMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/milestones/${milestone.id}/interview/milestone-retry-session/retry`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/missions/slices/:sliceId/interview/:sessionId/retry succeeds under saturated settings", async () => {
|
||||||
|
const { app, missionStore } = buildAppWithSaturatedSettings();
|
||||||
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||||
|
|
||||||
|
// Create a slice
|
||||||
|
const mission = ms.createMission({ title: "Test Mission" });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||||
|
|
||||||
|
// Mock retryTargetInterviewSession to succeed
|
||||||
|
const retryMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(milestoneSliceInterviewModule, "retryTargetInterviewSession").mockImplementation(retryMock);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/missions/slices/${slice.id}/interview/slice-retry-session/retry`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8202,6 +8202,588 @@ describe("Planning Mode Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saturated-slot regression coverage for utility AI routes.
|
||||||
|
* These tests prove that planning, subtask, and interview routes remain executable
|
||||||
|
* when the task-lane is saturated (maxConcurrent = 0).
|
||||||
|
*
|
||||||
|
* UTILITY PATH contract: These routes are on the heartbeat control-plane lane
|
||||||
|
* and must NOT be gated on task-lane saturation (maxConcurrent, semaphore, queue depth).
|
||||||
|
*
|
||||||
|
* See .fusion/memory.md "Heartbeat Control-Plane Lane (FN-1487)"
|
||||||
|
*/
|
||||||
|
describe("Saturated-slot regression: utility AI routes", () => {
|
||||||
|
/**
|
||||||
|
* Helper to create a store with saturated settings (maxConcurrent = 0).
|
||||||
|
* This simulates the task-lane being fully saturated.
|
||||||
|
*/
|
||||||
|
function createSaturatedStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
|
return createMockStore({
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 0, // SATURATED: zero task slots available
|
||||||
|
promptOverrides: {},
|
||||||
|
}),
|
||||||
|
getSettingsFast: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 0,
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
} as Partial<TaskStore>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create an app with saturated settings and optional aiSessionStore.
|
||||||
|
*/
|
||||||
|
function buildSaturatedApp(options: { aiSessionStore?: any } = {}) {
|
||||||
|
const store = createSaturatedStore();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routeOptions = options.aiSessionStore ? { aiSessionStore: options.aiSessionStore } : undefined;
|
||||||
|
app.use("/api", createApiRoutes(store, routeOptions as any));
|
||||||
|
return { app, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup a mock agent for planning session tests.
|
||||||
|
*/
|
||||||
|
function setupSaturatedPlanningMockAgent() {
|
||||||
|
const questionResponses = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: {
|
||||||
|
id: "q-scope",
|
||||||
|
type: "single_select",
|
||||||
|
question: "What is the scope?",
|
||||||
|
description: "Choose scope.",
|
||||||
|
options: [
|
||||||
|
{ id: "small", label: "Small" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "large", label: "Large" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: {
|
||||||
|
id: "q-req",
|
||||||
|
type: "text",
|
||||||
|
question: "Requirements?",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "complete",
|
||||||
|
data: {
|
||||||
|
title: "Saturated Plan",
|
||||||
|
description: "A plan created under saturation",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Item 1", "Item 2"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
let callIndex = 0;
|
||||||
|
const mockAgent = {
|
||||||
|
session: {
|
||||||
|
state: { messages },
|
||||||
|
prompt: vi.fn(async (msg: string) => {
|
||||||
|
messages.push({ role: "user", content: msg });
|
||||||
|
const response = questionResponses[callIndex++] ?? questionResponses[questionResponses.length - 1];
|
||||||
|
messages.push({ role: "assistant", content: response });
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
__setCreateKbAgent(async () => mockAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/planning/start — utility lane independence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetPlanningState();
|
||||||
|
setupSaturatedPlanningMockAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__setCreateKbAgent(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start",
|
||||||
|
JSON.stringify({ initialPlan: "Plan a feature under saturated task-lane" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// UTILITY PATH: Planning start must NOT be gated on maxConcurrent
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.sessionId).toBeDefined();
|
||||||
|
expect(res.body.firstQuestion).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/planning/start-streaming — utility lane independence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetPlanningState();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__setCreateKbAgent(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
// Mock agent for streaming
|
||||||
|
const messages: Array<{ role: string; content: string }> = [];
|
||||||
|
const mockAgent = {
|
||||||
|
session: {
|
||||||
|
state: { messages },
|
||||||
|
prompt: vi.fn(async (msg: string) => {
|
||||||
|
messages.push({ role: "user", content: msg });
|
||||||
|
messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "question",
|
||||||
|
data: { id: "q-scope", type: "text", question: "What to plan?" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
__setCreateKbAgent(async () => mockAgent);
|
||||||
|
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start-streaming",
|
||||||
|
JSON.stringify({ initialPlan: "Streaming plan under saturation" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// UTILITY PATH: Planning streaming must NOT be gated on maxConcurrent
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.sessionId).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/planning/respond — utility lane independence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetPlanningState();
|
||||||
|
setupSaturatedPlanningMockAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__setCreateKbAgent(undefined as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
// First create a session
|
||||||
|
const startRes = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start",
|
||||||
|
JSON.stringify({ initialPlan: "Test respond under saturation" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(startRes.status).toBe(201);
|
||||||
|
const sessionId = startRes.body.sessionId;
|
||||||
|
|
||||||
|
// Submit response - must NOT be blocked by maxConcurrent
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/respond",
|
||||||
|
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.type).toBe("question");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => {
|
||||||
|
// Create mock aiSessionStore that returns conflict on acquire
|
||||||
|
const mockAiSessionStore = {
|
||||||
|
acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-a" }),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { app } = buildSaturatedApp({ aiSessionStore: mockAiSessionStore });
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const startRes = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/start",
|
||||||
|
JSON.stringify({ initialPlan: "Test respond lock under saturation" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(startRes.status).toBe(201);
|
||||||
|
const sessionId = startRes.body.sessionId;
|
||||||
|
|
||||||
|
// Respond with conflicting tabId - mock returns conflict
|
||||||
|
const conflictRes = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/respond",
|
||||||
|
JSON.stringify({ sessionId, responses: { scope: "medium" }, tabId: "tab-b" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflictRes.status).toBe(409);
|
||||||
|
expect(conflictRes.body).toEqual({
|
||||||
|
error: "Session locked by another tab",
|
||||||
|
lockedByTab: "tab-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/planning/:sessionId/retry — utility lane independence", () => {
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
const retrySpy = vi.spyOn(planningModule, "retrySession").mockResolvedValue();
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
const res = await REQUEST(app, "POST", "/api/planning/session-sat-retry/retry");
|
||||||
|
|
||||||
|
// UTILITY PATH: Planning retry must NOT be gated on maxConcurrent
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true, sessionId: "session-sat-retry" });
|
||||||
|
expect(retrySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => {
|
||||||
|
// Create mock that returns conflict
|
||||||
|
const mockAiSessionStore = {
|
||||||
|
acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-x" }),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { app } = buildSaturatedApp({ aiSessionStore: mockAiSessionStore });
|
||||||
|
|
||||||
|
const conflictRes = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/planning/session-locked-retry/retry",
|
||||||
|
JSON.stringify({ tabId: "tab-y" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflictRes.status).toBe(409);
|
||||||
|
expect(conflictRes.body).toEqual({
|
||||||
|
error: "Session locked by another tab",
|
||||||
|
lockedByTab: "tab-x",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/subtasks/start-streaming — utility lane independence", () => {
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
// Mock the subtask breakdown module to return proper format
|
||||||
|
const mockCreateSubtaskSession = vi.fn().mockResolvedValue({ sessionId: "subtask-sat-session" });
|
||||||
|
vi.spyOn(subtaskBreakdownModule, "createSubtaskSession").mockImplementation(mockCreateSubtaskSession);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/subtasks/start-streaming",
|
||||||
|
JSON.stringify({ description: "Break into subtasks under saturation" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// UTILITY PATH: Subtask start must NOT be gated on maxConcurrent
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.sessionId).toBeDefined();
|
||||||
|
expect(mockCreateSubtaskSession).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/subtasks/:sessionId/retry — utility lane independence", () => {
|
||||||
|
it("executes successfully when task-lane is saturated (maxConcurrent=0)", async () => {
|
||||||
|
const retrySpy = vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockResolvedValue();
|
||||||
|
const { app } = buildSaturatedApp();
|
||||||
|
|
||||||
|
const res = await REQUEST(app, "POST", "/api/subtasks/session-sat-retry/retry");
|
||||||
|
|
||||||
|
// UTILITY PATH: Subtask retry must NOT be gated on maxConcurrent
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(retrySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lock-conflict 409 semantics when task-lane is saturated", async () => {
|
||||||
|
// Create mock that returns conflict
|
||||||
|
const mockAiSessionStore = {
|
||||||
|
acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "tab-locked" }),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { app } = buildSaturatedApp({ aiSessionStore: mockAiSessionStore });
|
||||||
|
|
||||||
|
const conflictRes = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/subtasks/subtask-locked-retry/retry",
|
||||||
|
JSON.stringify({ tabId: "tab-conflict" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conflictRes.status).toBe(409);
|
||||||
|
expect(conflictRes.body).toEqual({
|
||||||
|
error: "Session locked by another tab",
|
||||||
|
lockedByTab: "tab-locked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saturated-slot regression coverage for heartbeat wake routes.
|
||||||
|
* These tests prove that comment-triggered and steering-triggered heartbeat wake
|
||||||
|
* paths remain executable when the task-lane is saturated.
|
||||||
|
*
|
||||||
|
* UTILITY PATH contract: Wake delegation (heartbeatMonitor.executeHeartbeat) is on
|
||||||
|
* the heartbeat control-plane lane and must NOT be gated on task-lane saturation.
|
||||||
|
*
|
||||||
|
* See .fusion/memory.md "Heartbeat Control-Plane Lane (FN-1487)"
|
||||||
|
*/
|
||||||
|
describe("Saturated-slot regression: heartbeat wake routes", () => {
|
||||||
|
/**
|
||||||
|
* Helper to create a store with saturated settings (maxConcurrent = 0).
|
||||||
|
*/
|
||||||
|
function createSaturatedStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
|
return createMockStore({
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 0, // SATURATED: zero task slots available
|
||||||
|
promptOverrides: {},
|
||||||
|
}),
|
||||||
|
getSettingsFast: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 0,
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
} as Partial<TaskStore>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/tasks/:id/comments — utility lane independence", () => {
|
||||||
|
it("triggers heartbeat wake for assigned agent when task-lane is saturated", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-sat-comment-"));
|
||||||
|
const fusionDir = join(tempDir, ".fusion");
|
||||||
|
mkdirSync(fusionDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||||
|
await agentStore.init();
|
||||||
|
const agent = await agentStore.createAgent({ name: "Saturated Wake Agent", role: "executor" });
|
||||||
|
await agentStore.updateAgent(agent.id, {
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeatMonitor = {
|
||||||
|
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-sat-1" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "KB-SAT-001",
|
||||||
|
assignedAgentId: agent.id,
|
||||||
|
comments: [{ id: "comment-sat-1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createSaturatedStore({
|
||||||
|
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-SAT-001/comments",
|
||||||
|
JSON.stringify({ text: "Hello" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// UTILITY PATH: Wake must NOT be blocked by maxConcurrent=0
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(heartbeatMonitor.executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
agentId: agent.id,
|
||||||
|
source: "on_demand",
|
||||||
|
taskId: "KB-SAT-001",
|
||||||
|
triggeringCommentIds: ["comment-sat-1"],
|
||||||
|
triggeringCommentType: "task",
|
||||||
|
}));
|
||||||
|
}, { timeout: 1000 });
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves active-run conflict 409 semantics when task-lane is saturated", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-sat-active-run-"));
|
||||||
|
const fusionDir = join(tempDir, ".fusion");
|
||||||
|
mkdirSync(fusionDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||||
|
await agentStore.init();
|
||||||
|
const agent = await agentStore.createAgent({ name: "Active Run Agent", role: "executor" });
|
||||||
|
await agentStore.updateAgent(agent.id, {
|
||||||
|
runtimeConfig: { messageResponseMode: "immediate" },
|
||||||
|
});
|
||||||
|
// Start an active run (simulates existing heartbeat)
|
||||||
|
await agentStore.startHeartbeatRun(agent.id);
|
||||||
|
|
||||||
|
const heartbeatMonitor = {
|
||||||
|
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-sat-2" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "KB-SAT-002",
|
||||||
|
assignedAgentId: agent.id,
|
||||||
|
comments: [{ id: "comment-sat-2", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createSaturatedStore({
|
||||||
|
addTaskComment: vi.fn().mockResolvedValue(updatedTask),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store, { heartbeatMonitor } as any));
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-SAT-002/comments",
|
||||||
|
JSON.stringify({ text: "Hello" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Active run conflict must still work under saturation
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled();
|
||||||
|
}, { timeout: 1000 });
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/agents/:id/runs — utility lane independence", () => {
|
||||||
|
it("accepts triggering comment wake fields when task-lane is saturated", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-sat-agent-runs-"));
|
||||||
|
const fusionDir = join(tempDir, ".fusion");
|
||||||
|
mkdirSync(fusionDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||||
|
await agentStore.init();
|
||||||
|
const agent = await agentStore.createAgent({
|
||||||
|
name: "Saturated Run Agent",
|
||||||
|
role: "executor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createSaturatedStore({
|
||||||
|
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/agents/${agent.id}/runs`,
|
||||||
|
JSON.stringify({
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "task-comment",
|
||||||
|
taskId: "FN-SAT-001",
|
||||||
|
triggeringCommentIds: ["c-sat-1", "c-sat-2"],
|
||||||
|
triggeringCommentType: "task",
|
||||||
|
}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// UTILITY PATH: Agent run creation must NOT be blocked by maxConcurrent=0
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.contextSnapshot).toMatchObject({
|
||||||
|
wakeReason: "on_demand",
|
||||||
|
triggerDetail: "task-comment",
|
||||||
|
taskId: "FN-SAT-001",
|
||||||
|
triggeringCommentIds: ["c-sat-1", "c-sat-2"],
|
||||||
|
triggeringCommentType: "task",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves validation 400 for invalid triggeringCommentIds when task-lane is saturated", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "kb-routes-sat-validation-"));
|
||||||
|
const fusionDir = join(tempDir, ".fusion");
|
||||||
|
mkdirSync(fusionDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||||
|
await agentStore.init();
|
||||||
|
const agent = await agentStore.createAgent({
|
||||||
|
name: "Validation Agent",
|
||||||
|
role: "executor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createSaturatedStore({
|
||||||
|
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
|
||||||
|
// Invalid: triggeringCommentIds is a string, not array
|
||||||
|
const res = await REQUEST(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
`/api/agents/${agent.id}/runs`,
|
||||||
|
JSON.stringify({
|
||||||
|
source: "on_demand",
|
||||||
|
triggeringCommentIds: "not-an-array",
|
||||||
|
}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validation must still work under saturation
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("triggeringCommentIds must be an array");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DELETE /api/ai-sessions/cleanup", () => {
|
describe("DELETE /api/ai-sessions/cleanup", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user