This commit is contained in:
432
packages/server/tests/routes/workspace-state.test.ts
Normal file
432
packages/server/tests/routes/workspace-state.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import {
|
||||
buildWorkspaceState,
|
||||
formatWorkspaceStatePrompt,
|
||||
computeFreshness,
|
||||
type WorkspaceState,
|
||||
} from '../../src/local/workspace-state.js';
|
||||
|
||||
describe('workspace-state', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-state-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||
});
|
||||
|
||||
function setupWorkspace(id: string, opts?: {
|
||||
frames?: Array<{ content: string; importance: string; created_at?: string }>;
|
||||
sessions?: Array<{
|
||||
title: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
ageMs?: number;
|
||||
}>;
|
||||
awareness?: Array<{ category: string; content: string; priority: number }>;
|
||||
}) {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', id);
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
|
||||
const mindPath = path.join(wsDir, 'workspace.mind');
|
||||
if (opts?.frames && opts.frames.length > 0) {
|
||||
const db = new MindDB(mindPath);
|
||||
const raw = db.getDatabase();
|
||||
raw.prepare(
|
||||
`INSERT INTO sessions (gop_id, status, started_at)
|
||||
VALUES ('session:test', 'active', datetime('now'))`,
|
||||
).run();
|
||||
|
||||
for (const frame of opts.frames) {
|
||||
const createdAt = frame.created_at ?? new Date().toISOString();
|
||||
raw.prepare(
|
||||
`INSERT INTO memory_frames (gop_id, frame_type, content, importance, created_at)
|
||||
VALUES ('session:test', 'I', ?, ?, ?)`,
|
||||
).run(frame.content, frame.importance, createdAt);
|
||||
}
|
||||
|
||||
if (opts?.awareness) {
|
||||
for (const item of opts.awareness) {
|
||||
raw.prepare(
|
||||
`INSERT INTO awareness (category, content, priority, created_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
).run(item.category, item.content, item.priority);
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
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 created = new Date(Date.now() - (sess.ageMs ?? 0)).toISOString();
|
||||
const lines: string[] = [
|
||||
JSON.stringify({ type: 'meta', title: sess.title, created }),
|
||||
];
|
||||
for (const msg of sess.messages) {
|
||||
lines.push(JSON.stringify({ role: msg.role, content: msg.content }));
|
||||
}
|
||||
const filePath = path.join(sessDir, `${sessionId}.jsonl`);
|
||||
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
||||
if (sess.ageMs) {
|
||||
const pastTime = new Date(Date.now() - sess.ageMs);
|
||||
fs.utimesSync(filePath, pastTime, pastTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// ── computeFreshness ──────────────────────────────────────────
|
||||
|
||||
describe('computeFreshness', () => {
|
||||
it('returns fresh for today', () => {
|
||||
expect(computeFreshness(new Date().toISOString())).toBe('fresh');
|
||||
});
|
||||
|
||||
it('returns fresh for 1 day ago', () => {
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(computeFreshness(yesterday)).toBe('fresh');
|
||||
});
|
||||
|
||||
it('returns aging for 3 days ago', () => {
|
||||
const threeDays = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(computeFreshness(threeDays)).toBe('aging');
|
||||
});
|
||||
|
||||
it('returns stale for 10 days ago', () => {
|
||||
const tenDays = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(computeFreshness(tenDays)).toBe('stale');
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildWorkspaceState ───────────────────────────────────────
|
||||
|
||||
describe('buildWorkspaceState', () => {
|
||||
it('returns null for non-existent workspace', () => {
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'nonexistent',
|
||||
wsManager: makeManager(new Map()),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for workspace with no mind file', () => {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', 'empty');
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'empty',
|
||||
wsManager: makeManager(new Map([['empty', { id: 'empty', name: 'Empty' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for workspace with zero memory frames', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, 'workspaces', 'noframes'), { recursive: true });
|
||||
const db = new MindDB(path.join(tmpDir, 'workspaces', 'noframes', 'workspace.mind'));
|
||||
db.close();
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'noframes',
|
||||
wsManager: makeManager(new Map([['noframes', { id: 'noframes', name: 'No Frames' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns state with decisions from memory frames', () => {
|
||||
setupWorkspace('ws1', {
|
||||
frames: [
|
||||
{ content: 'Decision: Use SQLite for local storage', importance: 'critical' },
|
||||
{ content: 'Working on the authentication module', importance: 'normal' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws1',
|
||||
wsManager: makeManager(new Map([['ws1', { id: 'ws1', name: 'Project Alpha' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.recentDecisions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result!.recentDecisions[0].content).toContain('SQLite');
|
||||
expect(result!.recentDecisions[0].source).toBe('memory');
|
||||
});
|
||||
|
||||
it('extracts pending tasks from sessions', () => {
|
||||
setupWorkspace('ws2', {
|
||||
frames: [{ content: 'Project context established', importance: 'normal' }],
|
||||
sessions: [{
|
||||
title: 'Planning',
|
||||
messages: [
|
||||
{ role: 'user', content: 'We need to implement the rate limiting middleware for the API' },
|
||||
{ role: 'assistant', content: 'I will add that to the plan.' },
|
||||
{ role: 'user', content: 'Also, we should add comprehensive error handling to the service layer' },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws2',
|
||||
wsManager: makeManager(new Map([['ws2', { id: 'ws2', name: 'API Project' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// pending items come from TASK_PATTERNS in extractProgressItems
|
||||
// "need to" triggers task pattern
|
||||
expect(result!.pending.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('extracts open questions from sessions', () => {
|
||||
setupWorkspace('ws3', {
|
||||
frames: [{ content: 'Working on infrastructure', importance: 'normal' }],
|
||||
sessions: [{
|
||||
title: 'Infra discussion',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Should we use containers or bare metal for the deployment?' },
|
||||
{ role: 'assistant', content: 'Both have tradeoffs.' },
|
||||
{ role: 'user', content: 'We still need to decide on the hosting provider strategy.' },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws3',
|
||||
wsManager: makeManager(new Map([['ws3', { id: 'ws3', name: 'Infra' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.openQuestions.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('classifies fresh and stale threads', () => {
|
||||
const tenDays = 10 * 24 * 60 * 60 * 1000;
|
||||
setupWorkspace('ws4', {
|
||||
frames: [{ content: 'Multi-thread workspace', importance: 'normal' }],
|
||||
sessions: [
|
||||
{
|
||||
title: 'Fresh thread',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Working on fresh stuff right now.' },
|
||||
{ role: 'assistant', content: 'On it.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Old thread',
|
||||
messages: [
|
||||
{ role: 'user', content: 'This was from ten days ago.' },
|
||||
{ role: 'assistant', content: 'Noted.' },
|
||||
],
|
||||
ageMs: tenDays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws4',
|
||||
wsManager: makeManager(new Map([['ws4', { id: 'ws4', name: 'Multi' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Fresh session should appear in active
|
||||
expect(result!.active.some(a => a.content === 'Fresh thread')).toBe(true);
|
||||
// Stale session should appear in stale
|
||||
expect(result!.stale.some(s => s.content === 'Old thread')).toBe(true);
|
||||
});
|
||||
|
||||
it('derives nextActions from blockers and pending items', () => {
|
||||
setupWorkspace('ws5', {
|
||||
frames: [{ content: 'Active project', importance: 'normal' }],
|
||||
sessions: [{
|
||||
title: 'Work session',
|
||||
messages: [
|
||||
{ role: 'user', content: 'We are blocked by the missing SSL certificate for the staging server' },
|
||||
{ role: 'assistant', content: 'That is a critical blocker.' },
|
||||
{ role: 'user', content: 'We need to update the CI pipeline configuration as well' },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws5',
|
||||
wsManager: makeManager(new Map([['ws5', { id: 'ws5', name: 'Blocked' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.nextActions.length).toBeGreaterThanOrEqual(1);
|
||||
// Blockers should appear first in nextActions
|
||||
if (result!.blocked.length > 0) {
|
||||
expect(result!.nextActions[0]).toContain('Resolve:');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns all expected fields', () => {
|
||||
setupWorkspace('ws6', {
|
||||
frames: [
|
||||
{ content: 'Decision: Use React for frontend', importance: 'critical' },
|
||||
{ content: 'Project initialized', importance: 'normal' },
|
||||
],
|
||||
sessions: [{
|
||||
title: 'Setup session',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Let us set up the project structure.' },
|
||||
{ role: 'assistant', content: 'I will create the scaffold.' },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
const result = buildWorkspaceState({
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws6',
|
||||
wsManager: makeManager(new Map([['ws6', { id: 'ws6', name: 'Full' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveProperty('active');
|
||||
expect(result).toHaveProperty('openQuestions');
|
||||
expect(result).toHaveProperty('pending');
|
||||
expect(result).toHaveProperty('blocked');
|
||||
expect(result).toHaveProperty('completed');
|
||||
expect(result).toHaveProperty('stale');
|
||||
expect(result).toHaveProperty('recentDecisions');
|
||||
expect(result).toHaveProperty('nextActions');
|
||||
expect(Array.isArray(result!.active)).toBe(true);
|
||||
expect(Array.isArray(result!.nextActions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatWorkspaceStatePrompt ────────────────────────────────
|
||||
|
||||
describe('formatWorkspaceStatePrompt', () => {
|
||||
it('formats empty state with just the header', () => {
|
||||
const state: WorkspaceState = {
|
||||
active: [],
|
||||
openQuestions: [],
|
||||
pending: [],
|
||||
blocked: [],
|
||||
completed: [],
|
||||
stale: [],
|
||||
recentDecisions: [],
|
||||
nextActions: [],
|
||||
};
|
||||
const result = formatWorkspaceStatePrompt(state, 'Test Workspace');
|
||||
expect(result).toContain('# Workspace Now — Test Workspace');
|
||||
expect(result).not.toContain('## Active');
|
||||
});
|
||||
|
||||
it('includes Active section when there are active items', () => {
|
||||
const state: WorkspaceState = {
|
||||
active: [{
|
||||
content: 'Working on auth',
|
||||
freshness: 'fresh',
|
||||
source: 'session',
|
||||
dateLastTouched: '2026-03-14',
|
||||
}],
|
||||
openQuestions: [],
|
||||
pending: [],
|
||||
blocked: [],
|
||||
completed: [],
|
||||
stale: [],
|
||||
recentDecisions: [],
|
||||
nextActions: [],
|
||||
};
|
||||
const result = formatWorkspaceStatePrompt(state, 'Auth Project');
|
||||
expect(result).toContain('## Active');
|
||||
expect(result).toContain('Working on auth');
|
||||
});
|
||||
|
||||
it('annotates aging items', () => {
|
||||
const state: WorkspaceState = {
|
||||
active: [{
|
||||
content: 'Database migration',
|
||||
freshness: 'aging',
|
||||
source: 'session',
|
||||
dateLastTouched: '2026-03-10',
|
||||
}],
|
||||
openQuestions: [],
|
||||
pending: [],
|
||||
blocked: [],
|
||||
completed: [],
|
||||
stale: [],
|
||||
recentDecisions: [],
|
||||
nextActions: [],
|
||||
};
|
||||
const result = formatWorkspaceStatePrompt(state, 'DB');
|
||||
expect(result).toContain('(aging)');
|
||||
});
|
||||
|
||||
it('includes all populated sections', () => {
|
||||
const state: WorkspaceState = {
|
||||
active: [{ content: 'Thread A', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
|
||||
openQuestions: [{ content: 'Which DB?', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
|
||||
pending: [{ content: 'Add tests', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
|
||||
blocked: [{ content: 'Missing API key', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
|
||||
completed: [{ content: 'Setup done', freshness: 'fresh', source: 'session', dateLastTouched: '2026-03-14' }],
|
||||
stale: [{ content: 'Old thread', freshness: 'stale', source: 'session', dateLastTouched: '2026-03-01' }],
|
||||
recentDecisions: [{ content: 'Use React', freshness: 'fresh', source: 'memory', dateLastTouched: '2026-03-14' }],
|
||||
nextActions: ['Resolve: Missing API key', 'Add tests'],
|
||||
};
|
||||
const result = formatWorkspaceStatePrompt(state, 'Full');
|
||||
expect(result).toContain('## Active');
|
||||
expect(result).toContain('## Open Questions');
|
||||
expect(result).toContain('## Blocked');
|
||||
expect(result).toContain('## Pending');
|
||||
expect(result).toContain('## Completed');
|
||||
expect(result).toContain('## Needs Attention (stale)');
|
||||
expect(result).toContain('## Recent Decisions');
|
||||
expect(result).toContain('## Likely Next Actions');
|
||||
});
|
||||
|
||||
it('marks stale items as not touched recently', () => {
|
||||
const state: WorkspaceState = {
|
||||
active: [],
|
||||
openQuestions: [],
|
||||
pending: [],
|
||||
blocked: [],
|
||||
completed: [],
|
||||
stale: [{ content: 'Forgotten thread', freshness: 'stale', source: 'session', dateLastTouched: '2026-02-01' }],
|
||||
recentDecisions: [],
|
||||
nextActions: [],
|
||||
};
|
||||
const result = formatWorkspaceStatePrompt(state, 'Stale');
|
||||
expect(result).toContain('not touched recently');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user