Send bounded task state into task-detail planner chat so status and progress questions can be answered in context. - Build reusable server-side planner chat context from task metadata, dependencies, steps, comments, activity, source, and review state. - Pass and validate the task id on planner chat sends to keep sessions scoped to the current task. - Update planner chat copy, docs, tests, and changeset coverage for task-aware status/progress questions. Files changed: .changeset/fn-7312-task-planner-chat-context.md | 7 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/api/legacy.ts | 5 +- .../app/components/TaskPlannerChatTab.tsx | 5 +- .../__tests__/TaskPlannerChatTab.test.tsx | 22 ++ .../dashboard/src/__tests__/chat-manager.test.ts | 17 +- .../dashboard/src/__tests__/chat-routes.test.ts | 76 +++++ .../__tests__/task-planner-chat-context.test.ts | 165 +++++++++++ packages/dashboard/src/chat.ts | 92 +----- .../dashboard/src/routes/register-chat-routes.ts | 11 +- .../dashboard/src/task-planner-chat-context.ts | 326 +++++++++++++++++++++ 11 files changed, 634 insertions(+), 96 deletions(-) Fusion-Task-Id: FN-7312 Fusion-Task-Lineage: 90cf0fe3-3984-4a67-bb44-fce492c256eb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1898 lines
65 KiB
TypeScript
1898 lines
65 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import type { Request, Response } from "express";
|
|
import { request } from "../test-request.js";
|
|
import { createCoreMock, createEngineMock } from "../test/mockCoreEngine.js";
|
|
|
|
// ── SSE Test Helpers ────────────────────────────────────────────────────────
|
|
|
|
/** Create a mock Express request that can fire 'close'. */
|
|
function createSSERequest(): Request {
|
|
const emitter = new EventEmitter();
|
|
emitter.setMaxListeners(50);
|
|
(emitter as any).query = {}; // required: routes read req.query.projectId
|
|
(emitter as any).headers = {}; // required: routes read req.headers["last-event-id"]
|
|
return emitter as unknown as Request;
|
|
}
|
|
|
|
/** Create a mock Express response with SSE streaming capabilities. */
|
|
function createSSEResponse(): {
|
|
res: Response;
|
|
chunks: string[];
|
|
} {
|
|
const chunks: string[] = [];
|
|
const res = {
|
|
setHeader: vi.fn(),
|
|
flushHeaders: vi.fn(),
|
|
write: vi.fn((data: string) => {
|
|
chunks.push(data);
|
|
return true;
|
|
}),
|
|
end: vi.fn(),
|
|
writableEnded: false,
|
|
destroyed: false,
|
|
getHeaders: vi.fn(() => ({})),
|
|
statusCode: 200,
|
|
} as unknown as Response;
|
|
|
|
// Simulate end marking writableEnded
|
|
const originalEnd = res.end;
|
|
res.end = vi.fn((...args: unknown[]) => {
|
|
(res as { writableEnded: boolean }).writableEnded = true;
|
|
return originalEnd.apply(res, args as Parameters<typeof originalEnd>);
|
|
});
|
|
|
|
return { res, chunks };
|
|
}
|
|
|
|
// ── Mock Setup ──────────────────────────────────────────────────────────────
|
|
|
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
|
|
|
// Create mock functions before vi.mock
|
|
const {
|
|
mockCreateFnAgent,
|
|
mockChatStreamManager,
|
|
mockSendMessage,
|
|
mockCancelGeneration,
|
|
mockBeginGeneration,
|
|
mockIsGenerating,
|
|
mockGetActiveGenerationId,
|
|
} = vi.hoisted(() => {
|
|
// Store subscribers per session for broadcast simulation
|
|
const subscribers = new Map<string, Set<{ callback: (event: any, eventId?: number) => void; generationId?: number }>>();
|
|
|
|
const chatStreamManager = {
|
|
subscribe: vi.fn((sessionId: string, callback: (event: any, eventId?: number) => void, options?: { generationId?: number }) => {
|
|
if (!subscribers.has(sessionId)) {
|
|
subscribers.set(sessionId, new Set());
|
|
}
|
|
const entry = { callback, generationId: options?.generationId };
|
|
subscribers.get(sessionId)!.add(entry);
|
|
return () => {
|
|
subscribers.get(sessionId)?.delete(entry);
|
|
};
|
|
}),
|
|
broadcast: vi.fn((sessionId: string, event: any, options?: { generationId?: number }) => {
|
|
const callbacks = subscribers.get(sessionId);
|
|
if (callbacks) {
|
|
let eventId = 1;
|
|
for (const { callback, generationId } of callbacks) {
|
|
if (options?.generationId !== undefined && generationId !== undefined && options.generationId !== generationId) {
|
|
continue;
|
|
}
|
|
callback(event, eventId++);
|
|
}
|
|
}
|
|
}),
|
|
getBufferedEvents: vi.fn(() => []),
|
|
cleanupSession: vi.fn((sessionId: string) => {
|
|
subscribers.delete(sessionId);
|
|
}),
|
|
reset: vi.fn(() => {
|
|
subscribers.clear();
|
|
}),
|
|
hasSubscribers: vi.fn((sessionId: string) => {
|
|
return (subscribers.get(sessionId)?.size ?? 0) > 0;
|
|
}),
|
|
getSubscriberCount: vi.fn((sessionId: string) => {
|
|
return subscribers.get(sessionId)?.size ?? 0;
|
|
}),
|
|
// Helper to trigger done event for testing
|
|
__triggerDone: (sessionId: string, messageId: string) => {
|
|
const callbacks = subscribers.get(sessionId);
|
|
if (callbacks) {
|
|
for (const { callback } of callbacks) {
|
|
callback({ type: "done", data: { messageId } }, 1);
|
|
}
|
|
}
|
|
},
|
|
__triggerError: (sessionId: string, error: string) => {
|
|
const callbacks = subscribers.get(sessionId);
|
|
if (callbacks) {
|
|
for (const { callback } of callbacks) {
|
|
callback({ type: "error", data: error }, 1);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return {
|
|
mockCreateFnAgent: vi.fn(),
|
|
mockSendMessage: vi.fn(),
|
|
mockCancelGeneration: vi.fn(),
|
|
mockBeginGeneration: vi.fn(() => ({ generationId: 1, abortController: new AbortController() })),
|
|
mockIsGenerating: vi.fn(() => false),
|
|
mockGetActiveGenerationId: vi.fn(() => undefined),
|
|
mockChatStreamManager: chatStreamManager,
|
|
};
|
|
});
|
|
|
|
// Mock @fusion/engine to prevent createFnAgent resolution
|
|
vi.mock("@fusion/engine", () => createEngineMock({
|
|
createFnAgent: mockCreateFnAgent,
|
|
}));
|
|
|
|
// Mock ChatStore
|
|
const mockCreateSession = vi.fn();
|
|
const mockGetSession = vi.fn();
|
|
const mockListSessions = vi.fn();
|
|
const mockUpdateSession = vi.fn();
|
|
const mockDeleteSession = vi.fn();
|
|
const mockAddMessage = vi.fn();
|
|
const mockGetMessages = vi.fn();
|
|
const mockGetMessage = vi.fn();
|
|
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
|
const mockFindLatestActiveSessionForTarget = vi.fn();
|
|
const mockDeleteMessage = vi.fn();
|
|
|
|
// Mock AgentStore
|
|
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
|
const mockAgentStoreGetAgent = vi.fn();
|
|
|
|
// Mock ChatStore class for vi.mock
|
|
vi.mock("@fusion/core", async (importOriginal) => createCoreMock(
|
|
() => importOriginal<typeof import("@fusion/core")>(),
|
|
{
|
|
ChatStore: class MockChatStore extends EventEmitter {
|
|
init = mockInit;
|
|
createSession = mockCreateSession;
|
|
getSession = mockGetSession;
|
|
listSessions = mockListSessions;
|
|
updateSession = mockUpdateSession;
|
|
deleteSession = mockDeleteSession;
|
|
addMessage = mockAddMessage;
|
|
getMessages = mockGetMessages;
|
|
getMessage = mockGetMessage;
|
|
getLastMessageForSessions = mockGetLastMessageForSessions;
|
|
findLatestActiveSessionForTarget = mockFindLatestActiveSessionForTarget;
|
|
deleteMessage = mockDeleteMessage;
|
|
},
|
|
AgentStore: class MockAgentStore {
|
|
init = mockAgentStoreInit;
|
|
getAgent = mockAgentStoreGetAgent;
|
|
},
|
|
},
|
|
));
|
|
|
|
// Mock chat.js - must mock before importing server
|
|
vi.mock("../chat.js", () => {
|
|
return {
|
|
ChatManager: class MockChatManager {
|
|
sendMessage = mockSendMessage;
|
|
cancelGeneration = mockCancelGeneration;
|
|
beginGeneration = mockBeginGeneration;
|
|
isGenerating = mockIsGenerating;
|
|
getActiveGenerationId = mockGetActiveGenerationId;
|
|
},
|
|
chatStreamManager: mockChatStreamManager,
|
|
TASK_PLANNER_CHAT_AGENT_ID_PREFIX: "task-planner:",
|
|
checkRateLimit: vi.fn().mockReturnValue(true),
|
|
getRateLimitResetTime: vi.fn().mockReturnValue(null),
|
|
__setCreateFnAgent: vi.fn(),
|
|
__resetChatState: vi.fn(),
|
|
};
|
|
});
|
|
|
|
// Mock planning.js to prevent initialization
|
|
vi.mock("../planning.js", () => {
|
|
return {
|
|
getSession: vi.fn(),
|
|
cleanupSession: vi.fn(),
|
|
__setCreateFnAgent: vi.fn(),
|
|
__resetPlanningState: vi.fn(),
|
|
setAiSessionStore: vi.fn(),
|
|
rehydrateFromStore: vi.fn().mockReturnValue(0),
|
|
};
|
|
});
|
|
|
|
// Mock subtask-breakdown.js
|
|
vi.mock("../subtask-breakdown.js", () => {
|
|
return {
|
|
getSubtaskSession: vi.fn(),
|
|
cleanupSubtaskSession: vi.fn(),
|
|
__resetSubtaskState: vi.fn(),
|
|
setAiSessionStore: vi.fn(),
|
|
rehydrateFromStore: vi.fn().mockReturnValue(0),
|
|
};
|
|
});
|
|
|
|
// Mock mission-interview.js
|
|
vi.mock("../mission-interview.js", () => {
|
|
return {
|
|
getMissionInterviewSession: vi.fn(),
|
|
cleanupMissionInterviewSession: vi.fn(),
|
|
__resetMissionInterviewState: vi.fn(),
|
|
setAiSessionStore: vi.fn(),
|
|
rehydrateFromStore: vi.fn().mockReturnValue(0),
|
|
};
|
|
});
|
|
|
|
// Mock project-store-resolver.js
|
|
const mockGetOrCreateProjectStore = vi.fn();
|
|
vi.mock("../project-store-resolver.js", () => ({
|
|
getOrCreateProjectStore: mockGetOrCreateProjectStore,
|
|
invalidateAllGlobalSettingsCaches: vi.fn(),
|
|
}));
|
|
|
|
// ── Mock Store ──────────────────────────────────────────────────────────────
|
|
|
|
class MockStore extends EventEmitter {
|
|
getRootDir(): string {
|
|
return "/tmp/fn-chat-test";
|
|
}
|
|
|
|
getFusionDir(): string {
|
|
return "/tmp/fn-chat-test/.fusion";
|
|
}
|
|
|
|
getKbDir(): string {
|
|
return "/tmp/fn-chat-test/.fusion";
|
|
}
|
|
|
|
async getTask(id: string) {
|
|
if (id === "FN-MISSING") {
|
|
throw new Error("Task FN-MISSING not found");
|
|
}
|
|
return {
|
|
id,
|
|
title: "Planner task",
|
|
description: "Task description",
|
|
column: "todo",
|
|
status: "planning",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-06-30T00:00:00.000Z",
|
|
updatedAt: "2026-06-30T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
getDatabase() {
|
|
return {
|
|
exec: vi.fn(),
|
|
prepare: vi.fn().mockReturnValue({
|
|
run: vi.fn().mockReturnValue({ changes: 0 }),
|
|
get: vi.fn(),
|
|
all: vi.fn().mockReturnValue([]),
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Test Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
// Re-export the instance creator for use in beforeEach
|
|
const mockChatStoreInstance = {
|
|
init: mockInit,
|
|
createSession: mockCreateSession,
|
|
getSession: mockGetSession,
|
|
listSessions: mockListSessions,
|
|
updateSession: mockUpdateSession,
|
|
deleteSession: mockDeleteSession,
|
|
addMessage: mockAddMessage,
|
|
getMessages: mockGetMessages,
|
|
getMessage: mockGetMessage,
|
|
getLastMessageForSessions: mockGetLastMessageForSessions,
|
|
findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget,
|
|
deleteMessage: mockDeleteMessage,
|
|
emit: vi.fn(),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
};
|
|
|
|
function createMockChatManager() {
|
|
return {
|
|
sendMessage: mockSendMessage,
|
|
cancelGeneration: mockCancelGeneration,
|
|
beginGeneration: mockBeginGeneration,
|
|
isGenerating: mockIsGenerating,
|
|
getActiveGenerationId: mockGetActiveGenerationId,
|
|
};
|
|
}
|
|
|
|
function makeMultipartMessageRequest(options: {
|
|
fields?: Record<string, string>;
|
|
files?: Array<{ fieldName: string; filename: string; contentType: string; body: Buffer }>;
|
|
}): { payload: Buffer; boundary: string } {
|
|
const boundary = `----fn-chat-${Date.now()}`;
|
|
const parts: Buffer[] = [];
|
|
|
|
for (const [fieldName, value] of Object.entries(options.fields ?? {})) {
|
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"\r\n\r\n${value}\r\n`));
|
|
}
|
|
|
|
for (const file of options.files ?? []) {
|
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`));
|
|
parts.push(file.body);
|
|
parts.push(Buffer.from("\r\n"));
|
|
}
|
|
|
|
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
return { payload: Buffer.concat(parts), boundary };
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe("Chat API Routes", () => {
|
|
let store: MockStore;
|
|
let app: ReturnType<typeof import("../server.js").createServer>;
|
|
let mockChatStore: typeof mockChatStoreInstance;
|
|
let mockChatManager: ReturnType<typeof createMockChatManager>;
|
|
|
|
const sampleSession = {
|
|
id: "chat-abc123",
|
|
agentId: "agent-001",
|
|
title: "Test Chat",
|
|
status: "active",
|
|
projectId: null,
|
|
modelProvider: null,
|
|
modelId: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
const sampleMessage = {
|
|
id: "msg-xyz789",
|
|
sessionId: "chat-abc123",
|
|
role: "user" as const,
|
|
content: "Hello, how are you?",
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
mockInit.mockResolvedValue(undefined);
|
|
mockCreateSession.mockReset();
|
|
mockGetSession.mockReset();
|
|
mockListSessions.mockReset();
|
|
mockUpdateSession.mockReset();
|
|
mockDeleteSession.mockReset();
|
|
mockAddMessage.mockReset();
|
|
mockGetMessages.mockReset();
|
|
mockGetMessage.mockReset();
|
|
mockGetLastMessageForSessions.mockReset();
|
|
mockFindLatestActiveSessionForTarget.mockReset();
|
|
mockDeleteMessage.mockReset();
|
|
mockSendMessage.mockReset();
|
|
mockCancelGeneration.mockReset();
|
|
mockIsGenerating.mockReset();
|
|
mockGetActiveGenerationId.mockReset();
|
|
mockAgentStoreInit.mockResolvedValue(undefined);
|
|
mockAgentStoreGetAgent.mockReset();
|
|
mockGetOrCreateProjectStore.mockReset();
|
|
|
|
// Setup default mocks
|
|
mockListSessions.mockReturnValue([]);
|
|
mockFindLatestActiveSessionForTarget.mockReturnValue(null);
|
|
mockGetMessages.mockReturnValue([]);
|
|
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
|
mockCancelGeneration.mockReturnValue(false);
|
|
mockIsGenerating.mockReturnValue(false);
|
|
mockGetActiveGenerationId.mockReturnValue(undefined);
|
|
|
|
// Default agent mock - agent with model config
|
|
mockAgentStoreGetAgent.mockResolvedValue({
|
|
id: "agent-001",
|
|
name: "Alpha",
|
|
role: "executor",
|
|
state: "idle",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
metadata: {},
|
|
runtimeConfig: {
|
|
model: "anthropic/claude-sonnet-4-5",
|
|
},
|
|
});
|
|
|
|
store = new MockStore();
|
|
// Reset and use the shared mock instance
|
|
mockChatStore = mockChatStoreInstance;
|
|
mockChatManager = createMockChatManager();
|
|
|
|
// Setup project-store-resolver mock to return the mock store
|
|
mockGetOrCreateProjectStore.mockResolvedValue(store);
|
|
|
|
const { createServer } = await import("../server.js");
|
|
app = createServer(store as any, {
|
|
chatStore: mockChatStore as any,
|
|
chatManager: mockChatManager as any,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── Session CRUD Tests ──────────────────────────────────────────────────────
|
|
|
|
describe("POST /api/chat/task-planner/:taskId/session", () => {
|
|
it("creates a task-scoped planner chat session with the planning model override", async () => {
|
|
const created = {
|
|
...sampleSession,
|
|
id: "chat-planner-001",
|
|
agentId: "task-planner:FN-7310",
|
|
title: "FN-7310 planner chat",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-plan",
|
|
};
|
|
mockCreateSession.mockReturnValue(created);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/task-planner/FN-7310/session",
|
|
JSON.stringify({ modelProvider: "anthropic", modelId: "claude-plan" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect((response.body as any).session).toEqual(created);
|
|
expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7310" });
|
|
expect(mockCreateSession).toHaveBeenCalledWith({
|
|
agentId: "task-planner:FN-7310",
|
|
title: "FN-7310 planner chat",
|
|
projectId: null,
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-plan",
|
|
});
|
|
});
|
|
|
|
it("resumes and updates an existing planner chat session", async () => {
|
|
const existing = { ...sampleSession, id: "chat-existing", agentId: "task-planner:FN-7310", modelProvider: null, modelId: null };
|
|
const updated = { ...existing, modelProvider: "openai", modelId: "gpt-plan" };
|
|
mockFindLatestActiveSessionForTarget.mockReturnValue(existing);
|
|
mockUpdateSession.mockReturnValue(updated);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/task-planner/FN-7310/session?projectId=proj-001",
|
|
JSON.stringify({ modelProvider: "openai", modelId: "gpt-plan" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).session).toEqual(updated);
|
|
expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7310", projectId: "proj-001" });
|
|
expect(mockUpdateSession).toHaveBeenCalledWith("chat-existing", { modelProvider: "openai", modelId: "gpt-plan" });
|
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not resume another task's planner chat session", async () => {
|
|
const created = {
|
|
...sampleSession,
|
|
id: "chat-planner-7312",
|
|
agentId: "task-planner:FN-7312",
|
|
title: "FN-7312 planner chat",
|
|
};
|
|
mockFindLatestActiveSessionForTarget.mockImplementation((input: { agentId: string }) => {
|
|
return input.agentId === "task-planner:FN-7311"
|
|
? { ...sampleSession, id: "chat-planner-7311", agentId: "task-planner:FN-7311" }
|
|
: null;
|
|
});
|
|
mockCreateSession.mockReturnValue(created);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/task-planner/FN-7312/session",
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect((response.body as any).session).toEqual(created);
|
|
expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7312" });
|
|
expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({
|
|
agentId: "task-planner:FN-7312",
|
|
title: "FN-7312 planner chat",
|
|
}));
|
|
expect(mockUpdateSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects incomplete model override pairs", async () => {
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/task-planner/FN-7310/session",
|
|
JSON.stringify({ modelProvider: "anthropic" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("Both modelProvider and modelId");
|
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns not found for missing tasks in the scoped project store", async () => {
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/task-planner/FN-MISSING/session",
|
|
JSON.stringify({ modelProvider: "anthropic", modelId: "claude-plan" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect((response.body as any).error).toContain("Task FN-MISSING not found");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/chat/sessions", () => {
|
|
it("returns all sessions", async () => {
|
|
mockListSessions.mockReturnValue([sampleSession]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(1);
|
|
expect(mockListSessions).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it("filters by projectId", async () => {
|
|
mockListSessions.mockReturnValue([sampleSession]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions?projectId=proj-001");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(1);
|
|
expect(mockListSessions).toHaveBeenCalledWith({
|
|
projectId: "proj-001",
|
|
});
|
|
});
|
|
|
|
it("filters by status", async () => {
|
|
mockListSessions.mockReturnValue([sampleSession]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions?status=archived");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(1);
|
|
expect(mockListSessions).toHaveBeenCalledWith({
|
|
status: "archived",
|
|
});
|
|
});
|
|
|
|
it("filters by agentId", async () => {
|
|
mockListSessions.mockReturnValue([sampleSession]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions?agentId=agent-001");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(1);
|
|
expect(mockListSessions).toHaveBeenCalledWith({
|
|
agentId: "agent-001",
|
|
});
|
|
});
|
|
|
|
it("returns empty array when no sessions exist", async () => {
|
|
mockListSessions.mockReturnValue([]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(0);
|
|
});
|
|
|
|
it("enriches sessions with lastMessagePreview and lastMessageAt", async () => {
|
|
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
|
mockListSessions.mockReturnValue([sessionWithId]);
|
|
|
|
// Mock last message for the session
|
|
const mockLastMessage = {
|
|
id: "msg-001",
|
|
sessionId: "chat-abc123",
|
|
role: "assistant",
|
|
content: "Hello, how can I help you?",
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
createdAt: "2026-04-15T10:00:00.000Z",
|
|
};
|
|
mockGetLastMessageForSessions.mockReturnValue(
|
|
new Map([["chat-abc123", mockLastMessage]]),
|
|
);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockGetLastMessageForSessions).toHaveBeenCalledWith(["chat-abc123"]);
|
|
const enrichedSession = (response.body as any).sessions[0];
|
|
expect(enrichedSession.lastMessagePreview).toBe("Hello, how can I help you?");
|
|
expect(enrichedSession.lastMessageAt).toBe("2026-04-15T10:00:00.000Z");
|
|
});
|
|
|
|
it("truncates long lastMessagePreview to 100 chars", async () => {
|
|
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
|
mockListSessions.mockReturnValue([sessionWithId]);
|
|
|
|
// Mock a long message
|
|
const longContent = "A".repeat(150);
|
|
const mockLastMessage = {
|
|
id: "msg-001",
|
|
sessionId: "chat-abc123",
|
|
role: "assistant",
|
|
content: longContent,
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
createdAt: "2026-04-15T10:00:00.000Z",
|
|
};
|
|
mockGetLastMessageForSessions.mockReturnValue(
|
|
new Map([["chat-abc123", mockLastMessage]]),
|
|
);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions");
|
|
|
|
expect(response.status).toBe(200);
|
|
const enrichedSession = (response.body as any).sessions[0];
|
|
expect(enrichedSession.lastMessagePreview).toBe("A".repeat(100) + "…");
|
|
expect(enrichedSession.lastMessagePreview).toHaveLength(101);
|
|
});
|
|
|
|
it("does not add lastMessagePreview when session has no messages", async () => {
|
|
const sessionWithId = { ...sampleSession, id: "chat-abc123" };
|
|
mockListSessions.mockReturnValue([sessionWithId]);
|
|
|
|
// No messages for this session
|
|
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions");
|
|
|
|
expect(response.status).toBe(200);
|
|
const enrichedSession = (response.body as any).sessions[0];
|
|
expect(enrichedSession.lastMessagePreview).toBeUndefined();
|
|
expect(enrichedSession.lastMessageAt).toBeUndefined();
|
|
});
|
|
|
|
it("uses engine chatStore when engineManager is configured for requested projectId", async () => {
|
|
const { createServer } = await import("../server.js");
|
|
|
|
// Engine-scoped chatStore — simulates a secondary project's DB
|
|
const engineListSessions = vi.fn().mockReturnValue([sampleSession]);
|
|
const engineChatStore = {
|
|
...mockChatStoreInstance,
|
|
listSessions: engineListSessions,
|
|
getLastMessageForSessions: vi.fn().mockReturnValue(new Map()),
|
|
};
|
|
const mockEngine = { getChatStore: () => engineChatStore };
|
|
const mockEngineManager = {
|
|
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
|
|
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
|
|
};
|
|
|
|
const appWithEngine = createServer(store as any, {
|
|
chatStore: mockChatStore as any,
|
|
chatManager: mockChatManager as any,
|
|
engineManager: mockEngineManager as any,
|
|
});
|
|
|
|
const response = await request(appWithEngine, "GET", "/api/chat/sessions?projectId=proj-secondary");
|
|
|
|
expect(response.status).toBe(200);
|
|
// Engine chatStore was used, not the default one
|
|
expect(engineListSessions).toHaveBeenCalled();
|
|
expect(mockListSessions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to default chatStore when no engineManager is configured", async () => {
|
|
mockListSessions.mockReturnValue([sampleSession]);
|
|
|
|
// app has no engineManager (the default setup)
|
|
const response = await request(app, "GET", "/api/chat/sessions?projectId=proj-001");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockListSessions).toHaveBeenCalledWith({ projectId: "proj-001" });
|
|
});
|
|
});
|
|
|
|
describe("POST /api/chat/sessions", () => {
|
|
it("creates session with required fields and resolves model from agent config", async () => {
|
|
mockCreateSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({ agentId: "agent-001" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect((response.body as any).session.id).toBe("chat-abc123");
|
|
// Model is resolved from agent's runtimeConfig.model
|
|
// projectId is null when no projectId query param is provided
|
|
expect(mockCreateSession).toHaveBeenCalledWith({
|
|
agentId: "agent-001",
|
|
title: null,
|
|
projectId: null,
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
});
|
|
});
|
|
|
|
it("creates session with projectId when projectId query param is provided", async () => {
|
|
const sessionWithProject = {
|
|
...sampleSession,
|
|
projectId: "proj-001",
|
|
};
|
|
mockCreateSession.mockReturnValue(sessionWithProject);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions?projectId=proj-001",
|
|
JSON.stringify({ agentId: "agent-001" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect((response.body as any).session.projectId).toBe("proj-001");
|
|
// Verify projectId is passed to createSession
|
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: "proj-001",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("creates session with title and resolves model from agent config", async () => {
|
|
const sessionWithOptions = {
|
|
...sampleSession,
|
|
title: "Custom Title",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
};
|
|
mockCreateSession.mockReturnValue(sessionWithOptions);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({
|
|
agentId: "agent-001",
|
|
title: "Custom Title",
|
|
}),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect((response.body as any).session.title).toBe("Custom Title");
|
|
// projectId is null when no projectId query param is provided
|
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "Custom Title",
|
|
projectId: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns 400 when agentId is missing", async () => {
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("agentId is required");
|
|
});
|
|
|
|
it("returns 400 when agentId is empty", async () => {
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({ agentId: " " }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("agentId is required");
|
|
});
|
|
|
|
it("returns 404 when agent not found", async () => {
|
|
mockAgentStoreGetAgent.mockResolvedValueOnce(undefined);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({ agentId: "nonexistent" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect((response.body as any).error).toContain("not found");
|
|
});
|
|
|
|
it("creates session with default model when agent has no model config", async () => {
|
|
mockAgentStoreGetAgent.mockResolvedValueOnce({
|
|
id: "agent-002",
|
|
name: "Beta",
|
|
role: "reviewer",
|
|
state: "idle",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
metadata: {},
|
|
runtimeConfig: {},
|
|
});
|
|
|
|
const sessionNoModel = { ...sampleSession, agentId: "agent-002" };
|
|
mockCreateSession.mockReturnValue(sessionNoModel);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions",
|
|
JSON.stringify({ agentId: "agent-002" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(201);
|
|
// No model resolved from agent config
|
|
// projectId is null when no projectId query param is provided
|
|
expect(mockCreateSession).toHaveBeenCalledWith({
|
|
agentId: "agent-002",
|
|
title: null,
|
|
projectId: null,
|
|
modelProvider: null,
|
|
modelId: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("GET /api/chat/sessions/:id", () => {
|
|
it("returns session details", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions/chat-abc123");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).session.id).toBe("chat-abc123");
|
|
expect(mockGetSession).toHaveBeenCalledWith("chat-abc123");
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockGetSession.mockReturnValue(undefined);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions/nonexistent");
|
|
|
|
expect(response.status).toBe(404);
|
|
expect((response.body as any).error).toContain("not found");
|
|
});
|
|
|
|
it("uses engine chatStore to find session when projectId is provided", async () => {
|
|
const { createServer } = await import("../server.js");
|
|
|
|
const engineSession = { ...sampleSession, id: "chat-engine-456", projectId: "proj-secondary" };
|
|
const engineGetSession = vi.fn((id: string) => (id === "chat-engine-456" ? engineSession : undefined));
|
|
const engineChatStore = { ...mockChatStoreInstance, getSession: engineGetSession };
|
|
const mockEngine = { getChatStore: () => engineChatStore };
|
|
const mockEngineManager = {
|
|
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
|
|
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
|
|
};
|
|
|
|
// Default chatStore does NOT have this session
|
|
mockGetSession.mockReturnValue(undefined);
|
|
|
|
const appWithEngine = createServer(store as any, {
|
|
chatStore: mockChatStore as any,
|
|
chatManager: mockChatManager as any,
|
|
engineManager: mockEngineManager as any,
|
|
});
|
|
|
|
const response = await request(appWithEngine, "GET", "/api/chat/sessions/chat-engine-456?projectId=proj-secondary");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).session.id).toBe("chat-engine-456");
|
|
expect(engineGetSession).toHaveBeenCalledWith("chat-engine-456");
|
|
});
|
|
});
|
|
|
|
describe("PATCH /api/chat/sessions/:id", () => {
|
|
it("updates session title", async () => {
|
|
const updatedSession = { ...sampleSession, title: "Updated Title" };
|
|
mockUpdateSession.mockReturnValue(updatedSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"PATCH",
|
|
"/api/chat/sessions/chat-abc123",
|
|
JSON.stringify({ title: "Updated Title" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).session.title).toBe("Updated Title");
|
|
});
|
|
|
|
it("archives session", async () => {
|
|
const archivedSession = { ...sampleSession, status: "archived" as const };
|
|
mockUpdateSession.mockReturnValue(archivedSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"PATCH",
|
|
"/api/chat/sessions/chat-abc123",
|
|
JSON.stringify({ status: "archived" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).session.status).toBe("archived");
|
|
});
|
|
|
|
it("returns 400 for invalid status", async () => {
|
|
const response = await request(
|
|
app,
|
|
"PATCH",
|
|
"/api/chat/sessions/chat-abc123",
|
|
JSON.stringify({ status: "invalid" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("status must be");
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockUpdateSession.mockReturnValue(undefined);
|
|
|
|
const response = await request(
|
|
app,
|
|
"PATCH",
|
|
"/api/chat/sessions/nonexistent",
|
|
JSON.stringify({ title: "New Title" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/chat/sessions/:id", () => {
|
|
it("deletes session", async () => {
|
|
mockDeleteSession.mockReturnValue(true);
|
|
|
|
const response = await request(app, "DELETE", "/api/chat/sessions/chat-abc123");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).success).toBe(true);
|
|
expect(mockDeleteSession).toHaveBeenCalledWith("chat-abc123");
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockDeleteSession.mockReturnValue(false);
|
|
|
|
const response = await request(app, "DELETE", "/api/chat/sessions/nonexistent");
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ── Message CRUD Tests ─────────────────────────────────────────────────────
|
|
|
|
describe("GET /api/chat/sessions/:id/messages", () => {
|
|
it("returns messages for session", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessages.mockReturnValue([sampleMessage]);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions/chat-abc123/messages");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).messages).toHaveLength(1);
|
|
expect(mockGetMessages).toHaveBeenCalledWith("chat-abc123", {
|
|
limit: 50,
|
|
offset: 0,
|
|
});
|
|
});
|
|
|
|
it("applies pagination parameters", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessages.mockReturnValue([sampleMessage]);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?limit=10&offset=5",
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockGetMessages).toHaveBeenCalledWith("chat-abc123", {
|
|
limit: 10,
|
|
offset: 5,
|
|
});
|
|
});
|
|
|
|
it("limits max limit to 200", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessages.mockReturnValue([]);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?limit=500",
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockGetMessages).toHaveBeenCalledWith("chat-abc123", {
|
|
limit: 200,
|
|
offset: 0,
|
|
});
|
|
});
|
|
|
|
it("returns 400 for invalid limit", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?limit=-1",
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("limit must be a positive integer");
|
|
});
|
|
|
|
it("returns 400 for invalid offset", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?offset=-5",
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("offset must be a non-negative integer");
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockGetSession.mockReturnValue(undefined);
|
|
|
|
const response = await request(app, "GET", "/api/chat/sessions/nonexistent/messages");
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("passes order=desc to getMessages when query param is provided", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessages.mockReturnValue([sampleMessage]);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?order=desc",
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockGetMessages).toHaveBeenCalledWith("chat-abc123", expect.objectContaining({
|
|
order: "desc",
|
|
}));
|
|
});
|
|
|
|
it("returns 400 for invalid order value", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-abc123/messages?order=invalid",
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toMatch(/order/i);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/chat/sessions/:id/cancel", () => {
|
|
it("returns success true when generation is cancelled", async () => {
|
|
mockCancelGeneration.mockReturnValue(true);
|
|
|
|
const response = await request(app, "POST", "/api/chat/sessions/chat-abc123/cancel");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ success: true });
|
|
expect(mockCancelGeneration).toHaveBeenCalledWith("chat-abc123");
|
|
});
|
|
|
|
it("returns success false when no active generation exists", async () => {
|
|
mockCancelGeneration.mockReturnValue(false);
|
|
|
|
const response = await request(app, "POST", "/api/chat/sessions/chat-abc123/cancel");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ success: false });
|
|
expect(mockCancelGeneration).toHaveBeenCalledWith("chat-abc123");
|
|
});
|
|
|
|
it("returns 503 when chat manager is unavailable", async () => {
|
|
const express = await import("express");
|
|
const { createApiRoutes } = await import("../routes.js");
|
|
|
|
const appWithoutManager = express.default();
|
|
appWithoutManager.use(express.json());
|
|
appWithoutManager.use("/api", createApiRoutes(store as any, {
|
|
chatStore: mockChatStore as any,
|
|
chatManager: undefined,
|
|
}));
|
|
|
|
const response = await request(appWithoutManager as any, "POST", "/api/chat/sessions/chat-abc123/cancel");
|
|
|
|
expect(response.status).toBe(503);
|
|
expect((response.body as any).error).toContain("Chat manager not available");
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/chat/sessions/:id/messages/:messageId", () => {
|
|
it("deletes message when session exists", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessage.mockReturnValue(sampleMessage);
|
|
mockDeleteMessage.mockReturnValue(true);
|
|
|
|
const response = await request(
|
|
app,
|
|
"DELETE",
|
|
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).success).toBe(true);
|
|
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-xyz789");
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockGetSession.mockReturnValue(undefined);
|
|
|
|
const response = await request(
|
|
app,
|
|
"DELETE",
|
|
"/api/chat/sessions/nonexistent/messages/msg-xyz789",
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when message not found", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessage.mockReturnValue(undefined);
|
|
|
|
const response = await request(
|
|
app,
|
|
"DELETE",
|
|
"/api/chat/sessions/chat-abc123/messages/nonexistent",
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when deleteMessage returns false", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockGetMessage.mockReturnValue(sampleMessage);
|
|
mockDeleteMessage.mockReturnValue(false);
|
|
|
|
const response = await request(
|
|
app,
|
|
"DELETE",
|
|
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-xyz789");
|
|
});
|
|
});
|
|
|
|
// ── SSE Streaming Tests ────────────────────────────────────────────────────
|
|
|
|
describe("POST /api/chat/sessions/:id/messages (SSE)", () => {
|
|
it("creates a fresh backend send for second turn on same session", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: { messageId: `msg-${Date.now()}` },
|
|
});
|
|
});
|
|
|
|
const first = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: "Turn 1" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
const second = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: "Turn 2" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(second.status).toBe(200);
|
|
});
|
|
|
|
it("returns 404 when session not found", async () => {
|
|
mockGetSession.mockReturnValue(undefined);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/nonexistent/messages",
|
|
JSON.stringify({ content: "Hello" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when content is empty", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: "" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("content is required");
|
|
});
|
|
|
|
it("returns 400 when content is whitespace only", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: " " }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("content is required");
|
|
});
|
|
|
|
it("rejects a taskId that does not match a task-planner chat session", async () => {
|
|
mockGetSession.mockReturnValue({ ...sampleSession, agentId: "task-planner:FN-7312" });
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: "What is the status?", taskId: "FN-OTHER" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("taskId does not match");
|
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts matching taskId metadata while preserving the clean user content sent to ChatManager", async () => {
|
|
mockGetSession.mockReturnValue({ ...sampleSession, agentId: "task-planner:FN-7312" });
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: { messageId: "msg-task-planner" },
|
|
});
|
|
});
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
JSON.stringify({ content: "What is the status?", taskId: "FN-7312" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
"chat-abc123",
|
|
"What is the status?",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ generationId: expect.any(Number) },
|
|
);
|
|
});
|
|
|
|
it("accepts multipart content with attachments without crashing", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: { messageId: "msg-multipart" },
|
|
});
|
|
});
|
|
|
|
const { payload, boundary } = makeMultipartMessageRequest({
|
|
fields: { content: "Hello with attachment" },
|
|
files: [{
|
|
fieldName: "attachments",
|
|
filename: "note.txt",
|
|
contentType: "text/plain",
|
|
body: Buffer.from("hello"),
|
|
}],
|
|
});
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
payload,
|
|
{ "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
payload,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(String(response.body)).not.toContain("Cannot destructure property 'content'");
|
|
});
|
|
|
|
it("returns 400 for multipart requests missing content instead of crashing", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
/**
|
|
* FNXC:Chat 2026-06-18-06:34:
|
|
* FN-6637 keeps this regression test aligned with the register-chat-routes.ts FNXC:Chat 2026-06-17-02:12 invariant: attachment-only multipart sends are valid, so the 400 no-SSE path must omit both text content and files.
|
|
*/
|
|
const { payload, boundary } = makeMultipartMessageRequest({});
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
"/api/chat/sessions/chat-abc123/messages",
|
|
payload,
|
|
{ "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
payload,
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect((response.body as any).error).toContain("content is required");
|
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("SSE stream lifecycle", () => {
|
|
beforeEach(() => {
|
|
mockSendMessage.mockResolvedValue(undefined);
|
|
mockChatStreamManager.reset();
|
|
mockChatStreamManager.getBufferedEvents.mockClear();
|
|
mockChatStreamManager.subscribe.mockClear();
|
|
mockChatStreamManager.broadcast.mockClear();
|
|
mockChatStreamManager.cleanupSession.mockClear();
|
|
});
|
|
|
|
/**
|
|
* Helper to invoke the chat SSE route handler directly.
|
|
* Tests the SSE route behavior by calling the handler with mock req/res.
|
|
*/
|
|
async function invokeSSEHandler(
|
|
req: Request,
|
|
res: Response,
|
|
store: any,
|
|
chatStore: any,
|
|
chatManager: any,
|
|
routePath = "/chat/sessions/:id/messages",
|
|
method: "get" | "post" = "post",
|
|
): Promise<void> {
|
|
const { createApiRoutes } = await import("../routes.js");
|
|
const router = createApiRoutes(store, {
|
|
chatStore,
|
|
chatManager,
|
|
});
|
|
|
|
const stack = router.stack || [];
|
|
const handler = stack.find(
|
|
(layer: any) =>
|
|
layer.route?.path === routePath &&
|
|
layer.route?.methods?.[method],
|
|
);
|
|
|
|
if (!handler) {
|
|
throw new Error(`SSE route handler not found (${method.toUpperCase()} ${routePath}). Stack has ${stack.length} layers.`);
|
|
}
|
|
|
|
const routeHandler = handler.route.stack[handler.route.stack.length - 1].handle;
|
|
const next = vi.fn();
|
|
await routeHandler(req, res, next);
|
|
}
|
|
|
|
it("handler is found in router stack", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const { createApiRoutes } = await import("../routes.js");
|
|
const router = createApiRoutes(store, {
|
|
chatStore: mockChatStore,
|
|
chatManager: mockChatManager,
|
|
});
|
|
|
|
const stack = router.stack || [];
|
|
const handler = stack.find(
|
|
(layer: any) =>
|
|
layer.route?.path === "/chat/sessions/:id/messages" &&
|
|
layer.route?.methods?.post,
|
|
);
|
|
|
|
expect(handler).toBeDefined();
|
|
});
|
|
|
|
it("handler can be invoked", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const req = createSSERequest();
|
|
const { res } = createSSEResponse();
|
|
|
|
req.body = { content: "Hello" };
|
|
req.params = { id: "chat-abc123" };
|
|
req.ip = "127.0.0.1";
|
|
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
|
|
|
// Get the router and find handler
|
|
const { createApiRoutes } = await import("../routes.js");
|
|
const router = createApiRoutes(store, {
|
|
chatStore: mockChatStore,
|
|
chatManager: mockChatManager,
|
|
});
|
|
|
|
const stack = router.stack || [];
|
|
const handler = stack.find(
|
|
(layer: any) =>
|
|
layer.route?.path === "/chat/sessions/:id/messages" &&
|
|
layer.route?.methods?.post,
|
|
);
|
|
|
|
expect(handler).toBeDefined();
|
|
|
|
// Invoke the handler - handler should execute without error
|
|
const next = vi.fn();
|
|
const routeHandler = handler.route.stack[handler.route.stack.length - 1].handle;
|
|
const result = routeHandler(req, res, next);
|
|
|
|
// If it returns a promise, await it
|
|
if (result && typeof result.then === "function") {
|
|
await result;
|
|
}
|
|
|
|
// Handler executed without throwing
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
|
|
it("attach stream returns 404 for unknown session", async () => {
|
|
mockGetSession.mockReturnValue(null);
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
"/api/chat/sessions/chat-missing/stream",
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("attach stream replays buffered events and ends when not generating", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockIsGenerating.mockReturnValue(false);
|
|
mockChatStreamManager.getBufferedEvents.mockReturnValue([
|
|
{ id: 2, event: "text", data: JSON.stringify("hello") },
|
|
{ id: 3, event: "done", data: JSON.stringify({ messageId: "msg-1" }) },
|
|
]);
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager, "/chat/sessions/:id/stream", "get");
|
|
|
|
const output = chunks.join("");
|
|
expect(output).toContain("id: 2");
|
|
expect(output).toContain("event: text");
|
|
expect(output).toContain("id: 3");
|
|
expect(output).toContain("event: done");
|
|
expect(res.end).toHaveBeenCalled();
|
|
expect(mockChatStreamManager.subscribe).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("attach stream replays buffered events and receives live generation events", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockIsGenerating.mockReturnValue(true);
|
|
mockGetActiveGenerationId.mockReturnValue(42);
|
|
mockChatStreamManager.getBufferedEvents.mockReturnValue([
|
|
{ id: 5, event: "text", data: JSON.stringify("buffer") },
|
|
]);
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager, "/chat/sessions/:id/stream", "get");
|
|
expect(mockChatStreamManager.subscribe).toHaveBeenCalledWith(
|
|
"chat-abc123",
|
|
expect.any(Function),
|
|
{ generationId: 42 },
|
|
);
|
|
|
|
const subscriber = mockChatStreamManager.subscribe.mock.calls.at(-1)?.[1] as ((event: any, id?: number) => void);
|
|
subscriber({ type: "text", data: "live" }, 6);
|
|
subscriber({ type: "done", data: { messageId: "msg-2" } }, 7);
|
|
|
|
const output = chunks.join("");
|
|
expect(output).toContain("id: 5");
|
|
expect(output).toContain("data: \"buffer\"");
|
|
expect(output).toContain("data: \"live\"");
|
|
});
|
|
|
|
it("attach stream honors Last-Event-ID replay cutoff", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockIsGenerating.mockReturnValue(false);
|
|
|
|
const req = createSSERequest();
|
|
const { res } = createSSEResponse();
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = { "last-event-id": "9" } as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager, "/chat/sessions/:id/stream", "get");
|
|
|
|
expect(mockChatStreamManager.getBufferedEvents).toHaveBeenCalledWith("chat-abc123", 9);
|
|
});
|
|
|
|
it("attach stream filters out events from a different generation", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
mockIsGenerating.mockReturnValue(true);
|
|
mockGetActiveGenerationId.mockReturnValue(7);
|
|
mockChatStreamManager.getBufferedEvents.mockReturnValue([]);
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager, "/chat/sessions/:id/stream", "get");
|
|
mockChatStreamManager.broadcast("chat-abc123", { type: "text", data: "wrong" }, { generationId: 8 });
|
|
const subscriber = mockChatStreamManager.subscribe.mock.calls.at(-1)?.[1] as ((event: any, id?: number) => void);
|
|
subscriber({ type: "text", data: "right" }, 3);
|
|
subscriber({ type: "done", data: { messageId: "msg-3" } }, 4);
|
|
|
|
const output = chunks.join("");
|
|
expect(mockChatStreamManager.subscribe).toHaveBeenCalledWith(
|
|
"chat-abc123",
|
|
expect.any(Function),
|
|
{ generationId: 7 },
|
|
);
|
|
expect(output).toContain("right");
|
|
expect(output).not.toContain("wrong");
|
|
});
|
|
|
|
it("SSE route passes through tool_start and tool_end events", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const chatModule = await import("../chat.js");
|
|
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
|
|
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "tool_start",
|
|
data: {
|
|
toolName: "read",
|
|
args: { path: "/foo.ts" },
|
|
},
|
|
});
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "tool_end",
|
|
data: {
|
|
toolName: "read",
|
|
isError: false,
|
|
result: "file contents",
|
|
},
|
|
});
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: { messageId: "msg-tool" },
|
|
});
|
|
});
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
|
|
req.body = { content: "Read #foo.ts" };
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
req.ip = "127.0.0.1";
|
|
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
|
|
|
|
const output = chunks.join("");
|
|
expect(output).toContain("event: tool_start");
|
|
expect(output).toContain("event: tool_end");
|
|
|
|
expect(output).toContain('data: {"toolName":"read","args":{"path":"/foo.ts"}}');
|
|
expect(output).toContain('data: {"toolName":"read","isError":false,"result":"file contents"}');
|
|
});
|
|
|
|
it("SSE route forwards done payload with assistant snapshot", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const chatModule = await import("../chat.js");
|
|
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
|
|
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: {
|
|
messageId: "msg-final",
|
|
message: {
|
|
id: "msg-final",
|
|
sessionId,
|
|
role: "assistant",
|
|
content: "Final reply",
|
|
thinkingOutput: null,
|
|
metadata: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
|
|
req.body = { content: "Hello" };
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
req.ip = "127.0.0.1";
|
|
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
|
|
|
|
const output = chunks.join("");
|
|
expect(output).toContain("event: done");
|
|
expect(output).toContain('"messageId":"msg-final"');
|
|
expect(output).toContain('"content":"Final reply"');
|
|
});
|
|
|
|
it("SSE route forwards structured error payloads", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const chatModule = await import("../chat.js");
|
|
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
|
|
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "error",
|
|
data: {
|
|
summary: "Model request failed",
|
|
errorClass: "ProviderError",
|
|
code: "E_MODEL",
|
|
detail: "ProviderError: Model request failed",
|
|
},
|
|
});
|
|
});
|
|
|
|
const req = createSSERequest();
|
|
const { res, chunks } = createSSEResponse();
|
|
|
|
req.body = { content: "Hello" };
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
req.ip = "127.0.0.1";
|
|
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
|
|
|
|
const output = chunks.join("");
|
|
expect(output).toContain("event: error");
|
|
expect(output).toContain('"summary":"Model request failed"');
|
|
expect(output).toContain('"errorClass":"ProviderError"');
|
|
expect(output).toContain('"code":"E_MODEL"');
|
|
});
|
|
|
|
it("uses the same generation id for subscription and sendMessage", async () => {
|
|
mockGetSession.mockReturnValue(sampleSession);
|
|
|
|
const chatModule = await import("../chat.js");
|
|
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
|
|
mockBeginGeneration.mockReturnValueOnce({
|
|
generationId: 42,
|
|
abortController: new AbortController(),
|
|
});
|
|
|
|
mockSendMessage.mockImplementation(async (sessionId: string) => {
|
|
mockChatStreamManager.broadcast(sessionId, {
|
|
type: "done",
|
|
data: { messageId: "msg-final" },
|
|
});
|
|
});
|
|
|
|
const req = createSSERequest();
|
|
const { res } = createSSEResponse();
|
|
|
|
req.body = { content: "Hello" };
|
|
req.params = { id: "chat-abc123" };
|
|
req.query = {} as any;
|
|
req.headers = {} as any;
|
|
req.ip = "127.0.0.1";
|
|
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
|
|
|
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
|
|
|
|
expect(mockBeginGeneration).toHaveBeenCalledWith("chat-abc123");
|
|
expect(mockChatStreamManager.subscribe).toHaveBeenCalledWith(
|
|
"chat-abc123",
|
|
expect.any(Function),
|
|
{ generationId: 42 },
|
|
);
|
|
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
"chat-abc123",
|
|
"Hello",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ generationId: 42 },
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Error Handling Tests ───────────────────────────────────────────────────
|
|
|
|
// Note: The "returns 500 when chat store is not available" test was skipped
|
|
// because server.ts creates its own ChatStore when none is provided via:
|
|
// const chatStore = options?.chatStore ?? new ChatStore(...)
|
|
// This means the route won't fail when chatStore is undefined in tests.
|
|
});
|
|
|
|
// ── multi-project chat routing ──────────────────────────────────────────────
|
|
|
|
describe("multi-project chat routing", () => {
|
|
let store: MockStore;
|
|
let app: ReturnType<typeof import("../server.js").createServer>;
|
|
let mockChatStore: typeof mockChatStoreInstance;
|
|
let mockChatManager: ReturnType<typeof createMockChatManager>;
|
|
|
|
const secondarySession = {
|
|
id: "secondary-session-1",
|
|
agentId: "agent-001",
|
|
title: "Secondary Chat",
|
|
status: "active",
|
|
projectId: "secondary-proj-id",
|
|
modelProvider: null,
|
|
modelId: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
// Reset per-project caches to ensure no cross-test pollution
|
|
const { __resetScopedChatManagerCache, __resetScopedChatStoreCache } = await import("../chat-project-services.js");
|
|
__resetScopedChatManagerCache();
|
|
__resetScopedChatStoreCache();
|
|
|
|
mockInit.mockResolvedValue(undefined);
|
|
mockGetSession.mockReset();
|
|
mockListSessions.mockReset();
|
|
mockGetLastMessageForSessions.mockReset();
|
|
mockSendMessage.mockReset();
|
|
mockCancelGeneration.mockReset();
|
|
mockBeginGeneration.mockReturnValue({ generationId: 1, abortController: new AbortController() });
|
|
mockIsGenerating.mockReturnValue(false);
|
|
mockGetActiveGenerationId.mockReturnValue(undefined);
|
|
mockGetOrCreateProjectStore.mockReset();
|
|
|
|
// Default list/message mocks
|
|
mockListSessions.mockReturnValue([]);
|
|
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
|
mockCancelGeneration.mockReturnValue(false);
|
|
|
|
store = new MockStore();
|
|
mockChatStore = mockChatStoreInstance;
|
|
mockChatManager = createMockChatManager();
|
|
|
|
// Secondary project store resolves to same MockStore for simplicity
|
|
mockGetOrCreateProjectStore.mockResolvedValue(store);
|
|
|
|
const { createServer } = await import("../server.js");
|
|
app = createServer(store as any, {
|
|
chatStore: mockChatStore as any,
|
|
chatManager: mockChatManager as any,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("POST /cancel uses scoped ChatManager when projectId is provided", async () => {
|
|
mockCancelGeneration.mockReturnValue(false);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
`/api/chat/sessions/${secondarySession.id}/cancel?projectId=${secondarySession.projectId}`,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).success).toBe(false);
|
|
// Scoped path: getOrCreateProjectStore is called with the secondary projectId
|
|
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
|
|
// cancelGeneration was called on the scoped manager
|
|
expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id);
|
|
});
|
|
|
|
it("POST /cancel falls back to global ChatManager when no projectId", async () => {
|
|
mockCancelGeneration.mockReturnValue(false);
|
|
|
|
const response = await request(
|
|
app,
|
|
"POST",
|
|
`/api/chat/sessions/${secondarySession.id}/cancel`,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).success).toBe(false);
|
|
// Global path: getOrCreateProjectStore is NOT called
|
|
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
|
|
// cancelGeneration was called on the global manager
|
|
expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id);
|
|
});
|
|
|
|
it("GET /sessions uses scoped ChatManager for isGenerating when projectId is provided", async () => {
|
|
mockListSessions.mockReturnValue([secondarySession]);
|
|
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
|
|
|
const response = await request(
|
|
app,
|
|
"GET",
|
|
`/api/chat/sessions?projectId=${secondarySession.projectId}`,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((response.body as any).sessions).toHaveLength(1);
|
|
// Scoped path: getOrCreateProjectStore is called for isGenerating resolution
|
|
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
|
|
// isGenerating defaults to false (MockChatManager has no getGeneratingSessionIds)
|
|
expect((response.body as any).sessions[0].isGenerating).toBe(false);
|
|
});
|
|
});
|