This commit is contained in:
302
packages/server/tests/routes/workspace-context.test.ts
Normal file
302
packages/server/tests/routes/workspace-context.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { buildWorkspaceNowBlock, formatWorkspaceNowPrompt, type WorkspaceNowBlock } from '../../src/local/routes/workspace-context.js';
|
||||
|
||||
describe('workspace-context', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ctx-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// On Windows, SQLite WAL files may keep handles open briefly; best-effort cleanup
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Non-critical — OS will clean temp dir
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: create a workspace directory structure with a mind DB
|
||||
function setupWorkspace(id: string, opts?: {
|
||||
frames?: Array<{ content: string; importance: string }>;
|
||||
sessions?: Array<{ title: string; messages: Array<{ role: string; content: string }> }>;
|
||||
}) {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', id);
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
|
||||
// Write workspace.json
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'workspace.json'),
|
||||
JSON.stringify({ id, name: `Test Workspace ${id}`, group: 'test', created: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// Create mind DB with frames
|
||||
const mindPath = path.join(wsDir, 'workspace.mind');
|
||||
if (opts?.frames && opts.frames.length > 0) {
|
||||
const db = new MindDB(mindPath);
|
||||
const raw = db.getDatabase();
|
||||
// Insert a session first (frames require a session via gop_id FK)
|
||||
raw.prepare(
|
||||
`INSERT INTO sessions (gop_id, status, started_at)
|
||||
VALUES ('session:test', 'active', datetime('now'))`
|
||||
).run();
|
||||
|
||||
for (const frame of opts.frames) {
|
||||
raw.prepare(
|
||||
`INSERT INTO memory_frames (gop_id, frame_type, content, importance, created_at)
|
||||
VALUES ('session:test', 'I', ?, ?, datetime('now'))`
|
||||
).run(frame.content, frame.importance);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Create session files
|
||||
if (opts?.sessions) {
|
||||
const sessDir = path.join(wsDir, 'sessions');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
|
||||
for (let i = 0; i < opts.sessions.length; i++) {
|
||||
const sess = opts.sessions[i];
|
||||
const sessionId = `session-${i}`;
|
||||
const lines: string[] = [
|
||||
JSON.stringify({ type: 'meta', title: sess.title, created: new Date().toISOString() }),
|
||||
];
|
||||
for (const msg of sess.messages) {
|
||||
lines.push(JSON.stringify({ role: msg.role, content: msg.content }));
|
||||
}
|
||||
fs.writeFileSync(path.join(sessDir, `${sessionId}.jsonl`), lines.join('\n') + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
return { mindPath, wsDir };
|
||||
}
|
||||
|
||||
function makeManager(workspaces: Map<string, { id: string; name: string }>) {
|
||||
return {
|
||||
get: (id: string) => workspaces.get(id) ?? null,
|
||||
getMindPath: (id: string) => path.join(tmpDir, 'workspaces', id, 'workspace.mind'),
|
||||
};
|
||||
}
|
||||
|
||||
const noopActivate = (_id: string) => true;
|
||||
|
||||
// ── Test 1: Returns null for non-existent workspace ──────────────
|
||||
|
||||
it('returns null for non-existent workspace', () => {
|
||||
const wsManager = makeManager(new Map());
|
||||
|
||||
const result = buildWorkspaceNowBlock({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'does-not-exist',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── Test 2: Returns null for workspace with no data ──────────────
|
||||
|
||||
it('returns null for workspace with no mind file', () => {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', 'empty-ws');
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
|
||||
const wsManager = makeManager(new Map([
|
||||
['empty-ws', { id: 'empty-ws', name: 'Empty Workspace' }],
|
||||
]));
|
||||
|
||||
const result = buildWorkspaceNowBlock({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'empty-ws',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for workspace with mind file but zero frames', () => {
|
||||
const wsManager = makeManager(new Map([
|
||||
['zero-ws', { id: 'zero-ws', name: 'Zero Frames' }],
|
||||
]));
|
||||
|
||||
// Create a mind DB with no frames
|
||||
setupWorkspace('zero-ws', { frames: [] });
|
||||
// MindDB file exists but has no frames — we need the file to exist
|
||||
const mindPath = path.join(tmpDir, 'workspaces', 'zero-ws', 'workspace.mind');
|
||||
const db = new MindDB(mindPath);
|
||||
db.close();
|
||||
|
||||
const result = buildWorkspaceNowBlock({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'zero-ws',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── Test 3: Returns correctly shaped block ───────────────────────
|
||||
|
||||
it('returns correctly shaped block for workspace with data', () => {
|
||||
const wsManager = makeManager(new Map([
|
||||
['rich-ws', { id: 'rich-ws', name: 'Rich Workspace' }],
|
||||
]));
|
||||
|
||||
setupWorkspace('rich-ws', {
|
||||
frames: [
|
||||
{ content: 'Working on the Waggle project, a workspace-native AI agent platform.', importance: 'important' },
|
||||
{ content: 'Decision: Use SQLite for local storage instead of PostgreSQL.', importance: 'critical' },
|
||||
{ content: 'Implemented the memory frame system with FTS5 search.', importance: 'normal' },
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
title: 'Planning session',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Let us plan the architecture for the next milestone' },
|
||||
{ role: 'assistant', content: 'I have reviewed the current state and here is my recommendation.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceNowBlock({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'rich-ws',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.workspaceName).toBe('Rich Workspace');
|
||||
expect(result!.summary).toBeTruthy();
|
||||
expect(result!.summary.length).toBeGreaterThan(10);
|
||||
expect(Array.isArray(result!.recentDecisions)).toBe(true);
|
||||
expect(Array.isArray(result!.activeThreads)).toBe(true);
|
||||
expect(Array.isArray(result!.progressItems)).toBe(true);
|
||||
expect(Array.isArray(result!.nextActions)).toBe(true);
|
||||
// Should have at least one thread from the session
|
||||
expect(result!.activeThreads.length).toBeGreaterThanOrEqual(1);
|
||||
// Should have at least one decision (the critical frame)
|
||||
expect(result!.recentDecisions.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ── Test 4: Caps at correct limits ───────────────────────────────
|
||||
|
||||
it('caps decisions at 3, threads at 3, progress at 5', () => {
|
||||
const wsManager = makeManager(new Map([
|
||||
['capped-ws', { id: 'capped-ws', name: 'Capped Workspace' }],
|
||||
]));
|
||||
|
||||
// Create many decisions and sessions
|
||||
const frames = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
frames.push({ content: `Decision: chose option ${i} for the architecture component ${i}`, importance: 'critical' as const });
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
sessions.push({
|
||||
title: `Thread ${i}: discussing component ${i}`,
|
||||
messages: [
|
||||
{ role: 'user', content: `Tell me about component ${i}` },
|
||||
{ role: 'assistant', content: `Here is info about component ${i}` },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
setupWorkspace('capped-ws', { frames, sessions });
|
||||
|
||||
const result = buildWorkspaceNowBlock({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'capped-ws',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.recentDecisions.length).toBeLessThanOrEqual(3);
|
||||
expect(result!.activeThreads.length).toBeLessThanOrEqual(3);
|
||||
expect(result!.progressItems.length).toBeLessThanOrEqual(5);
|
||||
expect(result!.nextActions.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
// ── Test 5: formatWorkspaceNowPrompt produces clean markdown ─────
|
||||
|
||||
describe('formatWorkspaceNowPrompt', () => {
|
||||
it('produces clean markdown with all sections', () => {
|
||||
const block: WorkspaceNowBlock = {
|
||||
workspaceName: 'My Project',
|
||||
summary: 'Working on an AI agent platform. Active today with 42 memories across 5 sessions.',
|
||||
recentDecisions: ['Use SQLite for local storage', 'Deploy via Tauri'],
|
||||
activeThreads: ['Architecture planning (2h ago)', 'Memory model design (yesterday)'],
|
||||
progressItems: ['[blocker] Waiting for API key', '[task] Implement search', '[completed] Setup CI'],
|
||||
nextActions: ['Resolve: Waiting for API key', 'Implement search'],
|
||||
};
|
||||
|
||||
const result = formatWorkspaceNowPrompt(block);
|
||||
|
||||
expect(result).toContain('# Workspace Now — My Project');
|
||||
expect(result).toContain('Working on an AI agent platform');
|
||||
expect(result).toContain('## Recent Decisions');
|
||||
expect(result).toContain('- Use SQLite for local storage');
|
||||
expect(result).toContain('- Deploy via Tauri');
|
||||
expect(result).toContain('## Active Threads');
|
||||
expect(result).toContain('- Architecture planning (2h ago)');
|
||||
expect(result).toContain('## Progress');
|
||||
expect(result).toContain('- [blocker] Waiting for API key');
|
||||
expect(result).toContain('## Likely Next Actions');
|
||||
expect(result).toContain('- Resolve: Waiting for API key');
|
||||
// No trailing whitespace
|
||||
expect(result).toBe(result.trimEnd());
|
||||
});
|
||||
|
||||
it('omits empty sections', () => {
|
||||
const block: WorkspaceNowBlock = {
|
||||
workspaceName: 'Minimal',
|
||||
summary: 'A minimal workspace.',
|
||||
recentDecisions: [],
|
||||
activeThreads: [],
|
||||
progressItems: [],
|
||||
nextActions: [],
|
||||
};
|
||||
|
||||
const result = formatWorkspaceNowPrompt(block);
|
||||
|
||||
expect(result).toContain('# Workspace Now — Minimal');
|
||||
expect(result).toContain('A minimal workspace.');
|
||||
expect(result).not.toContain('## Recent Decisions');
|
||||
expect(result).not.toContain('## Active Threads');
|
||||
expect(result).not.toContain('## Progress');
|
||||
expect(result).not.toContain('## Likely Next Actions');
|
||||
});
|
||||
|
||||
it('omits summary section if empty', () => {
|
||||
const block: WorkspaceNowBlock = {
|
||||
workspaceName: 'No Summary',
|
||||
summary: '',
|
||||
recentDecisions: ['Some decision'],
|
||||
activeThreads: [],
|
||||
progressItems: [],
|
||||
nextActions: [],
|
||||
};
|
||||
|
||||
const result = formatWorkspaceNowPrompt(block);
|
||||
|
||||
expect(result).toContain('# Workspace Now — No Summary');
|
||||
expect(result).toContain('## Recent Decisions');
|
||||
// Should not have blank lines between heading and decisions (no empty summary block)
|
||||
const lines = result.split('\n');
|
||||
const headingIdx = lines.findIndex(l => l.startsWith('# Workspace Now'));
|
||||
const decisionsIdx = lines.findIndex(l => l === '## Recent Decisions');
|
||||
// There should be only one blank line between heading and decisions section
|
||||
expect(decisionsIdx - headingIdx).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user