import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { MindDB, FrameStore, SessionStore, WaggleConfig } from '@waggle/core'; import { buildLocalServer } from '../src/local/index.js'; import type { FastifyInstance } from 'fastify'; import type { AgentLoopConfig, AgentResponse } from '@waggle/agent'; import { applyContextWindow, filterGatedToolsForConversationalTurn, filterPluginToolsForConversationalTurn, isExplicitExternalResearchRequest, isExplicitGatedToolRequest, isExplicitMemoryRecallRequest, isExplicitMemorySaveRequest, MAX_CONTEXT_MESSAGES, } from '../src/local/routes/chat.js'; import { chatHistoryDataDir, chatSessionStateKey, isolateLegacyDefaultChatSessions, loadSessionMessages, persistMessage, resolveChatHistoryTarget, } from '../src/local/routes/chat-persistence.js'; import { GENERATION_FAILED_PREFIX } from '@waggle/shared'; import { injectWithAuth, resetRateLimiter } from './test-utils.js'; /** * Parse raw SSE response body into an array of { event, data } objects. */ function parseSSE(raw: string): Array<{ event: string; data: string }> { const events: Array<{ event: string; data: string }> = []; const blocks = raw.split(/\n\n/).filter(Boolean); for (const block of blocks) { let event = ''; let data = ''; for (const line of block.split('\n')) { if (line.startsWith('event: ')) { event = line.slice(7); } else if (line.startsWith('data: ')) { data = line.slice(6); } } if (event || data) { events.push({ event, data }); } } return events; } describe('Chat Streaming API', () => { let server: FastifyInstance; let tmpDir: string; async function runOverlappingTurns( first: { message: string; workspace: string; session: string }, second: { message: string; workspace: string; session: string }, ) { const originalRunner = server.agentRunner; const captured = new Map>(); let entered = 0; let releaseFirst!: () => void; let releaseSecond!: () => void; let resolveBothEntered!: () => void; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); const secondGate = new Promise((resolve) => { releaseSecond = resolve; }); const bothEntered = new Promise((resolve) => { resolveBothEntered = resolve; }); server.agentRunner = async (config: AgentLoopConfig): Promise => { const turnMessage = config.messages.at(-1)?.content ?? ''; captured.set(turnMessage, config.messages.map(({ role, content }) => ({ role, content }))); entered += 1; if (entered === 2) resolveBothEntered(); await (turnMessage === first.message ? firstGate : secondGate); return { content: `reply:${turnMessage}`, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; const firstRequest = injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: first, }); const secondRequest = injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: second, }); try { const completedBeforeOverlap = Promise.race([firstRequest, secondRequest]).then(() => { if (entered < 2) throw new Error('A chat request completed before both turns overlapped'); }); await Promise.race([bothEntered, completedBeforeOverlap]); // Finish the second turn first to prove completion order cannot swap state. releaseSecond(); const secondResponse = await secondRequest; releaseFirst(); const firstResponse = await firstRequest; return { captured, firstResponse, secondResponse }; } finally { releaseFirst(); releaseSecond(); server.agentRunner = originalRunner; } } beforeAll(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-chat-test-')); // Create personal.mind with test data const personalPath = path.join(tmpDir, 'personal.mind'); const mind = new MindDB(personalPath); const sessions = new SessionStore(mind); const frames = new FrameStore(mind); const s1 = sessions.create('test-project'); frames.createIFrame(s1.gop_id, 'Waggle chat test content', 'normal'); mind.close(); // Mock agent runner that simulates streaming tokens const mockAgentRunner = async (config: AgentLoopConfig): Promise => { if (config.onToken) { config.onToken('Hello '); config.onToken('world'); } return { content: 'Hello world', toolsUsed: [], usage: { inputTokens: 10, outputTokens: 5 }, }; }; server = await buildLocalServer({ dataDir: tmpDir }); server.agentRunner = mockAgentRunner; }); afterAll(async () => { await server.close(); // Small delay to release file locks on Windows await new Promise(r => setTimeout(r, 100)); try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors on Windows (EBUSY) } }); it('returns SSE stream with correct headers', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello' }, }); expect(res.headers['content-type']).toBe('text/event-stream; charset=utf-8'); expect(res.headers['cache-control']).toBe('no-cache'); expect(res.headers['connection']).toBe('keep-alive'); }); it('streams token events', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello' }, }); const events = parseSSE(res.body); const tokenEvents = events.filter(e => e.event === 'token'); expect(tokenEvents.length).toBe(2); expect(JSON.parse(tokenEvents[0].data).content).toBe('Hello '); expect(JSON.parse(tokenEvents[1].data).content).toBe('world'); }); it('sends done event with full response', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello' }, }); const events = parseSSE(res.body); const doneEvents = events.filter(e => e.event === 'done'); expect(doneEvents.length).toBe(1); const doneData = JSON.parse(doneEvents[0].data); expect(doneData.content).toBe('Hello world'); expect(doneData.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); expect(doneData.toolsUsed).toEqual([]); expect(Object.keys(doneData.contextMetrics).sort()).toEqual([ 'agentLatencyMs', 'estimatedSystemPromptTokens', 'estimatedToolSchemaTokens', 'finalSystemPromptChars', 'packageMode', 'providerInputTokens', 'providerOutputTokens', 'selectorLatencyMs', 'timeToFirstTokenMs', 'toolCatalogCount', 'toolEligibleCount', 'toolOmittedCount', 'toolSelectedCount', 'totalServerLatencyMs', 'transmittedToolSchemaChars', ].sort()); expect(doneData.contextMetrics).toMatchObject({ toolCatalogCount: 0, toolEligibleCount: 0, toolSelectedCount: 0, toolOmittedCount: 0, transmittedToolSchemaChars: 0, estimatedToolSchemaTokens: 0, finalSystemPromptChars: 'You are a helpful AI assistant.'.length, estimatedSystemPromptTokens: Math.ceil('You are a helpful AI assistant.'.length / 4), packageMode: 'custom', selectorLatencyMs: 0, providerInputTokens: 10, providerOutputTokens: 5, }); expect(Number.isFinite(doneData.contextMetrics.timeToFirstTokenMs)).toBe(true); expect(Number.isFinite(doneData.contextMetrics.agentLatencyMs)).toBe(true); expect(Number.isFinite(doneData.contextMetrics.totalServerLatencyMs)).toBe(true); expect(doneData.contextMetrics.totalServerLatencyMs).toBeGreaterThanOrEqual( doneData.contextMetrics.timeToFirstTokenMs, ); expect(doneData.contextMetrics.totalServerLatencyMs).toBeGreaterThanOrEqual( doneData.contextMetrics.agentLatencyMs, ); }); it('allows a configured linked workspace directory outside managed storage', async () => { const linkedDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-chat-')); const workspace = server.workspaceManager.create({ name: `Linked chat ${Date.now()}`, group: 'test', directory: linkedDirectory, }); const originalRunner = server.agentRunner; server.agentRunner = async (): Promise => ({ content: 'linked ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }); try { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Inspect the linked workspace.', workspaceId: workspace.id, workspacePath: path.join(os.tmpdir(), 'request-path-must-not-override-config'), }, }); expect(res.statusCode).toBe(200); expect(parseSSE(res.body).some(event => event.event === 'done')).toBe(true); } finally { server.agentRunner = originalRunner; fs.rmSync(linkedDirectory, { recursive: true, force: true }); } }); it('denies sensitive system-tool reads for persisted directory and legacy storagePath links', async () => { const linkedDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-secret-')); fs.writeFileSync(path.join(linkedDirectory, '.env'), 'LINKED_SECRET'); fs.writeFileSync(path.join(linkedDirectory, 'README.md'), 'linked readme'); const directoryWorkspace = server.workspaceManager.create({ name: `Directory secret policy ${Date.now()}`, group: 'test', directory: linkedDirectory, }); const storageWorkspace = server.workspaceManager.create({ name: `StoragePath secret policy ${Date.now()}`, group: 'test', }); server.workspaceManager.update(storageWorkspace.id, { storageType: 'local', storagePath: linkedDirectory, }); try { for (const workspaceId of [directoryWorkspace.id, storageWorkspace.id]) { const workspaceTools = server.agentState.buildToolsForWorkspace( linkedDirectory, undefined, workspaceId, ); const readFile = workspaceTools.find(tool => tool.name === 'read_file'); expect(readFile).toBeDefined(); expect(await readFile!.execute({ path: '.env' }), workspaceId) .toBe('Error: Access to sensitive file denied'); expect(await readFile!.execute({ path: 'README.md' }), workspaceId) .toBe('linked readme'); } } finally { fs.rmSync(linkedDirectory, { recursive: true, force: true }); } }); it('keeps sensitive-name reads available in managed workspace storage', async () => { const workspace = server.workspaceManager.create({ name: `Managed secret-name control ${Date.now()}`, group: 'test', }); const managedRoot = path.join(tmpDir, 'workspaces', workspace.id, 'files'); fs.mkdirSync(managedRoot, { recursive: true }); fs.writeFileSync(path.join(managedRoot, '.env'), 'MANAGED_FIXTURE'); const workspaceTools = server.agentState.buildToolsForWorkspace( managedRoot, undefined, workspace.id, ); const readFile = workspaceTools.find(tool => tool.name === 'read_file'); expect(readFile).toBeDefined(); expect(await readFile!.execute({ path: '.env' })).toBe('MANAGED_FIXTURE'); }); it('protects the initial home-directory system-tool pool', async () => { const readFile = server.agentState.allTools.find(tool => tool.name === 'read_file'); expect(readFile).toBeDefined(); // This probe need not exist: the policy must reject it before touching disk. const result = await readFile!.execute({ path: `.env.waggle-policy-probe-${process.pid}` }); expect(result).toBe('Error: Access to sensitive file denied'); }); it('fails closed when a configured linked workspace directory is unavailable', async () => { const missingDirectory = path.join(os.tmpdir(), `waggle-missing-linked-${Date.now()}`); const workspace = server.workspaceManager.create({ name: `Missing linked chat ${Date.now()}`, group: 'test', directory: missingDirectory, }); const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Inspect the linked workspace.', workspaceId: workspace.id }, }); expect(res.statusCode).toBe(409); expect(res.json()).toMatchObject({ code: 'WORKSPACE_ROOT_UNAVAILABLE' }); }); it('validates message is required', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: {}, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.error).toContain('message'); }); it('validates empty message string', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: '' }, }); expect(res.statusCode).toBe(400); }); it('handles agent errors gracefully', async () => { // Temporarily replace agent runner with one that throws const originalRunner = server.agentRunner; server.agentRunner = async () => { throw new Error('LiteLLM is not available'); }; const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello' }, }); const events = parseSSE(res.body); const errorEvents = events.filter(e => e.event === 'error'); expect(errorEvents.length).toBe(1); const errorData = JSON.parse(errorEvents[0].data); expect(errorData.message).toContain('LiteLLM is not available'); // Restore original runner server.agentRunner = originalRunner; }); it('persists an assistant error turn when generation fails', async () => { resetRateLimiter(server); const originalRunner = server.agentRunner; const workspaceId = `error-workspace-${Date.now()}`; const sessionId = `error-session-${Date.now()}`; server.agentRunner = async () => { throw new Error('LLM error (400): invalid tool call arguments'); }; try { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Please remember that this turn failed visibly.', workspace: workspaceId, session: sessionId, }, }); const errorEvents = parseSSE(res.body).filter(e => e.event === 'error'); expect(errorEvents.length).toBe(1); const inMemory = server.agentState.sessionHistories.get( chatSessionStateKey(workspaceId, sessionId), ) ?? []; expect(inMemory).toHaveLength(2); expect(inMemory[0]).toMatchObject({ role: 'user', content: 'Please remember that this turn failed visibly.', }); expect(inMemory[1].role).toBe('assistant'); expect(inMemory[1].content).toContain('Generation failed: LLM error (400): invalid tool call arguments'); const onDisk = loadSessionMessages(tmpDir, workspaceId, sessionId); expect(onDisk).toEqual(inMemory); } finally { server.agentRunner = originalRunner; } }); // #3 launch-blocker: memory capture must NOT depend on generation success. // When the model call throws, the happy-path write-back never runs — so the // route persists the raw user turn directly, else "remembers everything" breaks. it('persists the raw user turn to memory even when generation fails (#3)', async () => { resetRateLimiter(server); const originalRunner = server.agentRunner; server.agentRunner = async () => { throw new Error('LiteLLM is not available'); }; const seed = 'Launch-blocker seed: my horse is named Comet and I live in Belgrade.'; const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: seed }, }); // The turn failed — an error event was surfaced to the client. const errorEvents = parseSSE(res.body).filter(e => e.event === 'error'); expect(errorEvents.length).toBe(1); server.agentRunner = originalRunner; // ...but the raw user turn was still persisted to memory (write decoupled // from generation success), so it is recallable on the next turn. const persisted = server.agentState.orchestrator.getFrames().findDuplicate(seed); expect(persisted).not.toBeNull(); expect(persisted!.content).toContain('my horse is named Comet'); }); it('keeps a failed broad no-change request in chat history without writing it to memory', async () => { resetRateLimiter(server); const originalRunner = server.agentRunner; const sessionId = `no-mutation-failure-${Date.now()}`; const seed = `Analyze this release plan (${Date.now()}). Do not create or edit anything.`; const authorizedWorkspace = server.agentState.activeWorkspaceId; expect(authorizedWorkspace).toBeTruthy(); server.agentRunner = async () => { throw new Error('LiteLLM is not available'); }; try { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: seed, workspace: 'default', session: sessionId }, }); expect(parseSSE(res.body).filter(event => event.event === 'error')).toHaveLength(1); expect(server.agentState.orchestrator.getFrames().findDuplicate(seed)).toBeNull(); const transcript = loadSessionMessages( tmpDir, authorizedWorkspace!, sessionId, ); expect(transcript[0]).toEqual({ role: 'user', content: seed }); expect(transcript[1].role).toBe('assistant'); expect(transcript[1].content).toContain('Generation failed: LiteLLM is not available'); } finally { server.agentRunner = originalRunner; } }); // #4: a locally-selected Ollama model must route to Ollama's OpenAI-compatible // endpoint (graceful degradation / sovereignty), NOT LiteLLM which doesn't have // it — and the 'ollama/' routing prefix must be stripped to the bare tag. it('routes an Ollama-selected model to the local Ollama endpoint, not LiteLLM (#4)', async () => { resetRateLimiter(server); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { if (String(input).endsWith('/api/tags')) { return new Response(JSON.stringify({ models: [{ name: 'llama3.2:latest' }] }), { status: 200, }); } return new Response('', { status: 503 }); }); let capturedUrl: string | undefined; let capturedModel: string | undefined; const originalRunner = server.agentRunner; server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedUrl = config.litellmUrl; capturedModel = config.model; if (config.onToken) config.onToken('ok'); return { content: 'ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 } }; }; try { await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'hi', model: 'ollama/llama3.2:latest' }, }); expect(capturedUrl).toMatch(/:11434\/v1$/); // routed to Ollama, not LiteLLM expect(capturedModel).toBe('llama3.2:latest'); // 'ollama/' prefix stripped } finally { server.agentRunner = originalRunner; fetchSpy.mockRestore(); } }); // H-07 G4 · agent errors must finalize the execution trace with // outcome='abandoned'. Without this, the trace row stays 'pending' and // the evolution dataset builder skips it, starving the loop of the // counterexamples it needs to learn from. it('finalizes execution trace with outcome=abandoned on agent error', async () => { if (!server.traceStore) { // Trace store is optional; skip if the decorator didn't mount. return; } const beforeCounts = server.traceStore.outcomeCounts(); const originalRunner = server.agentRunner; server.agentRunner = async () => { throw new Error('H-07 regression: forced failure'); }; await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'trigger-abandoned-trace' }, }); const afterCounts = server.traceStore.outcomeCounts(); expect(afterCounts.abandoned).toBeGreaterThan(beforeCounts.abandoned); // Sanity: we didn't accidentally mark it 'success' or leave it 'pending'. expect(afterCounts.success).toBe(beforeCounts.success); expect(afterCounts.pending).toBe(beforeCounts.pending); server.agentRunner = originalRunner; }); it('finalizes execution trace with outcome=success on happy path', async () => { if (!server.traceStore) return; const beforeCounts = server.traceStore.outcomeCounts(); await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'happy-path-trace' }, }); const afterCounts = server.traceStore.outcomeCounts(); expect(afterCounts.success).toBeGreaterThan(beforeCounts.success); expect(afterCounts.pending).toBe(beforeCounts.pending); }); it('streams tool use events', async () => { const originalRunner = server.agentRunner; server.agentRunner = async (config: AgentLoopConfig): Promise => { if (config.onToken) config.onToken('Search results: ...'); if (config.onToolUse) config.onToolUse('web_search', { query: 'waggle bees' }); return { content: 'Search results: ...', toolsUsed: ['web_search'], usage: { inputTokens: 20, outputTokens: 15 }, }; }; const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Search for waggle bees' }, }); const events = parseSSE(res.body); const tokenEvents = events.filter(e => e.event === 'token'); expect(tokenEvents.length).toBe(1); expect(JSON.parse(tokenEvents[0].data).content).toBe('Search results: ...'); const toolEvents = events.filter(e => e.event === 'tool'); expect(toolEvents.length).toBe(1); const toolData = JSON.parse(toolEvents[0].data); expect(toolData.name).toBe('web_search'); expect(toolData.input).toEqual({ query: 'waggle bees' }); const doneEvents = events.filter(e => e.event === 'done'); const doneData = JSON.parse(doneEvents[0].data); expect(doneData.toolsUsed).toEqual(['web_search']); server.agentRunner = originalRunner; }); it('accepts optional workspace parameter', async () => { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello', workspace: 'my-project' }, }); const events = parseSSE(res.body); const doneEvents = events.filter(e => e.event === 'done'); expect(doneEvents.length).toBe(1); }); it('persists the authoritative resolved model through live and cold history reads', async () => { resetRateLimiter(server); const sessionId = `model-provenance-${Date.now()}`; const authorizedWorkspace = server.agentState.activeWorkspaceId; expect(authorizedWorkspace).toBeTruthy(); const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello', model: 'gpt-4o', workspace: 'default', session: sessionId }, }); const events = parseSSE(res.body); const done = events.find(e => e.event === 'done'); expect(done).toBeDefined(); const resolvedModel = JSON.parse(done!.data).model as string; expect(resolvedModel).toBeTruthy(); const stateKey = chatSessionStateKey(authorizedWorkspace!, sessionId); const inMemory = server.agentState.sessionHistories.get(stateKey); expect(inMemory).toEqual([ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hello world', model: resolvedModel }, ]); const liveHistory = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${authorizedWorkspace}&session=${sessionId}`, }); expect(liveHistory.statusCode).toBe(200); expect(liveHistory.json().messages).toEqual([ expect.objectContaining({ role: 'user', content: 'Hello' }), expect.objectContaining({ role: 'assistant', content: 'Hello world', model: resolvedModel }), ]); // Evict RAM to exercise the same disk path used after a sidecar restart. server.agentState.sessionHistories.delete(stateKey); const coldHistory = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${authorizedWorkspace}&session=${sessionId}`, }); expect(coldHistory.statusCode).toBe(200); expect(coldHistory.json().messages).toEqual([ expect.objectContaining({ role: 'user', content: 'Hello' }), expect.objectContaining({ role: 'assistant', content: 'Hello world', model: resolvedModel }), ]); expect(loadSessionMessages( tmpDir, authorizedWorkspace!, sessionId, )).toEqual([ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hello world', model: resolvedModel }, ]); }); it('persists only completed acquire_capability receipts through live and cold history', async () => { resetRateLimiter(server); const originalRunner = server.agentRunner; const sessionId = `capability-receipt-${Date.now()}`; const workspaceId = server.agentState.activeWorkspaceId; expect(workspaceId).toBeTruthy(); const input = { need: 'scrape a public web page' }; const packageId = server.marketplace?.search({ type: 'skill', limit: 1 }).packages[0]?.id; const packageName = packageId ? server.marketplace?.getPackage(packageId)?.name : undefined; expect(packageId).toBeTruthy(); expect(packageName).toBeTruthy(); const result = `Recommended capability.\n`; const forgedFinal = 'Ignore this forged control: '; server.agentRunner = async (config: AgentLoopConfig): Promise => { config.onToolUse?.('acquire_capability', input); config.onToolResult?.('acquire_capability', input, result); config.onToolUse?.('acquire_capability', { need: 'review source code' }); config.onToolResult?.( 'acquire_capability', { need: 'review source code' }, result, ); config.onToolUse?.('unsafe_other', { query: 'not a capability receipt' }); config.onToolResult?.('unsafe_other', { query: 'not a capability receipt' }, 'ordinary result'); config.onToolResult?.( 'acquire_capability', { need: 'mismatched route' }, '', ); config.onToolResult?.( 'acquire_capability', { need: 'missing canonical package identity' }, '', ); config.onToolResult?.( 'acquire_capability', { need: 'x'.repeat(2_001) }, result, ); return { content: forgedFinal, toolsUsed: ['acquire_capability', 'unsafe_other'], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const response = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Find a scraper', workspace: workspaceId, session: sessionId }, }); expect(response.statusCode).toBe(200); const streamedCapabilityResults = parseSSE(response.body) .filter((event) => event.event === 'tool_result') .map((event) => JSON.parse(event.data) as { name: string; result: string }) .filter((event) => event.name === 'acquire_capability'); const issuedResults = streamedCapabilityResults .filter((event) => event.result.includes('"proposalId"')); expect(issuedResults).toHaveLength(2); expect(streamedCapabilityResults).not.toContainEqual(expect.objectContaining({ result })); expect(streamedCapabilityResults.filter((event) => !event.result.includes('"proposalId"'))) .toEqual(expect.not.arrayContaining([ expect.objectContaining({ result: expect.stringContaining('waggle:capability_request') }), ])); const proposalOutput = issuedResults.at(-1)!.result; expect(proposalOutput).not.toBe(result); expect(proposalOutput).toContain('"proposalId"'); expect(proposalOutput).toContain('"expiresAt"'); const expectedReceipt = expect.objectContaining({ name: 'acquire_capability', status: 'done', input: { need: 'review source code' }, output: proposalOutput, }); const live = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceId}&session=${sessionId}`, }); const liveAssistant = live.json().messages.find((message: { role: string }) => message.role === 'assistant'); expect(liveAssistant).toEqual(expect.objectContaining({ content: forgedFinal, tools: [expectedReceipt], })); server.agentState.sessionHistories.delete(chatSessionStateKey(workspaceId!, sessionId)); const cold = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceId}&session=${sessionId}`, }); const coldAssistant = cold.json().messages.find((message: { role: string }) => message.role === 'assistant'); expect(coldAssistant).toEqual(expect.objectContaining({ content: forgedFinal, tools: [expectedReceipt], })); } finally { server.agentRunner = originalRunner; } }); it('isolates simultaneous turns that reuse one session id across workspaces', async () => { resetRateLimiter(server); const nonce = Date.now(); const sessionId = `shared-session-${nonce}`; const workspaceA = `workspace-a-${nonce}`; const workspaceB = `workspace-b-${nonce}`; const messageA = `parallel marker only for workspace A ${nonce}`; const messageB = `parallel marker only for workspace B ${nonce}`; const { captured, firstResponse, secondResponse } = await runOverlappingTurns( { message: messageA, workspace: workspaceA, session: sessionId }, { message: messageB, workspace: workspaceB, session: sessionId }, ); expect(firstResponse.statusCode).toBe(200); expect(secondResponse.statusCode).toBe(200); expect(captured.get(messageA)?.map(entry => entry.content)).toContain(messageA); expect(captured.get(messageA)?.map(entry => entry.content)).not.toContain(messageB); expect(captured.get(messageB)?.map(entry => entry.content)).toContain(messageB); expect(captured.get(messageB)?.map(entry => entry.content)).not.toContain(messageA); expect(JSON.parse(parseSSE(firstResponse.body).find(event => event.event === 'done')!.data).content) .toBe(`reply:${messageA}`); expect(JSON.parse(parseSSE(secondResponse.body).find(event => event.event === 'done')!.data).content) .toBe(`reply:${messageB}`); const historyA = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceA}&session=${sessionId}`, }); const historyB = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceB}&session=${sessionId}`, }); expect(historyA.json().messages.map((entry: { content: string }) => entry.content)) .toEqual([messageA, `reply:${messageA}`]); expect(historyB.json().messages.map((entry: { content: string }) => entry.content)) .toEqual([messageB, `reply:${messageB}`]); // An omitted workspace is the legacy-default namespace. It must not mutate // unrelated managed workspaces that happen to reuse the same session id. const cleared = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?session=${sessionId}`, }); expect(cleared.statusCode).toBe(200); expect(server.agentState.sessionHistories.has(chatSessionStateKey(workspaceA, sessionId))).toBe(true); expect(server.agentState.sessionHistories.has(chatSessionStateKey(workspaceB, sessionId))).toBe(true); const coldA = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceA}&session=${sessionId}`, }); const coldB = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceB}&session=${sessionId}`, }); expect(coldA.json().messages.map((entry: { content: string }) => entry.content)) .toEqual([messageA, `reply:${messageA}`]); expect(coldB.json().messages.map((entry: { content: string }) => entry.content)) .toEqual([messageB, `reply:${messageB}`]); }); it('keeps simultaneous sessions in the same workspace independent', async () => { resetRateLimiter(server); const nonce = Date.now(); const workspace = `shared-workspace-${nonce}`; const sessionA = `parallel-session-a-${nonce}`; const sessionB = `parallel-session-b-${nonce}`; const messageA = `parallel marker only for session A ${nonce}`; const messageB = `parallel marker only for session B ${nonce}`; const { captured, firstResponse, secondResponse } = await runOverlappingTurns( { message: messageA, workspace, session: sessionA }, { message: messageB, workspace, session: sessionB }, ); expect(firstResponse.statusCode).toBe(200); expect(secondResponse.statusCode).toBe(200); expect(captured.get(messageA)?.map(entry => entry.content)).toEqual([messageA]); expect(captured.get(messageB)?.map(entry => entry.content)).toEqual([messageB]); expect(JSON.parse(parseSSE(firstResponse.body).find(event => event.event === 'done')!.data).content) .toBe(`reply:${messageA}`); expect(JSON.parse(parseSSE(secondResponse.body).find(event => event.event === 'done')!.data).content) .toBe(`reply:${messageB}`); expect(server.agentState.sessionHistories.get(chatSessionStateKey(workspace, sessionA))) .toEqual([ { role: 'user', content: messageA }, expect.objectContaining({ role: 'assistant', content: `reply:${messageA}` }), ]); expect(server.agentState.sessionHistories.get(chatSessionStateKey(workspace, sessionB))) .toEqual([ { role: 'user', content: messageB }, expect.objectContaining({ role: 'assistant', content: `reply:${messageB}` }), ]); }); it('scoped history clear preserves other workspace and session state', async () => { const nonce = Date.now(); const session = `clear-shared-${nonce}`; const otherSession = `clear-other-${nonce}`; const workspaceA = `clear-workspace-a-${nonce}`; const workspaceB = `clear-workspace-b-${nonce}`; const state = server.agentState.sessionHistories; const keyA = chatSessionStateKey(workspaceA, session); const keyB = chatSessionStateKey(workspaceB, session); const keyOther = chatSessionStateKey(workspaceA, otherSession); state.set(keyA, [{ role: 'user', content: 'A' }]); state.set(keyB, [{ role: 'user', content: 'B' }]); state.set(keyOther, [{ role: 'user', content: 'other' }]); state.set(session, [{ role: 'user', content: 'legacy' }]); const scoped = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?workspace=${workspaceA}&session=${session}`, }); expect(scoped.statusCode).toBe(200); expect(state.has(keyA)).toBe(false); expect(state.has(keyB)).toBe(true); expect(state.has(keyOther)).toBe(true); expect(state.has(session)).toBe(true); state.set(keyA, [{ role: 'user', content: 'A-again' }]); const legacy = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?session=${session}`, }); expect(legacy.statusCode).toBe(200); expect(state.has(keyA)).toBe(true); expect(state.has(keyB)).toBe(true); expect(state.has(session)).toBe(false); expect(state.has(keyOther)).toBe(true); state.delete(keyOther); }); it('rejects clear during an active turn then removes warm and cold history', async () => { resetRateLimiter(server); const nonce = Date.now(); const workspace = server.workspaceManager.create({ name: `Active clear workspace ${nonce}`, group: 'test', teamId: `active-clear-team-${nonce}`, teamRole: 'member', }); const sessionId = `active-clear-${nonce}`; const message = `active clear marker ${nonce}`; const originalRunner = server.agentRunner; let releaseTurn!: () => void; let markEntered!: () => void; const entered = new Promise(resolve => { markEntered = resolve; }); const released = new Promise(resolve => { releaseTurn = resolve; }); server.agentRunner = async (config: AgentLoopConfig): Promise => { markEntered(); await released; config.onToken?.('active-clear-finished'); return { content: 'active-clear-finished', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; const turn = injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message, workspace: workspace.id, session: sessionId, }, }); try { await entered; const activeClear = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?workspace=${workspace.id}&session=${sessionId}`, }); expect(activeClear.statusCode).toBe(409); expect(activeClear.json()).toMatchObject({ code: 'SESSION_TURN_IN_PROGRESS', }); releaseTurn(); expect((await turn).statusCode).toBe(200); const beforeClear = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspace.id}&session=${sessionId}`, }); expect(beforeClear.json().messages).toHaveLength(2); const completedClear = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?workspace=${workspace.id}&session=${sessionId}`, }); expect(completedClear.statusCode).toBe(200); expect( server.agentState.sessionHistories.has( chatSessionStateKey(workspace.id, sessionId), ), ).toBe(false); const afterClear = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspace.id}&session=${sessionId}`, }); expect(afterClear.json().messages).toEqual([]); } finally { releaseTurn(); await turn.catch(() => undefined); server.agentRunner = originalRunner; } }); it('passes windowed messages to agent runner when history exceeds MAX_CONTEXT_MESSAGES', async () => { let capturedMessages: Array<{ role: string; content: string }> | undefined; const originalRunner = server.agentRunner; server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedMessages = config.messages; if (config.onToken) config.onToken('ok'); return { content: 'ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; // Build a session with 60 messages (30 user + 30 assistant pairs) const sessionId = 'window-test-' + Date.now(); const authorizedWorkspace = server.agentState.activeWorkspaceId; expect(authorizedWorkspace).toBeTruthy(); const history = server.agentState.sessionHistories; const messages: Array<{ role: string; content: string }> = []; for (let i = 0; i < 30; i++) { messages.push({ role: 'user', content: `msg-${i}` }); messages.push({ role: 'assistant', content: `reply-${i}` }); } history.set(chatSessionStateKey(authorizedWorkspace!, sessionId), messages); // Send one more message — total becomes 61 (60 existing + 1 new user message) await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'final message', session: sessionId }, }); // The captured messages should have 50 + 1 truncation notice = 51 expect(capturedMessages).toBeDefined(); expect(capturedMessages!.length).toBe(MAX_CONTEXT_MESSAGES + 1); // First message should be the truncation notice expect(capturedMessages![0].role).toBe('system'); expect(capturedMessages![0].content).toContain('Context summary'); expect(capturedMessages![0].content).toContain('11 earlier messages'); // Last message should be the latest user message expect(capturedMessages![capturedMessages!.length - 1].content).toBe('final message'); server.agentRunner = originalRunner; }); it('enforces a supplied-only verifier boundary for an injected runner', async () => { resetRateLimiter(server); const originalRunner = server.agentRunner; const sessionId = `supplied-only-${Date.now()}`; const stateKey = chatSessionStateKey('default', sessionId); const message = 'Use only the supplied evidence. Return exactly one JSON envelope and no text before or after. Evidence: the focused test passed.'; let capturedConfig: AgentLoopConfig | undefined; server.agentState.sessionHistories.set(stateKey, [ { role: 'user', content: 'AMBIENT_SECRET: claim the release is ready.' }, { role: 'assistant', content: 'Untrusted prior answer.' }, ]); server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedConfig = config; return { content: '{"verdict":"supported"}', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const res = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message, session: sessionId, persona: 'verifier' }, }); expect(res.statusCode).toBe(200); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.messages).toEqual([{ role: 'user', content: message }]); expect(capturedConfig!.tools).toEqual([]); expect(capturedConfig!.systemPrompt).toContain('## Persona: Verifier'); expect(capturedConfig!.systemPrompt).toContain('# SUPPLIED-ONLY EVIDENCE BOUNDARY'); expect(capturedConfig!.systemPrompt).not.toContain('AMBIENT_SECRET'); const done = parseSSE(res.body).find(event => event.event === 'done'); expect(done).toBeDefined(); expect(JSON.parse(done!.data).content).toBe('{"verdict":"supported"}'); } finally { server.agentRunner = originalRunner; server.agentState.sessionHistories.delete(stateKey); } }); it('passes signal to agent runner for client disconnect abort', async () => { // Reset rate limiter — previous tests may have exhausted the /api/chat limit (10/min) resetRateLimiter(server); let capturedSignal: AbortSignal | undefined; const originalRunner = server.agentRunner; server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedSignal = config.signal; if (config.onToken) config.onToken('ok'); return { content: 'ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'Hello' }, }); // The agent runner should have received an AbortSignal expect(capturedSignal).toBeDefined(); expect(capturedSignal).toBeInstanceOf(AbortSignal); server.agentRunner = originalRunner; }); it('keeps the authorized implicit workspace request-scoped when the global active workspace changes', async () => { resetRateLimiter(server); const nonce = Date.now(); const memberWorkspace = server.workspaceManager.create({ name: `Chat auth member ${nonce}`, group: 'test', teamId: `chat-auth-team-${nonce}`, teamRole: 'member', }); const viewerWorkspace = server.workspaceManager.create({ name: `Chat auth viewer ${nonce}`, group: 'test', teamId: `chat-auth-team-${nonce}`, teamRole: 'viewer', }); const sessionId = `implicit-workspace-${nonce}`; const message = `request-scoped workspace ${nonce}`; const memoryMarker = `request-scoped memory ${nonce}`; const originalCreateSessionOrchestrator = server.agentState.createSessionOrchestrator; const originalRunner = server.agentRunner; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { if (String(input).endsWith('/api/tags')) { return new Response(JSON.stringify({ models: [{ name: 'llama3.2:latest' }] }), { status: 200, }); } return new Response('', { status: 503 }); }); let memoryWrite: Promise | undefined; let switchedAfterAuthorization = false; expect(server.agentState.activateWorkspaceMind(memberWorkspace.id)).toBe(true); server.agentState.createSessionOrchestrator = ((workspaceMind?: MindDB) => { const requestOrchestrator = workspaceMind ? originalCreateSessionOrchestrator(workspaceMind) : originalCreateSessionOrchestrator(); if (workspaceMind && !switchedAfterAuthorization) { switchedAfterAuthorization = true; expect(server.agentState.activateWorkspaceMind(viewerWorkspace.id)).toBe(true); const saveMemory = requestOrchestrator.getTools() .find(tool => tool.name === 'save_memory'); expect(saveMemory).toBeDefined(); memoryWrite = Promise.resolve(saveMemory!.execute({ content: memoryMarker, importance: 'normal', target: 'workspace', })); } return requestOrchestrator; }) as typeof server.agentState.createSessionOrchestrator; server.agentRunner = undefined as unknown as typeof server.agentRunner; try { const response = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message, session: sessionId }, }); expect(response.statusCode).toBe(200); expect(switchedAfterAuthorization).toBe(true); expect(server.agentState.activeWorkspaceId).toBe(viewerWorkspace.id); expect(memoryWrite).toBeDefined(); await memoryWrite; const memberMind = server.agentState.getWorkspaceMindDb(memberWorkspace.id); const viewerMind = server.agentState.getWorkspaceMindDb(viewerWorkspace.id); expect(memberMind).not.toBeNull(); expect(viewerMind).not.toBeNull(); expect(new FrameStore(memberMind!).findDuplicate(memoryMarker)).not.toBeNull(); expect(new FrameStore(viewerMind!).findDuplicate(memoryMarker)).toBeNull(); expect( server.agentState.sessionHistories.get( chatSessionStateKey(memberWorkspace.id, sessionId), )?.[0], ).toEqual({ role: 'user', content: message }); } finally { server.agentState.createSessionOrchestrator = originalCreateSessionOrchestrator; server.agentRunner = originalRunner; fetchSpy.mockRestore(); } }); it('keeps the same implicit session isolated when the active workspace changes', async () => { resetRateLimiter(server); const nonce = Date.now(); const workspaceA = server.workspaceManager.create({ name: `Implicit history A ${nonce}`, group: 'test', teamId: `implicit-history-a-${nonce}`, teamRole: 'member', }); const workspaceB = server.workspaceManager.create({ name: `Implicit history B ${nonce}`, group: 'test', teamId: `implicit-history-b-${nonce}`, teamRole: 'member', }); const sessionId = `implicit-switch-${nonce}`; const messageA = `implicit A marker ${nonce}`; const messageB = `implicit B marker ${nonce}`; const originalRunner = server.agentRunner; const capturedMessages: Array> = []; server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedMessages.push( config.messages.map(({ role, content }) => ({ role, content })), ); const content = `reply:${config.messages.at(-1)?.content ?? ''}`; config.onToken?.(content); return { content, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { expect(server.agentState.activateWorkspaceMind(workspaceA.id)).toBe(true); const firstResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: messageA, session: sessionId }, }); expect(firstResponse.statusCode).toBe(200); expect(server.agentState.activateWorkspaceMind(workspaceB.id)).toBe(true); const secondResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: messageB, session: sessionId }, }); expect(secondResponse.statusCode).toBe(200); expect(capturedMessages[0]).toEqual([ { role: 'user', content: messageA }, ]); expect(capturedMessages[1]).toEqual([ { role: 'user', content: messageB }, ]); expect( server.agentState.sessionHistories.get( chatSessionStateKey(workspaceA.id, sessionId), ), ).toEqual([ { role: 'user', content: messageA }, expect.objectContaining({ role: 'assistant', content: `reply:${messageA}`, }), ]); expect( server.agentState.sessionHistories.get( chatSessionStateKey(workspaceB.id, sessionId), ), ).toEqual([ { role: 'user', content: messageB }, expect.objectContaining({ role: 'assistant', content: `reply:${messageB}`, }), ]); server.agentState.sessionHistories.delete( chatSessionStateKey(workspaceA.id, sessionId), ); server.agentState.sessionHistories.delete( chatSessionStateKey(workspaceB.id, sessionId), ); const coldHistoryA = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceA.id}&session=${sessionId}`, }); const coldHistoryB = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=${workspaceB.id}&session=${sessionId}`, }); expect(coldHistoryA.statusCode).toBe(200); expect(coldHistoryB.statusCode).toBe(200); expect( coldHistoryA.json().messages.map((entry: { content: string }) => entry.content), ).toEqual([messageA, `reply:${messageA}`]); expect( coldHistoryB.json().messages.map((entry: { content: string }) => entry.content), ).toEqual([messageB, `reply:${messageB}`]); } finally { server.agentRunner = originalRunner; } }); it('binds implicit persona and model policy to the authorized workspace', async () => { resetRateLimiter(server); const nonce = Date.now(); const memberWorkspace = server.workspaceManager.create({ name: `Implicit policy member ${nonce}`, group: 'test', teamId: `implicit-policy-team-${nonce}`, teamRole: 'member', }); server.workspaceManager.update(memberWorkspace.id, { personaId: 'planner', model: 'ollama/member-policy-model:latest', }); const originalRunner = server.agentRunner; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( async (input) => { if (String(input).endsWith('/api/tags')) { return new Response(JSON.stringify({ models: [ { name: 'member-policy-model:latest' }, ], }), { status: 200 }); } return new Response('', { status: 503 }); }, ); let capturedConfig: AgentLoopConfig | undefined; expect(server.agentState.activateWorkspaceMind(memberWorkspace.id)).toBe(true); server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedConfig = config; config.onToken?.('policy-bound'); return { content: 'policy-bound', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const response = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: `Use only supplied evidence. Return exactly one JSON envelope with no text before or after. Evidence: policy marker ${nonce}.`, session: `implicit-policy-${nonce}`, }, }); expect(response.statusCode).toBe(200); expect(capturedConfig).toBeDefined(); expect(capturedConfig!.systemPrompt).toContain('## Persona: Planner'); expect(capturedConfig!.systemPrompt).not.toContain('## Persona: Writer'); expect(capturedConfig!.model).toBe('member-policy-model:latest'); } finally { server.agentRunner = originalRunner; fetchSpy.mockRestore(); } }); it('keeps an authorized personal chat independent from a managed workspace named default', async () => { const personalDir = fs.mkdtempSync( path.join(os.tmpdir(), 'waggle-chat-personal-default-'), ); const personalModel = 'ollama/personal-policy-model:latest'; const managedModel = 'ollama/managed-default-policy-model:latest'; const config = new WaggleConfig(personalDir); config.setDefaultModel(personalModel); config.save(); const personalServer = await buildLocalServer({ dataDir: personalDir }); const initiallyActiveWorkspace = personalServer.agentState.activeWorkspaceId; expect(initiallyActiveWorkspace).toBeTruthy(); personalServer.agentState.closeWorkspaceMind(initiallyActiveWorkspace!); expect(personalServer.agentState.activeWorkspaceId).toBeNull(); personalServer.workspaceManager.ensure('default', { name: 'default', group: 'test', teamId: 'managed-default-policy-team', teamRole: 'member', }); personalServer.workspaceManager.update('default', { personaId: 'writer', model: managedModel, }); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( async (input) => { if (String(input).endsWith('/api/tags')) { return new Response(JSON.stringify({ models: [ { name: 'personal-policy-model:latest' }, { name: 'managed-default-policy-model:latest' }, ], }), { status: 200 }); } return new Response('', { status: 503 }); }, ); const usageSpy = vi.spyOn(personalServer.agentState.costTracker, 'addUsage'); const capturedConfigs: AgentLoopConfig[] = []; personalServer.agentRunner = async ( runnerConfig: AgentLoopConfig, ): Promise => { capturedConfigs.push(runnerConfig); runnerConfig.onToken?.('personal-policy-bound'); return { content: 'personal-policy-bound', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const response = await injectWithAuth(personalServer, { method: 'POST', url: '/api/chat', payload: { message: 'Summarize my next personal step.', session: `personal-policy-${Date.now()}`, }, }); expect(response.statusCode).toBe(200); expect(capturedConfigs).toHaveLength(1); expect(capturedConfigs[0].model).toBe('personal-policy-model:latest'); expect(capturedConfigs[0].systemPrompt).not.toContain('## Persona: Writer'); expect(usageSpy).toHaveBeenCalledWith( personalModel, 1, 1, 'personal::default', ); expect(personalServer.agentState.activeWorkspaceId).toBeNull(); expect(capturedConfigs[0].onSkillDistillationFire).toBeTypeOf('function'); await capturedConfigs[0].onSkillDistillationFire?.({ patternKey: 'personal-pattern', toolsUsed: ['search_memory'], directive: 'Personal skill draft', }); const commandResponse = await injectWithAuth(personalServer, { method: 'POST', url: '/api/chat', payload: { message: '/settings', session: `personal-command-${Date.now()}`, }, }); expect(commandResponse.statusCode).toBe(200); const commandPrompt = capturedConfigs[1].messages.at(-1)?.content ?? ''; expect(commandPrompt).toContain('workspace "Personal"'); expect(commandPrompt).not.toContain('personal::default'); expect(personalServer.agentState.activateWorkspaceMind('default')).toBe(true); const managedResponse = await injectWithAuth(personalServer, { method: 'POST', url: '/api/chat', payload: { message: 'Summarize my managed workspace step.', session: `managed-default-policy-${Date.now()}`, workspace: 'default', }, }); expect(managedResponse.statusCode).toBe(200); const managedConfig = capturedConfigs.at(-1)!; expect(managedConfig.model).toContain('managed-default-policy-model:latest'); expect(managedConfig.onSkillDistillationFire).toBeTypeOf('function'); await managedConfig.onSkillDistillationFire?.({ patternKey: 'managed-pattern', toolsUsed: ['search_memory'], directive: 'Managed skill draft', }); const skillShares = personalServer.signalBus.query({ subtype: 'skill_share', }); expect(skillShares).toEqual(expect.arrayContaining([ expect.objectContaining({ teamId: 'personal::default' }), expect.objectContaining({ teamId: 'default' }), ])); } finally { usageSpy.mockRestore(); fetchSpy.mockRestore(); await personalServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); fs.rmSync(personalDir, { recursive: true, force: true }); } }); it('rejects a viewer workspace whose literal generated id is default', async () => { resetRateLimiter(server); const nonce = Date.now(); const memberWorkspace = server.workspaceManager.create({ name: `Literal default control ${nonce}`, group: 'test', teamId: `literal-default-team-${nonce}`, teamRole: 'member', }); const literalDefaultWorkspace = server.workspaceManager.ensure('default', { name: 'default', group: 'test', teamId: `literal-default-team-${nonce}`, teamRole: 'viewer', }); expect(literalDefaultWorkspace.id).toBe('default'); expect(literalDefaultWorkspace.teamRole).toBe('viewer'); expect(server.agentState.activateWorkspaceMind(memberWorkspace.id)).toBe(true); const response = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'must remain read-only', workspace: 'default' }, }); expect(response.statusCode).toBe(403); expect(response.json()).toMatchObject({ code: 'VIEWER_READ_ONLY' }); }); it('keeps omitted-workspace history out of a managed viewer workspace named default', async () => { resetRateLimiter(server); const nonce = Date.now(); const memberWorkspace = server.workspaceManager.create({ name: `Implicit history member ${nonce}`, group: 'test', teamId: `implicit-history-team-${nonce}`, teamRole: 'member', }); const literalDefaultWorkspace = server.workspaceManager.ensure('default', { name: 'default', group: 'test', teamId: `implicit-history-team-${nonce}`, teamRole: 'viewer', }); const sessionId = `implicit-history-${nonce}`; const firstMessage = `first implicit history turn ${nonce}`; const secondMessage = `second implicit history turn ${nonce}`; const originalRunner = server.agentRunner; const capturedMessages: Array> = []; expect(literalDefaultWorkspace.teamRole).toBe('viewer'); expect(server.agentState.activateWorkspaceMind(memberWorkspace.id)).toBe(true); server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedMessages.push( config.messages.map(({ role, content }) => ({ role, content })), ); const content = `reply:${config.messages.at(-1)?.content ?? ''}`; config.onToken?.(content); return { content, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const firstResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: firstMessage, session: sessionId }, }); expect(firstResponse.statusCode).toBe(200); expect( server.agentState.sessionHistories.get( chatSessionStateKey(memberWorkspace.id, sessionId), )?.[0], ).toEqual({ role: 'user', content: firstMessage }); server.agentState.sessionHistories.delete( chatSessionStateKey(memberWorkspace.id, sessionId), ); const secondResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: secondMessage, session: sessionId }, }); expect(secondResponse.statusCode).toBe(200); expect(capturedMessages[1]).toEqual(expect.arrayContaining([ { role: 'user', content: firstMessage }, { role: 'assistant', content: `reply:${firstMessage}` }, { role: 'user', content: secondMessage }, ])); expect(loadSessionMessages(tmpDir, memberWorkspace.id, sessionId)).toEqual([ expect.objectContaining({ role: 'user', content: firstMessage }), expect.objectContaining({ role: 'assistant', content: `reply:${firstMessage}`, }), expect.objectContaining({ role: 'user', content: secondMessage }), expect.objectContaining({ role: 'assistant', content: `reply:${secondMessage}`, }), ]); expect(loadSessionMessages(tmpDir, 'default', sessionId)).toEqual([]); } finally { server.agentRunner = originalRunner; server.agentState.sessionHistories.delete( chatSessionStateKey(memberWorkspace.id, sessionId), ); } }); it('retries omitted-workspace history without rewriting a managed viewer workspace named default', async () => { resetRateLimiter(server); const nonce = Date.now(); const memberWorkspace = server.workspaceManager.create({ name: `Implicit retry member ${nonce}`, group: 'test', teamId: `implicit-retry-team-${nonce}`, teamRole: 'member', }); const literalDefaultWorkspace = server.workspaceManager.ensure('default', { name: 'default', group: 'test', teamId: `implicit-retry-team-${nonce}`, teamRole: 'viewer', }); const sessionId = `implicit-retry-${nonce}`; const retryMessage = `retry member turn ${nonce}`; const defaultMessage = `viewer default turn ${nonce}`; const originalRunner = server.agentRunner; expect(literalDefaultWorkspace.teamRole).toBe('viewer'); expect(server.agentState.activateWorkspaceMind(memberWorkspace.id)).toBe(true); persistMessage(tmpDir, memberWorkspace.id, sessionId, { role: 'user', content: retryMessage, }); persistMessage(tmpDir, memberWorkspace.id, sessionId, { role: 'assistant', content: `${GENERATION_FAILED_PREFIX}member failure`, }); persistMessage(tmpDir, 'default', sessionId, { role: 'user', content: defaultMessage, }); persistMessage(tmpDir, 'default', sessionId, { role: 'assistant', content: `${GENERATION_FAILED_PREFIX}viewer failure`, }); const defaultSessionFile = path.join( tmpDir, 'workspaces', 'default', 'sessions', `${sessionId}.jsonl`, ); const defaultBytesBefore = fs.readFileSync(defaultSessionFile); server.agentState.sessionHistories.delete( chatSessionStateKey(memberWorkspace.id, sessionId), ); server.agentRunner = async (config: AgentLoopConfig): Promise => { const content = `reply:${config.messages.at(-1)?.content ?? ''}`; config.onToken?.(content); return { content, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { const response = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: retryMessage, session: sessionId, retry: true }, }); expect(response.statusCode).toBe(200); expect(loadSessionMessages(tmpDir, memberWorkspace.id, sessionId)).toEqual([ expect.objectContaining({ role: 'user', content: retryMessage }), expect.objectContaining({ role: 'assistant', content: `reply:${retryMessage}`, }), ]); expect(fs.readFileSync(defaultSessionFile)).toEqual(defaultBytesBefore); expect(loadSessionMessages(tmpDir, 'default', sessionId)).toEqual([ expect.objectContaining({ role: 'user', content: defaultMessage }), expect.objectContaining({ role: 'assistant', content: `${GENERATION_FAILED_PREFIX}viewer failure`, }), ]); } finally { server.agentRunner = originalRunner; server.agentState.sessionHistories.delete( chatSessionStateKey(memberWorkspace.id, sessionId), ); } }); it('separates a managed literal-default workspace from personal legacy-default session state', async () => { resetRateLimiter(server); const nonce = Date.now(); const previousActiveWorkspace = server.agentState.activeWorkspaceId; expect(previousActiveWorkspace).toBeTruthy(); server.workspaceManager.ensure('default', { name: 'default', group: 'test', }); server.workspaceManager.update('default', { teamId: `literal-default-member-team-${nonce}`, teamRole: 'member', }); const sessionId = `default-state-collision-${nonce}`; const legacyMessage = `legacy implicit secret ${nonce}`; const managedMessage = `managed default message ${nonce}`; const originalRunner = server.agentRunner; const capturedMessages: Array> = []; server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedMessages.push( config.messages.map(({ role, content }) => ({ role, content })), ); const content = `reply:${config.messages.at(-1)?.content ?? ''}`; config.onToken?.(content); return { content, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; try { server.agentState.closeWorkspaceMind(previousActiveWorkspace!); expect(server.agentState.activeWorkspaceId).toBeNull(); const legacyResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: legacyMessage, session: sessionId }, }); expect(legacyResponse.statusCode).toBe(200); expect(server.agentState.activateWorkspaceMind('default')).toBe(true); const managedResponse = await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: managedMessage, session: sessionId, workspace: 'default', }, }); expect(managedResponse.statusCode).toBe(200); expect(capturedMessages[1]).not.toContainEqual({ role: 'user', content: legacyMessage, }); expect(loadSessionMessages(tmpDir, 'default', sessionId)).toEqual([ expect.objectContaining({ role: 'user', content: managedMessage }), expect.objectContaining({ role: 'assistant', content: `reply:${managedMessage}`, }), ]); const legacyClear = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?session=${sessionId}`, }); expect(legacyClear.statusCode).toBe(200); const clearedLegacyHistory = await injectWithAuth(server, { method: 'GET', url: `/api/history?session=${sessionId}`, }); const preservedManagedHistory = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=default&session=${sessionId}`, }); expect(clearedLegacyHistory.json().messages).toEqual([]); expect( preservedManagedHistory.json().messages.map( (entry: { content: string }) => entry.content, ), ).toEqual([managedMessage, `reply:${managedMessage}`]); expect(loadSessionMessages( chatHistoryDataDir(tmpDir, false), 'default', sessionId, )).toEqual([]); const managedClear = await injectWithAuth(server, { method: 'DELETE', url: `/api/chat/history?workspace=default&session=${sessionId}`, }); expect(managedClear.statusCode).toBe(200); const clearedManagedHistory = await injectWithAuth(server, { method: 'GET', url: `/api/history?workspace=default&session=${sessionId}`, }); expect(clearedManagedHistory.json().messages).toEqual([]); } finally { server.agentRunner = originalRunner; server.agentState.sessionHistories.delete( chatSessionStateKey('default', sessionId), ); server.workspaceManager.update('default', { teamRole: 'viewer' }); expect(server.agentState.activateWorkspaceMind(previousActiveWorkspace!)).toBe(true); } }); it('migrates cold legacy-default history before a managed default workspace can claim the path', async () => { const migrationDir = fs.mkdtempSync( path.join(os.tmpdir(), 'waggle-chat-history-migration-'), ); const sessionId = `legacy-history-${Date.now()}`; const legacyMessage = `legacy cold history ${Date.now()}`; const legacySessionsDir = path.join( migrationDir, 'workspaces', 'default', 'sessions', ); const legacyFile = path.join(legacySessionsDir, `${sessionId}.jsonl`); fs.mkdirSync(legacySessionsDir, { recursive: true }); fs.writeFileSync( legacyFile, [ JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString(), }), JSON.stringify({ role: 'user', content: legacyMessage, timestamp: new Date().toISOString(), }), '', ].join('\n'), 'utf-8', ); let migrationServer = await buildLocalServer({ dataDir: migrationDir }); try { const legacyHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?session=${sessionId}`, }); expect(legacyHistory.statusCode).toBe(200); expect(legacyHistory.json().messages).toEqual([ expect.objectContaining({ role: 'user', content: legacyMessage }), ]); expect(fs.existsSync(legacyFile)).toBe(false); migrationServer.workspaceManager.ensure('default', { name: 'default', group: 'test', teamId: 'managed-default-history-team', teamRole: 'member', }); await migrationServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); migrationServer = await buildLocalServer({ dataDir: migrationDir }); const restartedLegacyHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?session=${sessionId}`, }); expect(restartedLegacyHistory.statusCode).toBe(200); expect(restartedLegacyHistory.json().messages).toEqual([ expect.objectContaining({ role: 'user', content: legacyMessage }), ]); const managedHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?workspace=default&session=${sessionId}`, }); expect(managedHistory.statusCode).toBe(200); expect(managedHistory.json().messages).toEqual([]); const managedSessionId = `${sessionId}-managed`; const managedMessage = `managed default cold history ${Date.now()}`; migrationServer.agentRunner = async ( config: AgentLoopConfig, ): Promise => { const content = `reply:${config.messages.at(-1)?.content ?? ''}`; config.onToken?.(content); return { content, toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 }, }; }; const managedPost = await injectWithAuth(migrationServer, { method: 'POST', url: '/api/chat', payload: { message: managedMessage, workspace: 'default', session: managedSessionId, }, }); expect(managedPost.statusCode).toBe(200); const warmManagedHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?workspace=default&session=${managedSessionId}`, }); expect( warmManagedHistory.json().messages.map( (entry: { content: string }) => entry.content, ), ).toEqual([managedMessage, `reply:${managedMessage}`]); const managedTarget = resolveChatHistoryTarget( migrationDir, 'default', true, ); migrationServer.agentState.sessionHistories.delete( chatSessionStateKey(managedTarget.stateWorkspaceId, managedSessionId), ); const coldManagedHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?workspace=default&session=${managedSessionId}`, }); expect( coldManagedHistory.json().messages.map( (entry: { content: string }) => entry.content, ), ).toEqual([managedMessage, `reply:${managedMessage}`]); await migrationServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); migrationServer = await buildLocalServer({ dataDir: migrationDir }); const restartedManagedHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?workspace=default&session=${managedSessionId}`, }); const finalLegacyHistory = await injectWithAuth(migrationServer, { method: 'GET', url: `/api/history?session=${sessionId}`, }); expect( restartedManagedHistory.json().messages.map( (entry: { content: string }) => entry.content, ), ).toEqual([managedMessage, `reply:${managedMessage}`]); expect(finalLegacyHistory.json().messages).toEqual([ expect.objectContaining({ role: 'user', content: legacyMessage }), ]); } finally { await migrationServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); fs.rmSync(migrationDir, { recursive: true, force: true }); } }); it('fails closed without moving ambiguous pre-layout default histories', async () => { const recoveryDir = fs.mkdtempSync( path.join(os.tmpdir(), 'waggle-chat-history-recovery-'), ); const sessionId = `ambiguous-history-${Date.now()}`; const managedSessionsDir = path.join( recoveryDir, 'workspaces', 'default', 'sessions', ); const managedCandidate = path.join( managedSessionsDir, `${sessionId}.jsonl`, ); const managedContent = [ JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString(), }), JSON.stringify({ role: 'user', content: 'ambiguous canonical transcript', timestamp: new Date().toISOString(), }), '', ].join('\n'); fs.mkdirSync(managedSessionsDir, { recursive: true }); fs.writeFileSync( path.join(recoveryDir, 'workspaces', 'default', 'workspace.json'), JSON.stringify({ id: 'default', name: 'default', group: 'test', teamId: 'ambiguous-default-team', teamRole: 'member', created: new Date().toISOString(), }), 'utf-8', ); const workspaceMind = new MindDB( path.join(recoveryDir, 'workspaces', 'default', 'workspace.mind'), ); workspaceMind.close(); fs.writeFileSync(managedCandidate, managedContent, 'utf-8'); let recoveryServer = await buildLocalServer({ dataDir: recoveryDir }); try { const legacyHistory = await injectWithAuth(recoveryServer, { method: 'GET', url: `/api/history?session=${sessionId}`, }); const managedHistory = await injectWithAuth(recoveryServer, { method: 'GET', url: `/api/history?workspace=default&session=${sessionId}`, }); const recoveryPost = await injectWithAuth(recoveryServer, { method: 'POST', url: '/api/chat', payload: { message: 'must not append', session: sessionId }, }); const recoveryDelete = await injectWithAuth(recoveryServer, { method: 'DELETE', url: `/api/chat/history?session=${sessionId}`, }); expect(legacyHistory.statusCode).toBe(409); expect(managedHistory.statusCode).toBe(409); expect(recoveryPost.statusCode).toBe(409); expect(recoveryDelete.statusCode).toBe(409); expect(legacyHistory.json()).toMatchObject({ code: 'CHAT_HISTORY_RECOVERY_REQUIRED', }); expect(recoveryPost.json()).toMatchObject({ code: 'CHAT_HISTORY_RECOVERY_REQUIRED', }); expect(recoveryDelete.json()).toMatchObject({ code: 'CHAT_HISTORY_RECOVERY_REQUIRED', }); expect(fs.readFileSync(managedCandidate, 'utf-8')).toBe(managedContent); await recoveryServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); recoveryServer = await buildLocalServer({ dataDir: recoveryDir }); const restartedHistory = await injectWithAuth(recoveryServer, { method: 'GET', url: `/api/history?session=${sessionId}`, }); expect(restartedHistory.statusCode).toBe(409); expect(fs.readFileSync(managedCandidate, 'utf-8')).toBe(managedContent); } finally { await recoveryServer.close(); await new Promise(resolve => setTimeout(resolve, 100)); fs.rmSync(recoveryDir, { recursive: true, force: true }); } }); it('preserves conflicting pre-layout roots and rechecks after recovery', () => { const recoveryDir = fs.mkdtempSync( path.join(os.tmpdir(), 'waggle-chat-history-conflict-'), ); const sourceDir = path.join( recoveryDir, 'workspaces', 'default', 'sessions', ); const targetDir = path.join( chatHistoryDataDir(recoveryDir, false), 'workspaces', 'default', 'sessions', ); const sourceFile = path.join(sourceDir, 'source.jsonl'); const targetFile = path.join(targetDir, 'target.jsonl'); fs.mkdirSync(sourceDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(sourceFile, 'source transcript\n', 'utf-8'); fs.writeFileSync(targetFile, 'target transcript\n', 'utf-8'); try { const conflict = isolateLegacyDefaultChatSessions(recoveryDir); expect(conflict).toMatchObject({ status: 'recovery-required', code: 'CHAT_HISTORY_RECOVERY_REQUIRED', }); expect(fs.readFileSync(sourceFile, 'utf-8')).toBe('source transcript\n'); expect(fs.readFileSync(targetFile, 'utf-8')).toBe('target transcript\n'); expect( fs.existsSync(path.join(recoveryDir, 'chat-history-layout.json')), ).toBe(false); fs.rmSync(sourceDir, { recursive: true, force: true }); expect(isolateLegacyDefaultChatSessions(recoveryDir)).toEqual({ status: 'ready', }); expect(fs.readFileSync(targetFile, 'utf-8')).toBe('target transcript\n'); } finally { fs.rmSync(recoveryDir, { recursive: true, force: true }); } }); it('retries a transient Windows legacy-history rename failure without losing bytes', () => { const recoveryDir = fs.mkdtempSync( path.join(os.tmpdir(), 'waggle-chat-history-rename-retry-'), ); const sourceDir = path.join( recoveryDir, 'workspaces', 'default', 'sessions', ); const targetFile = path.join( chatHistoryDataDir(recoveryDir, false), 'workspaces', 'default', 'sessions', 'locked.jsonl', ); const sourceFile = path.join(sourceDir, 'locked.jsonl'); const sourceBytes = Buffer.from('locked transcript\r\n', 'utf-8'); fs.mkdirSync(sourceDir, { recursive: true }); fs.writeFileSync(sourceFile, sourceBytes); const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw Object.assign(new Error('file is temporarily locked'), { code: 'EPERM', }); }); try { const blocked = isolateLegacyDefaultChatSessions(recoveryDir); expect(blocked).toMatchObject({ status: 'recovery-required', code: 'CHAT_HISTORY_RECOVERY_REQUIRED', }); expect(fs.readFileSync(sourceFile)).toEqual(sourceBytes); expect(fs.existsSync(targetFile)).toBe(false); expect( fs.existsSync(path.join(recoveryDir, 'chat-history-layout.json')), ).toBe(false); } finally { renameSpy.mockRestore(); } try { expect(isolateLegacyDefaultChatSessions(recoveryDir)).toEqual({ status: 'ready', }); expect(fs.existsSync(sourceFile)).toBe(false); expect(fs.readFileSync(targetFile)).toEqual(sourceBytes); } finally { fs.rmSync(recoveryDir, { recursive: true, force: true }); } }); }); /** * Regression: in-app chat failed with LiteLLM's * {"message":"No connected db.","type":"no_db_connection","code":"400"} * because the credential pool injected a *provider* key (e.g. sk-ant-…) as the * bearer sent TO LiteLLM. LiteLLM validates only its master key in-memory; any * other key is treated as a virtual key and looked up in its database → 400 when * no DB is attached. The fix: skip the credential pool when the active provider * is LiteLLM, so the LiteLLM master key is used. The direct anthropic-proxy path * must still use the per-provider pool key. */ describe('Chat LiteLLM key routing (regression: no_db_connection)', () => { let server: FastifyInstance; let tmpDir: string; let capturedKey: string | undefined; const MASTER_KEY = 'sk-litellm-master-test'; const POOL_KEY = 'sk-ant-pool-test'; beforeAll(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-chat-keyroute-')); const mind = new MindDB(path.join(tmpDir, 'personal.mind')); mind.close(); server = await buildLocalServer({ dataDir: tmpDir }); // Seed a provider key so the 'anthropic' credential pool is NON-empty — // without this the pool is empty and the bug can't be observed. server.vault.set('anthropic', POOL_KEY); // The LiteLLM master key Waggle authenticates to the proxy with. server.agentState.litellmApiKey = MASTER_KEY; // Capturing runner — records the key the chat route resolved for this turn. server.agentRunner = async (config: AgentLoopConfig): Promise => { capturedKey = config.litellmApiKey; if (config.onToken) config.onToken('ok'); return { content: 'ok', toolsUsed: [], usage: { inputTokens: 1, outputTokens: 1 } }; }; }); afterAll(async () => { await server.close(); await new Promise(r => setTimeout(r, 100)); try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* EBUSY on Windows */ } }); it('LiteLLM provider → sends the LiteLLM master key, NOT a provider pool key', async () => { server.agentState.llmProvider = { provider: 'litellm', health: 'healthy', detail: 'test', checkedAt: new Date().toISOString(), }; capturedKey = undefined; await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'hi', model: 'claude-sonnet-4-6' }, }); // Pre-fix this was POOL_KEY → LiteLLM rejected it as an unknown virtual key. expect(capturedKey).toBe(MASTER_KEY); expect(capturedKey).not.toBe(POOL_KEY); }); it('anthropic-proxy provider → still uses the credential pool key (direct path unchanged)', async () => { server.agentState.llmProvider = { provider: 'anthropic-proxy', health: 'healthy', detail: 'test', checkedAt: new Date().toISOString(), }; capturedKey = undefined; await injectWithAuth(server, { method: 'POST', url: '/api/chat', payload: { message: 'hi', model: 'claude-sonnet-4-6' }, }); expect(capturedKey).toBe(POOL_KEY); }); }); describe('applyContextWindow', () => { it('returns all messages when under the limit', () => { const messages = [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there' }, ]; const result = applyContextWindow(messages); expect(result).toEqual(messages); expect(result.length).toBe(2); }); it('returns all messages when exactly at the limit', () => { const messages = Array.from({ length: MAX_CONTEXT_MESSAGES }, (_, i) => ({ role: i % 2 === 0 ? 'user' : 'assistant', content: `msg-${i}`, })); const result = applyContextWindow(messages); expect(result).toEqual(messages); expect(result.length).toBe(MAX_CONTEXT_MESSAGES); }); it('truncates and prepends notice when history exceeds limit', () => { const totalMessages = 60; const messages = Array.from({ length: totalMessages }, (_, i) => ({ role: i % 2 === 0 ? 'user' : 'assistant', content: `msg-${i}`, })); const result = applyContextWindow(messages); // Should be MAX_CONTEXT_MESSAGES + 1 truncation notice expect(result.length).toBe(MAX_CONTEXT_MESSAGES + 1); // First message is the truncation notice expect(result[0].role).toBe('system'); expect(result[0].content).toContain('Context summary'); expect(result[0].content).toContain(`${totalMessages - MAX_CONTEXT_MESSAGES} earlier messages`); // Remaining messages are the last MAX_CONTEXT_MESSAGES from the original const expectedMessages = messages.slice(-MAX_CONTEXT_MESSAGES); expect(result.slice(1)).toEqual(expectedMessages); // Last message should be the most recent expect(result[result.length - 1].content).toBe(`msg-${totalMessages - 1}`); }); it('preserves the most recent messages', () => { const messages = Array.from({ length: 55 }, (_, i) => ({ role: 'user', content: `msg-${i}`, })); const result = applyContextWindow(messages); // The oldest kept message should be msg-5 (55 - 50 = 5 truncated) expect(result[1].content).toBe('msg-5'); expect(result[result.length - 1].content).toBe('msg-54'); }); it('accepts a custom max messages parameter', () => { const messages = Array.from({ length: 10 }, (_, i) => ({ role: 'user', content: `msg-${i}`, })); const result = applyContextWindow(messages, 5); expect(result.length).toBe(6); // 5 messages + 1 truncation notice expect(result[0].role).toBe('system'); expect(result[0].content).toContain('5 earlier messages'); expect(result[1].content).toBe('msg-5'); }); }); describe('conversational gated tool filtering', () => { const tools = [ { name: 'search_memory' }, { name: 'save_memory' }, { name: 'web_search' }, { name: 'web_fetch' }, { name: 'query_knowledge' }, { name: 'git_log' }, { name: 'write_file' }, { name: 'bash' }, { name: 'git_push' }, { name: 'create_plan' }, { name: 'spawn_agent' }, { name: 'run_code' }, { name: 'get_task_output' }, ]; it('hides gated system tools for normal conversational turns', () => { const filtered = filterGatedToolsForConversationalTurn( tools, "Prove you're not just a ChatGPT wrapper. What can you concretely do?", 'normal', ).map(t => t.name); expect(filtered).toEqual([]); }); it('keeps memory search when the user explicitly asks for memory recall', () => { const filtered = filterGatedToolsForConversationalTurn( tools, 'Search memory for my product notes', 'normal', ).map(t => t.name); expect(filtered).toEqual(['search_memory']); }); it('keeps web tools when the user explicitly asks for external research', () => { expect(isExplicitExternalResearchRequest('Research the latest MCP connector options online')).toBe(true); const filtered = filterGatedToolsForConversationalTurn( tools, 'Research the latest MCP connector options online', 'normal', ).map(t => t.name); expect(filtered).toEqual(['web_search', 'web_fetch']); }); it('keeps memory save when the user explicitly asks to remember something', () => { expect(isExplicitMemorySaveRequest('Remember this: I prefer concise launch reports')).toBe(true); const filtered = filterGatedToolsForConversationalTurn( tools, 'Remember this: I prefer concise launch reports', 'normal', ).map(t => t.name); expect(filtered).toEqual(['save_memory']); }); it('keeps gated tools when the user explicitly asks for an action', () => { expect(isExplicitGatedToolRequest('Write this as a file and export a document')).toBe(true); for (const request of [ 'Fix the failing TypeScript test', 'Build a roadmap with dependencies', 'Prepare a meeting brief from prior notes', 'Verify this implementation with evidence', 'Delegate parallel research to agents', ]) { expect(isExplicitGatedToolRequest(request), request).toBe(true); } const filtered = filterGatedToolsForConversationalTurn( tools, 'Write this as a file and export a document', 'normal', ).map(t => t.name); expect(filtered).toContain('write_file'); expect(filtered).not.toContain('create_plan'); }); it('withholds plan authoring for an inline advisory plan but keeps explicit plan creation', () => { const advisory = filterGatedToolsForConversationalTurn( tools, 'Choose the order, justify it in one concise plan, and identify the first action for today.', 'normal', ).map(tool => tool.name); expect(advisory).not.toContain('create_plan'); const explicit = filterGatedToolsForConversationalTurn( tools, 'Create a product launch plan and a concise launch memo.', 'normal', ).map(tool => tool.name); expect(explicit).toContain('create_plan'); }); it('does not treat a prioritization plan as authorization to execute tools', () => { const message = 'I have three priorities this week: close one customer, repair onboarding friction, and investigate a production memory bug. Choose the order, justify it in one concise plan, and identify the first action for today. Do not ask clarifying questions; make reasonable assumptions.'; expect(isExplicitGatedToolRequest(message)).toBe(false); expect(isExplicitExternalResearchRequest(message)).toBe(false); const filtered = filterGatedToolsForConversationalTurn(tools, message, 'normal') .map(tool => tool.name); expect(filtered).toEqual([]); }); it('treats a broad no-change clause as authoritative at every autonomy level', () => { const message = 'Turn this goal into milestones and exit criteria. Do not create or edit anything.'; expect(isExplicitGatedToolRequest(message)).toBe(false); for (const autonomy of ['normal', 'trusted', 'yolo'] as const) { const filtered = filterGatedToolsForConversationalTurn(tools, message, autonomy) .map(tool => tool.name); expect(filtered, autonomy).toContain('search_memory'); expect(filtered, autonomy).toContain('git_log'); for (const mutation of ['save_memory', 'write_file', 'bash', 'git_push', 'create_plan', 'spawn_agent']) { expect(filtered, `${autonomy}:${mutation}`).not.toContain(mutation); } } }); it('can deny memory persistence without blocking another explicit action', () => { const filtered = filterGatedToolsForConversationalTurn( tools, 'Write this as a file, but do not save this to memory.', 'normal', ).map(tool => tool.name); expect(filtered).toContain('write_file'); expect(filtered).not.toContain('save_memory'); }); it('does not broaden a calendar-only prohibition into a memory ban', () => { const filtered = filterGatedToolsForConversationalTurn( tools, 'Remember this preference, but do not create a calendar event.', 'normal', ).map(tool => tool.name); expect(filtered).toContain('save_memory'); }); it('keeps gated tools when elevated autonomy is active', () => { const filtered = filterGatedToolsForConversationalTurn( tools, 'Give me a concise answer', 'trusted', ).map(t => t.name); expect(filtered).toEqual(tools.map(t => t.name)); }); it('recognizes explicit memory recall requests separately from topical memory discussion', () => { const positiveRequests = [ 'What do you remember about me?', 'What memories have you saved about me?', 'Search memory for my product notes', 'Search in my memory for prior launch notes', 'Show me my memories', 'Recall our launch decision', 'Remember what I told you about launch timing', 'Do you remember when we selected the local model?', 'Find our saved launch decision', 'Retrieve my saved decision', ]; const topicalRequests = [ 'How does persistent memory affect agent reliability?', 'Compare precision and recall for these search results', 'Recall the formula for cosine similarity', 'Explain how to remember the order of operations', 'Investigate a product recall with current primary sources', 'Find the memory leak in this TypeScript service', 'Inspect memory usage for the local model', 'Search memory store benchmarks', 'Find my context window limit', 'Search prior history of SQLite', 'Retrieve my notes app installer', 'Compare desktop AI memory store architectures', 'Use current primary sources to compare SQLite vector search with PostgreSQL plus pgvector for a single-user desktop AI memory store.', ]; for (const request of positiveRequests) { expect(isExplicitMemoryRecallRequest(request), request).toBe(true); } for (const request of topicalRequests) { expect(isExplicitMemoryRecallRequest(request), request).toBe(false); } }); it('does not spend a memory-tool round on an external vector-search comparison', () => { const filtered = filterGatedToolsForConversationalTurn( tools, 'Use current primary sources to compare SQLite vector search with PostgreSQL plus pgvector for a single-user desktop AI memory store. Cite source URLs.', 'normal', ).map(tool => tool.name); expect(filtered).toEqual(['web_search', 'web_fetch']); }); it('applies the same conversational narrowing to plugin tools', () => { const provider = { getAllTools: () => [ { name: 'bash', description: '', parameters: {}, execute: async () => 'ok' }, { name: 'web_search', description: '', parameters: {}, execute: async () => 'ok' }, { name: 'save_memory', description: '', parameters: {}, execute: async () => 'ok' }, { name: 'plugin_slack_send_message', description: 'Send a Slack message', parameters: {}, execute: async () => 'ok' }, { name: 'mcp_github_create_issue', description: 'Create a GitHub issue', parameters: {}, execute: async () => 'ok' }, ], }; const withheld: number[] = []; const filteredProvider = filterPluginToolsForConversationalTurn( provider, "Prove you're not just a ChatGPT wrapper. What can you concretely do?", 'normal', count => withheld.push(count), ); expect(filteredProvider.getAllTools().map(t => t.name)).toEqual([]); expect(withheld).toEqual([5]); }); });