This commit is contained in:
314
app/tests/e2e/chat.test.ts
Normal file
314
app/tests/e2e/chat.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* E2E Tests: Chat Streaming, Tool Events, and Approval Gate
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 4. Chat message sent and response received (with mock agentRunner)
|
||||
* 9. Tool execution — mock runner calls onToolUse, SSE includes tool event
|
||||
* 10. External mutation gate — eventBus-based approval flow
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import type { AgentRunner } from '@waggle/server/local/routes/chat';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
/**
|
||||
* Parse SSE text into structured events.
|
||||
* Each event is: "event: <type>\ndata: <json>\n\n"
|
||||
*/
|
||||
function parseSSE(raw: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const blocks = raw.split('\n\n').filter(b => b.trim());
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
let event = '';
|
||||
let data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
event = line.slice(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
data = line.slice(6);
|
||||
}
|
||||
}
|
||||
if (event && data) {
|
||||
try {
|
||||
events.push({ event, data: JSON.parse(data) });
|
||||
} catch {
|
||||
events.push({ event, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('Chat E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 4: Chat message sent and response received via SSE
|
||||
it('sends chat message and receives SSE token + done events', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Inject mock agentRunner
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToken) {
|
||||
config.onToken('Hello ');
|
||||
config.onToken('world');
|
||||
}
|
||||
return {
|
||||
content: 'Hello world',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolsUsed: [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Hi there' },
|
||||
});
|
||||
|
||||
// SSE hijacks the response — status comes from raw writeHead
|
||||
// inject() returns the raw body as payload
|
||||
const events = parseSSE(res.payload);
|
||||
|
||||
// Should have token events
|
||||
const tokenEvents = events.filter(e => e.event === 'token');
|
||||
expect(tokenEvents.length).toBe(2);
|
||||
expect((tokenEvents[0].data as { content: string }).content).toBe('Hello ');
|
||||
expect((tokenEvents[1].data as { content: string }).content).toBe('world');
|
||||
|
||||
// Should have done event
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
const doneData = doneEvents[0].data as { content: string; usage: unknown; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Hello world');
|
||||
expect(doneData.toolsUsed).toEqual([]);
|
||||
});
|
||||
|
||||
// Scenario 9: Tool execution events in SSE stream
|
||||
it('includes tool events in SSE when agentRunner calls onToolUse', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToken) {
|
||||
config.onToken('Reading file...');
|
||||
}
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('read_file', { path: '/src/index.ts' });
|
||||
}
|
||||
if (config.onToken) {
|
||||
config.onToken(' Done.');
|
||||
}
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('write_file', { path: '/src/output.ts', content: 'export {}' });
|
||||
}
|
||||
return {
|
||||
content: 'Reading file... Done.',
|
||||
usage: { inputTokens: 20, outputTokens: 10 },
|
||||
toolsUsed: ['read_file', 'write_file'],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Read the index file' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
|
||||
// Should have tool events
|
||||
const toolEvents = events.filter(e => e.event === 'tool');
|
||||
expect(toolEvents.length).toBe(2);
|
||||
|
||||
const tool1 = toolEvents[0].data as { name: string; input: Record<string, unknown> };
|
||||
expect(tool1.name).toBe('read_file');
|
||||
expect(tool1.input.path).toBe('/src/index.ts');
|
||||
|
||||
const tool2 = toolEvents[1].data as { name: string; input: Record<string, unknown> };
|
||||
expect(tool2.name).toBe('write_file');
|
||||
|
||||
// Done event should list tools used
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
const doneData = doneEvents[0].data as { toolsUsed: string[] };
|
||||
expect(doneData.toolsUsed).toEqual(['read_file', 'write_file']);
|
||||
});
|
||||
|
||||
// Scenario 10: External mutation gate — eventBus approval flow
|
||||
// The approval gate is driven by the eventBus: the agent emits a
|
||||
// 'gate:request' event and waits for 'gate:response'. This test
|
||||
// verifies the eventBus-based flow without needing an HTTP endpoint.
|
||||
it('external mutation gate: eventBus blocks and resumes on approval', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Simulate the approval gate pattern used by the desktop app:
|
||||
// agentRunner emits 'gate:request' on the eventBus, waits for 'gate:response'
|
||||
const gateLog: string[] = [];
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
// Simulate a tool that triggers an approval gate
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('bash', { command: 'rm -rf /important' });
|
||||
}
|
||||
|
||||
// Emit gate request and wait for response
|
||||
const approved = await new Promise<boolean>((resolve) => {
|
||||
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
|
||||
gateLog.push(response.approved ? 'approved' : 'denied');
|
||||
resolve(response.approved);
|
||||
});
|
||||
server.eventBus.emit('gate:request', {
|
||||
tool: 'bash',
|
||||
input: { command: 'rm -rf /important' },
|
||||
requestId: 'gate-001',
|
||||
});
|
||||
});
|
||||
|
||||
if (config.onToken) {
|
||||
config.onToken(approved ? 'Executed.' : 'Blocked.');
|
||||
}
|
||||
|
||||
return {
|
||||
content: approved ? 'Executed.' : 'Blocked.',
|
||||
usage: { inputTokens: 15, outputTokens: 3 },
|
||||
toolsUsed: approved ? ['bash'] : [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
// Listen for gate requests and auto-approve
|
||||
server.eventBus.on('gate:request', (req: { requestId: string }) => {
|
||||
gateLog.push('request-received');
|
||||
// Simulate user clicking "Approve" in the UI
|
||||
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: true });
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Delete the folder' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
|
||||
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Executed.');
|
||||
expect(doneData.toolsUsed).toEqual(['bash']);
|
||||
|
||||
// Verify gate flow happened in correct order
|
||||
expect(gateLog.length).toBe(2);
|
||||
expect(gateLog[0]).toBe('request-received');
|
||||
expect(gateLog[1]).toBe('approved');
|
||||
});
|
||||
|
||||
// Scenario 10b: External mutation gate — denial path
|
||||
it('external mutation gate: eventBus blocks and returns denial', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const gateLog: string[] = [];
|
||||
|
||||
const mockRunner: AgentRunner = async (config) => {
|
||||
if (config.onToolUse) {
|
||||
config.onToolUse('bash', { command: 'rm -rf /important' });
|
||||
}
|
||||
|
||||
// Emit gate request and wait for response
|
||||
const approved = await new Promise<boolean>((resolve) => {
|
||||
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
|
||||
gateLog.push(response.approved ? 'approved' : 'denied');
|
||||
resolve(response.approved);
|
||||
});
|
||||
server.eventBus.emit('gate:request', {
|
||||
tool: 'bash',
|
||||
input: { command: 'rm -rf /important' },
|
||||
requestId: 'gate-002',
|
||||
});
|
||||
});
|
||||
|
||||
if (config.onToken) {
|
||||
config.onToken(approved ? 'Executed.' : 'Blocked.');
|
||||
}
|
||||
|
||||
return {
|
||||
content: approved ? 'Executed.' : 'Blocked.',
|
||||
usage: { inputTokens: 15, outputTokens: 3 },
|
||||
toolsUsed: approved ? ['bash'] : [],
|
||||
};
|
||||
};
|
||||
server.agentRunner = mockRunner;
|
||||
|
||||
// Listen for gate requests and auto-DENY
|
||||
server.eventBus.on('gate:request', (req: { requestId: string }) => {
|
||||
gateLog.push('request-received');
|
||||
// Simulate user clicking "Deny" in the UI
|
||||
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: false });
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/chat',
|
||||
payload: { message: 'Delete the folder' },
|
||||
});
|
||||
|
||||
const events = parseSSE(res.payload);
|
||||
const doneEvents = events.filter(e => e.event === 'done');
|
||||
expect(doneEvents.length).toBe(1);
|
||||
|
||||
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
|
||||
expect(doneData.content).toBe('Blocked.');
|
||||
expect(doneData.toolsUsed).toEqual([]);
|
||||
|
||||
// Verify gate flow happened in correct order with denial
|
||||
expect(gateLog.length).toBe(2);
|
||||
expect(gateLog[0]).toBe('request-received');
|
||||
expect(gateLog[1]).toBe('denied');
|
||||
});
|
||||
});
|
||||
132
app/tests/e2e/startup.test.ts
Normal file
132
app/tests/e2e/startup.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* E2E Tests: Startup, Onboarding, and Settings Persistence
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 1. Service starts and responds to health check
|
||||
* 2. Onboarding wizard completes successfully (config save/load via settings API)
|
||||
* 7. Settings saved and persisted across restart
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
describe('Startup & Settings E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 1: Service starts and responds to health check
|
||||
it('health check returns 200 with status ok and mode local', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
const res = await server.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(res.payload);
|
||||
// With skipLiteLLM, health is degraded (no verified LLM), not 'ok' — truthful health
|
||||
expect(['ok', 'degraded', 'unavailable']).toContain(body.status);
|
||||
expect(body.mode).toBe('local');
|
||||
expect(body.timestamp).toBeDefined();
|
||||
// Deep health fields present
|
||||
expect(body.llm).toBeDefined();
|
||||
expect(body.database).toBeDefined();
|
||||
});
|
||||
|
||||
// Scenario 2: Onboarding — save config via PUT, read it back via GET
|
||||
it('onboarding: saves and loads config via settings API', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Save onboarding config
|
||||
const putRes = await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: {
|
||||
defaultModel: 'anthropic/claude-sonnet-4-20250514',
|
||||
providers: {
|
||||
anthropic: { apiKey: 'sk-ant-test-key-1234567890', models: ['claude-sonnet-4-20250514'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
|
||||
const putBody = JSON.parse(putRes.payload);
|
||||
expect(putBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
|
||||
|
||||
// Read config back
|
||||
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/settings' });
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
|
||||
const getBody = JSON.parse(getRes.payload);
|
||||
expect(getBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
|
||||
expect(getBody.providers).toBeDefined();
|
||||
expect(getBody.dataDir).toBe(dataDir);
|
||||
});
|
||||
|
||||
// Scenario 7: Settings persisted across restart
|
||||
it('settings persist across server restart', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
|
||||
// --- First server: save settings ---
|
||||
const port1 = TEST_PORT;
|
||||
const { server: server1 } = await startService({ dataDir, port: port1, skipLiteLLM: true });
|
||||
servers.push(server1);
|
||||
|
||||
const putRes = await injectWithAuth(server1, {
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: {
|
||||
defaultModel: 'openai/gpt-4o',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-test-openai-key-1234567', models: ['gpt-4o'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
|
||||
await server1.close();
|
||||
servers.pop();
|
||||
|
||||
// --- Second server: read settings back ---
|
||||
const port2 = TEST_PORT;
|
||||
const { server: server2 } = await startService({ dataDir, port: port2, skipLiteLLM: true });
|
||||
servers.push(server2);
|
||||
|
||||
const getRes = await injectWithAuth(server2, { method: 'GET', url: '/api/settings' });
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
|
||||
const body = JSON.parse(getRes.payload);
|
||||
expect(body.defaultModel).toBe('openai/gpt-4o');
|
||||
});
|
||||
});
|
||||
22
app/tests/e2e/test-utils.ts
Normal file
22
app/tests/e2e/test-utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared test utilities for e2e tests.
|
||||
* Provides authenticated inject helper for local server tests (SEC-011).
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, InjectOptions } from 'fastify';
|
||||
|
||||
/**
|
||||
* Shorthand: inject with auth token from the server's agentState.
|
||||
* Returns the same result as server.inject().
|
||||
*/
|
||||
export function injectWithAuth(server: FastifyInstance, opts: InjectOptions) {
|
||||
const token = server.agentState.wsSessionToken;
|
||||
const existingHeaders = (opts.headers ?? {}) as Record<string, string>;
|
||||
return server.inject({
|
||||
...opts,
|
||||
headers: {
|
||||
...existingHeaders,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
332
app/tests/e2e/workspaces.test.ts
Normal file
332
app/tests/e2e/workspaces.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* E2E Tests: Workspaces, Memory Isolation, and Sessions
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 3. Workspace created via API
|
||||
* 5. Workspace switching changes mind context
|
||||
* 6. Memory search returns results from correct mind
|
||||
* 8. Session management (create, list, delete)
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { startService } from '@waggle/server/local/service';
|
||||
import { FrameStore, SessionStore } from '@waggle/core';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
|
||||
}
|
||||
|
||||
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
|
||||
const TEST_PORT = 0;
|
||||
|
||||
describe('Workspaces & Sessions E2E', () => {
|
||||
const servers: FastifyInstance[] = [];
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
try { await s.close(); } catch { /* ignore */ }
|
||||
}
|
||||
servers.length = 0;
|
||||
for (const d of tmpDirs) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
// Scenario 3: Create workspace via POST, verify GET returns it
|
||||
it('creates a workspace and retrieves it', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create workspace
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Test Project', group: 'Work', icon: 'briefcase' },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
|
||||
const created = JSON.parse(createRes.payload);
|
||||
expect(created.name).toBe('Test Project');
|
||||
expect(created.group).toBe('Work');
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
// List workspaces — should contain the new one
|
||||
const listRes = await injectWithAuth(server, { method: 'GET', url: '/api/workspaces' });
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
|
||||
const list = JSON.parse(listRes.payload);
|
||||
expect(list).toBeInstanceOf(Array);
|
||||
// Server now auto-creates a Default Workspace on boot via
|
||||
// WorkspaceManager.ensureDefault(), so the list contains both
|
||||
// the default and the one this test just created.
|
||||
const testWs = list.find((w: { name: string }) => w.name === 'Test Project');
|
||||
expect(testWs).toBeDefined();
|
||||
|
||||
// Get by ID
|
||||
const getRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${created.id}`,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const fetched = JSON.parse(getRes.payload);
|
||||
expect(fetched.id).toBe(created.id);
|
||||
expect(fetched.icon).toBe('briefcase');
|
||||
});
|
||||
|
||||
// Scenario 5: Workspace switching changes mind context
|
||||
it('workspace switching isolates memory context', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create two workspaces
|
||||
const res1 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Alpha', group: 'Work', model: 'openai/gpt-4o' },
|
||||
});
|
||||
const ws1 = JSON.parse(res1.payload);
|
||||
|
||||
const res2 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Beta', group: 'Personal', model: 'anthropic/claude-sonnet-4-20250514' },
|
||||
});
|
||||
const ws2 = JSON.parse(res2.payload);
|
||||
|
||||
// Verify they are distinct
|
||||
expect(ws1.id).not.toBe(ws2.id);
|
||||
expect(ws1.group).toBe('Work');
|
||||
expect(ws2.group).toBe('Personal');
|
||||
|
||||
// Each workspace has its own .mind file on disk
|
||||
const mind1Path = path.join(dataDir, 'workspaces', ws1.id, 'workspace.mind');
|
||||
const mind2Path = path.join(dataDir, 'workspaces', ws2.id, 'workspace.mind');
|
||||
expect(fs.existsSync(mind1Path)).toBe(true);
|
||||
expect(fs.existsSync(mind2Path)).toBe(true);
|
||||
|
||||
// Switch to ws-A, store memory, verify it's found
|
||||
server.multiMind.switchWorkspace(mind1Path);
|
||||
const wsASessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wsASession = wsASessions.create();
|
||||
const wsAFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsAFrames.createIFrame(wsASession.gop_id, 'Alpha project uses Kubernetes for deployment');
|
||||
|
||||
// Search ws-A scope — should find Kubernetes
|
||||
const searchA = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchA.statusCode).toBe(200);
|
||||
const resultsA = JSON.parse(searchA.payload);
|
||||
expect(resultsA.count).toBeGreaterThan(0);
|
||||
|
||||
// Switch to ws-B — search should NOT find ws-A's memory
|
||||
server.multiMind.switchWorkspace(mind2Path);
|
||||
const searchB = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchB.statusCode).toBe(200);
|
||||
const resultsB = JSON.parse(searchB.payload);
|
||||
expect(resultsB.count).toBe(0);
|
||||
|
||||
// Store different memory in ws-B
|
||||
const wsBSessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wsBSession = wsBSessions.create();
|
||||
const wsBFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsBFrames.createIFrame(wsBSession.gop_id, 'Beta project uses Docker Compose locally');
|
||||
|
||||
// ws-B should find Docker but not Kubernetes
|
||||
const searchB2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Docker&scope=workspace',
|
||||
});
|
||||
expect(searchB2.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchB2.payload).count).toBeGreaterThan(0);
|
||||
|
||||
// Switch back to ws-A — should find Kubernetes, not Docker
|
||||
server.multiMind.switchWorkspace(mind1Path);
|
||||
const searchA2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Kubernetes&scope=workspace',
|
||||
});
|
||||
expect(searchA2.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchA2.payload).count).toBeGreaterThan(0);
|
||||
|
||||
const searchA3 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Docker&scope=workspace',
|
||||
});
|
||||
expect(searchA3.statusCode).toBe(200);
|
||||
expect(JSON.parse(searchA3.payload).count).toBe(0);
|
||||
});
|
||||
|
||||
// Scenario 6: Memory search returns results from correct mind only
|
||||
it('memory search returns results scoped to the correct mind', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Store a memory in the personal mind — need a session first (FK constraint)
|
||||
const personalSessions = new SessionStore(server.multiMind.personal);
|
||||
const pSession = personalSessions.create();
|
||||
const personalFrames = new FrameStore(server.multiMind.personal);
|
||||
personalFrames.createIFrame(pSession.gop_id, 'Waggle architecture uses Tauri with React frontend');
|
||||
|
||||
// Create a workspace and store memory in its mind
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Research', group: 'Study' },
|
||||
});
|
||||
const ws = JSON.parse(createRes.payload);
|
||||
|
||||
// Switch multiMind to workspace so workspace search works
|
||||
const wsMindPath = path.join(dataDir, 'workspaces', ws.id, 'workspace.mind');
|
||||
server.multiMind.switchWorkspace(wsMindPath);
|
||||
|
||||
// Store memory in workspace mind — need a session first (FK constraint)
|
||||
const wsSessions = new SessionStore(server.multiMind.workspace!);
|
||||
const wSession = wsSessions.create();
|
||||
const wsFrames = new FrameStore(server.multiMind.workspace!);
|
||||
wsFrames.createIFrame(wSession.gop_id, 'GraphContext uses SHACL validation for knowledge graphs');
|
||||
|
||||
// Search personal scope — should find "Tauri" but not "SHACL"
|
||||
const personalSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=Tauri&scope=personal',
|
||||
});
|
||||
expect(personalSearch.statusCode).toBe(200);
|
||||
const personalResults = JSON.parse(personalSearch.payload);
|
||||
expect(personalResults.count).toBeGreaterThan(0);
|
||||
|
||||
// Search workspace scope — should find "SHACL" but not "Tauri"
|
||||
const wsSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=SHACL&scope=workspace',
|
||||
});
|
||||
expect(wsSearch.statusCode).toBe(200);
|
||||
const wsResults = JSON.parse(wsSearch.payload);
|
||||
expect(wsResults.count).toBeGreaterThan(0);
|
||||
|
||||
// Cross-check: personal scope should NOT find workspace-only content
|
||||
const crossCheck = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=SHACL&scope=personal',
|
||||
});
|
||||
const crossResults = JSON.parse(crossCheck.payload);
|
||||
expect(crossResults.count).toBe(0);
|
||||
|
||||
// Search all scope — should find both
|
||||
const allSearch = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/memory/search?q=architecture&scope=all',
|
||||
});
|
||||
const allResults = JSON.parse(allSearch.payload);
|
||||
expect(allResults.count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Scenario 8: Session CRUD — create, list, rename (switch), delete
|
||||
it('creates, lists, renames, and deletes sessions within a workspace', async () => {
|
||||
const dataDir = makeTmpDir();
|
||||
tmpDirs.push(dataDir);
|
||||
const port = TEST_PORT;
|
||||
|
||||
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
|
||||
servers.push(server);
|
||||
|
||||
// Create workspace first
|
||||
const wsRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Chat Workspace', group: 'Work' },
|
||||
});
|
||||
const ws = JSON.parse(wsRes.payload);
|
||||
|
||||
// Create a session
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
payload: { title: 'My First Chat' },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
const session = JSON.parse(createRes.payload);
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.title).toBe('My First Chat');
|
||||
expect(session.messageCount).toBe(0);
|
||||
|
||||
// Create a second session
|
||||
const createRes2 = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
payload: { title: 'Debug Session' },
|
||||
});
|
||||
expect(createRes2.statusCode).toBe(201);
|
||||
const session2 = JSON.parse(createRes2.payload);
|
||||
|
||||
// List sessions — should have 2
|
||||
const listRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const list = JSON.parse(listRes.payload);
|
||||
expect(list.length).toBe(2);
|
||||
|
||||
// Rename (switch equivalent) — proves session is accessible and modifiable
|
||||
const patchRes = await injectWithAuth(server, {
|
||||
method: 'PATCH',
|
||||
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
|
||||
payload: { title: 'Renamed Chat' },
|
||||
});
|
||||
expect(patchRes.statusCode).toBe(200);
|
||||
const patched = JSON.parse(patchRes.payload);
|
||||
expect(patched.title).toBe('Renamed Chat');
|
||||
expect(patched.id).toBe(session.id);
|
||||
|
||||
// Verify rename persisted in list
|
||||
const listRes3 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
const list3 = JSON.parse(listRes3.payload);
|
||||
const renamed = list3.find((s: { id: string }) => s.id === session.id);
|
||||
expect(renamed).toBeDefined();
|
||||
expect(renamed.title).toBe('Renamed Chat');
|
||||
|
||||
// Delete first session
|
||||
const delRes = await injectWithAuth(server, {
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
|
||||
});
|
||||
expect(delRes.statusCode).toBe(200);
|
||||
const delBody = JSON.parse(delRes.payload);
|
||||
expect(delBody.deleted).toBe(true);
|
||||
|
||||
// List again — should have 1
|
||||
const listRes2 = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${ws.id}/sessions`,
|
||||
});
|
||||
const list2 = JSON.parse(listRes2.payload);
|
||||
expect(list2.length).toBe(1);
|
||||
expect(list2[0].id).toBe(session2.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user