/** * Unit tests for the POST /v1/btw SSE-streaming side-chain endpoint. * * Validates request validation (400s), service unavailability (503), * successful SSE streaming, provider argument passing, no persistence, * and no session.processing mutation. */ import { rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { beforeEach, describe, expect, mock, test } from "bun:test"; // --------------------------------------------------------------------------- // Mocks — must be defined before importing the module under test // --------------------------------------------------------------------------- const mockGetConversationByKey = mock( ( _key: string, ): { conversationId: string; } | null => ({ conversationId: "conv-test-123", }), ); mock.module("../persistence/conversation-key-store.js", () => ({ getConversationByKey: mockGetConversationByKey, getOrCreateConversation: () => { throw new Error( "getOrCreateConversation must not be called from btw-routes", ); }, })); const mockAddMessage = mock(() => {}); mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, addMessage: mockAddMessage, reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const MOCK_TOOLS = [ { name: "test_tool", description: "A test tool", input_schema: { type: "object", properties: {} }, }, ]; mock.module("../tools/registry.js", () => ({ getAllToolDefinitions: () => MOCK_TOOLS, })); const MOCK_SYSTEM_PROMPT = "You are a helpful assistant."; const mockBuildSystemPrompt = mock(() => MOCK_SYSTEM_PROMPT); mock.module("../prompts/system-prompt.js", () => ({ buildSystemPrompt: mockBuildSystemPrompt, })); mock.module("../prompts/persona-resolver.js", () => ({ resolvePersonaContext: () => ({ userPersona: null, userSlug: null, channelPersona: null, }), resolveGuardianPersona: () => null, resolveChannelPersona: () => null, resolveUserPersona: () => null, resolveUserSlug: () => null, })); mock.module("../runtime/routes/workspace-greetings.js", () => ({ readWorkspaceGreetings: () => null, })); // Mock getOrCreateConversation from conversation-store so the handler // never touches DaemonServer. const mockGetOrCreateConversation = mock(async (_id: string) => makeMockSession(), ); mock.module("../daemon/conversation-store.js", () => ({ getOrCreateConversation: mockGetOrCreateConversation, findConversation: () => undefined, findConversationBySurfaceId: () => undefined, setConversation: () => {}, deleteConversation: () => false, clearConversations: () => {}, hasConversation: () => false, conversationCount: () => 0, allConversations: () => [][Symbol.iterator](), conversationEntries: () => [][Symbol.iterator](), conversationIds: () => [][Symbol.iterator](), getConversationMap: () => new Map(), registerConversationFactory: () => {}, getOrCreateActiveConversation: mockGetOrCreateConversation, getConversationOptions: () => undefined, setConversationOptions: () => {}, mergeConversationOptions: () => {}, deleteConversationOptions: () => {}, clearConversationOptions: () => {}, })); // --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import type { ProviderResponse, SendMessageOptions, } from "../providers/types.js"; import { ROUTES } from "../runtime/routes/btw-routes.js"; import { BadRequestError, ServiceUnavailableError, } from "../runtime/routes/errors.js"; import type { RouteHandlerArgs } from "../runtime/routes/types.js"; import { getWorkspaceDir } from "../util/platform.js"; import { setConfig } from "./helpers/set-config.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeMockProvider( onSendMessage?: ( messages: unknown[], options?: SendMessageOptions, ) => Promise, ) { const defaultSendMessage = async ( _messages: unknown[], options?: SendMessageOptions, ): Promise => { options?.onEvent?.({ type: "text_delta", text: "hello" }); return { content: [{ type: "text", text: "hello" }], model: "test-model", usage: { inputTokens: 10, outputTokens: 5 }, stopReason: "end_turn", }; }; return { name: "test-provider", sendMessage: mock(onSendMessage ?? defaultSendMessage), }; } function makeMockSession( providerOverride?: ReturnType, ) { const provider = providerOverride ?? makeMockProvider(); return { provider, systemPrompt: "You are a helpful assistant.", processing: false, getMessages: () => [ { role: "user" as const, content: [{ type: "text" as const, text: "prior message" }], }, { role: "assistant" as const, content: [{ type: "text" as const, text: "prior response" }], }, ], }; } const route = ROUTES.find((r) => r.endpoint === "btw" && r.method === "POST"); if (!route) { throw new Error("btw route not found in ROUTES"); } async function callHandler( body: Record, ): Promise<{ result: unknown; error?: unknown }> { const args: RouteHandlerArgs = { body, headers: {}, abortSignal: new AbortController().signal, }; try { const result = await route!.handler(args); return { result }; } catch (error) { return { result: undefined, error }; } } async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); if (done) { break; } chunks.push(value); } return new TextDecoder().decode( new Uint8Array(chunks.reduce((a, c) => a + c.length, 0)).buffer .byteLength === 0 ? new Uint8Array(0) : Buffer.concat(chunks), ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("POST /v1/btw", () => { beforeEach(() => { // Disable greeting caching so every request generates fresh, and keep // NOW.md scratchpad injection out of the prompts these tests assert on. setConfig("ui", { emptyStateGreetingCacheTtlMs: 0 }); setConfig("memory", { retrieval: { scratchpadInjection: { enabled: false } }, }); }); // -- Validation (400s) -- test("throws BadRequestError for missing conversationKey", async () => { const { error } = await callHandler({ content: "hello" }); expect(error).toBeInstanceOf(BadRequestError); expect((error as BadRequestError).message).toContain("conversationKey"); }); test("throws BadRequestError for missing content", async () => { const { error } = await callHandler({ conversationKey: "key" }); expect(error).toBeInstanceOf(BadRequestError); expect((error as BadRequestError).message).toContain("content"); }); test("throws BadRequestError for empty content", async () => { const { error } = await callHandler({ conversationKey: "key", content: "", }); expect(error).toBeInstanceOf(BadRequestError); expect((error as BadRequestError).message).toContain("content"); }); // -- Service unavailability (503) -- test("throws ServiceUnavailableError when conversation factory fails", async () => { mockGetOrCreateConversation.mockImplementationOnce(async () => { throw new Error("not initialized"); }); const { error } = await callHandler({ conversationKey: "key", content: "hello", }); expect(error).toBeInstanceOf(ServiceUnavailableError); }); // -- Successful SSE streaming -- test("streams btw_text_delta SSE events", async () => { const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "key", content: "hello", }); expect(result).toBeInstanceOf(ReadableStream); const text = await readStream(result as ReadableStream); expect(text).toContain(`event: btw_text_delta\ndata: {"text":"hello"}`); }); test("response ends with btw_complete", async () => { const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "key", content: "hello", }); const text = await readStream(result as ReadableStream); expect(text).toContain("event: btw_complete\ndata: {}"); }); // -- Provider receives correct args -- test("provider receives session messages + btw user message, system prompt, tools, and tool_choice none", async () => { mockBuildSystemPrompt.mockClear(); const provider = makeMockProvider(); const session = makeMockSession(provider); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "key", content: " my question ", }); await readStream(result as ReadableStream); expect(provider.sendMessage).toHaveBeenCalledTimes(1); const [messages, options] = provider.sendMessage.mock.calls[0]; expect(messages).toHaveLength(3); expect(messages[0]).toEqual({ role: "user", content: [{ type: "text", text: "prior message" }], }); expect(messages[1]).toEqual({ role: "assistant", content: [{ type: "text", text: "prior response" }], }); expect(messages[2]).toEqual({ role: "user", content: [{ type: "text", text: "my question" }], }); expect(options!.tools).toEqual(MOCK_TOOLS); expect(options!.systemPrompt).toBe(MOCK_SYSTEM_PROMPT); // Persona is resolved internally now; btw-routes no longer plumbs // it through to buildSystemPrompt. expect(mockBuildSystemPrompt).toHaveBeenCalledWith({ excludeBootstrap: true, excludeCustomPrefix: true, }); expect(options!.config!.tool_choice).toEqual({ type: "none" }); expect(options!.config!.callSite).toBe("identityIntro"); expect(options!.config!.modelIntent).toBeUndefined(); }); test("greeting requests pass callSite: 'emptyStateGreeting'", async () => { const provider = makeMockProvider(); const session = makeMockSession(provider); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "greeting", content: "Generate a greeting", }); await readStream(result as ReadableStream); expect(provider.sendMessage).toHaveBeenCalledTimes(1); const [, options] = provider.sendMessage.mock.calls[0]; expect(options!.config!.callSite).toBe("emptyStateGreeting"); }); test("greeting requests include fresh turn context using the client timezone", async () => { const provider = makeMockProvider(); const session = makeMockSession(provider); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "greeting", content: "Generate a greeting", clientTimezone: "Europe/Skopje", }); await readStream(result as ReadableStream); expect(provider.sendMessage).toHaveBeenCalledTimes(1); const [messages] = provider.sendMessage.mock.calls[0] as [ Array<{ content: Array<{ type: string; text: string }> }>, SendMessageOptions | undefined, ]; const greetingPrompt = messages[2]!.content[0]!.text; expect(greetingPrompt).toContain("Generate a greeting"); expect(greetingPrompt).toContain(""); expect(greetingPrompt).toContain("current_time:"); expect(greetingPrompt).toContain("(Europe/Skopje)"); }); test("generic requests pass the default callSite", async () => { const provider = makeMockProvider(); const session = makeMockSession(provider); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "profile-intro", content: "Generate an intro", }); await readStream(result as ReadableStream); expect(provider.sendMessage).toHaveBeenCalledTimes(1); const [, options] = provider.sendMessage.mock.calls[0]; expect(options!.config!.callSite).toBe("identityIntro"); }); test("generic requests do not synthesize a static name greeting", async () => { const identityPath = join(getWorkspaceDir(), "IDENTITY.md"); writeFileSync( identityPath, "# Identity\n\n- **Name:** Example Assistant\n", "utf-8", ); try { const provider = makeMockProvider(); const session = makeMockSession(provider); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "profile-intro", content: "Generate an intro", }); const text = await readStream(result as ReadableStream); expect(provider.sendMessage).toHaveBeenCalledTimes(1); expect(text).not.toContain("Hi, I'm Example Assistant!"); } finally { rmSync(identityPath, { force: true }); } }); // -- No persistence -- test("does not persist any messages (addMessage never called)", async () => { mockAddMessage.mockClear(); const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "key", content: "hello", }); await readStream(result as ReadableStream); expect(mockAddMessage).not.toHaveBeenCalled(); }); // -- session.processing not touched -- test("session.processing remains unchanged", async () => { const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); expect(session.processing).toBe(false); const { result } = await callHandler({ conversationKey: "key", content: "hello", }); await readStream(result as ReadableStream); expect(session.processing).toBe(false); }); // -- Conversation key resolution -- test("unknown conversationKey falls back to raw key", async () => { mockGetConversationByKey.mockReturnValueOnce(null); const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "greeting-abc123", content: "Generate a greeting", }); await readStream(result as ReadableStream); expect(mockGetConversationByKey).toHaveBeenCalledWith("greeting-abc123"); // An unmapped key has no real conversation, so the side-chain runs against // an ephemeral in-memory conversation with no persisted (sidebar-visible) // row. expect(mockGetOrCreateConversation).toHaveBeenCalledWith( "greeting-abc123", { ephemeral: true, }, ); }); test("known conversationKey resolves to existing conversation ID without the ephemeral flag", async () => { mockGetConversationByKey.mockReturnValueOnce({ conversationId: "existing-conv-id", }); const session = makeMockSession(); mockGetOrCreateConversation.mockImplementationOnce(async () => session); const { result } = await callHandler({ conversationKey: "my-conversation-key", content: "What is 2+2?", }); await readStream(result as ReadableStream); // A mapped key targets a real conversation, so its persisted row is reused // — no ephemeral options. expect(mockGetOrCreateConversation).toHaveBeenCalledWith( "existing-conv-id", undefined, ); }); });