moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,497 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
extractOpenQuestions,
classifyThreads,
extractSessionOutcome,
persistSessionOutcome,
type OpenQuestion,
type ThreadInfo,
type SessionOutcome,
} from '../../src/local/routes/sessions.js';
describe('extractOpenQuestions', () => {
let tmpDir: string;
let sessionsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-oq-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeSession(id: string, messages: Array<{ role: string; content: string }>) {
const meta = JSON.stringify({ type: 'meta', title: `Session ${id}`, created: new Date().toISOString() });
const lines = [meta, ...messages.map(m => JSON.stringify(m))];
fs.writeFileSync(path.join(sessionsDir, `${id}.jsonl`), lines.join('\n') + '\n');
}
it('returns empty for non-existent dir', () => {
expect(extractOpenQuestions('/nonexistent/path')).toEqual([]);
});
it('returns empty for sessions with no questions', () => {
writeSession('s1', [
{ role: 'user', content: 'Hello, how are you today?' },
{ role: 'assistant', content: 'I am doing well, thank you.' },
{ role: 'user', content: 'Great, let us continue working.' },
]);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('detects literal question-form open questions', () => {
writeSession('s1', [
{ role: 'user', content: 'What should we use for the database layer in this project?' },
{ role: 'assistant', content: 'There are several options to consider.' },
{ role: 'user', content: 'Should we go with PostgreSQL or SQLite for local storage?' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('database') || q.content.toLowerCase().includes('postgresql'))).toBe(true);
});
it('detects "we still need to decide" pattern', () => {
writeSession('s1', [
{ role: 'user', content: 'We made progress on the API.' },
{ role: 'assistant', content: 'Yes, but we still need to decide on the authentication approach for the API layer.' },
{ role: 'user', content: 'Good point, let us think about it.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('authentication'))).toBe(true);
});
it('detects "not yet clear" pattern', () => {
writeSession('s1', [
{ role: 'assistant', content: 'The deployment strategy is not yet clear whether we should use containers or bare metal.' },
{ role: 'user', content: 'Right, we need to figure that out.' },
{ role: 'assistant', content: 'Let me research both options.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('container') || q.content.toLowerCase().includes('deployment'))).toBe(true);
});
it('detects "pending decision" and "TBD" patterns', () => {
writeSession('s1', [
{ role: 'user', content: 'The hosting provider is TBD: we are evaluating AWS vs GCP for this.' },
{ role: 'assistant', content: 'There is also a pending decision on the CI pipeline tool selection.' },
{ role: 'user', content: 'Yes, both are important.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
});
it('detects "need to figure out" pattern', () => {
writeSession('s1', [
{ role: 'user', content: 'We need to figure out how to handle rate limiting in the proxy layer.' },
{ role: 'assistant', content: 'That is an important consideration.' },
{ role: 'user', content: 'Agreed.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.some(q => q.content.toLowerCase().includes('rate limiting'))).toBe(true);
});
it('excludes greeting-style questions', () => {
writeSession('s1', [
{ role: 'user', content: 'Hello there!' },
{ role: 'assistant', content: 'Hi! How are you doing today?' },
{ role: 'user', content: 'Good thanks.' },
]);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('deduplicates similar questions across sessions', () => {
writeSession('s1', [
{ role: 'user', content: 'What should we use for the caching layer in this system?' },
{ role: 'assistant', content: 'Good question.' },
{ role: 'user', content: 'Let me think about it.' },
]);
writeSession('s2', [
{ role: 'user', content: 'What should we use for the caching layer in this system?' },
{ role: 'assistant', content: 'Redis or Memcached are popular choices.' },
{ role: 'user', content: 'Thanks for the info.' },
]);
const result = extractOpenQuestions(sessionsDir);
// Should deduplicate — only one entry for the same question
const cachingQuestions = result.filter(q => q.content.toLowerCase().includes('caching'));
expect(cachingQuestions.length).toBeLessThanOrEqual(1);
});
it('skips sessions with fewer than 3 lines', () => {
const meta = JSON.stringify({ type: 'meta', title: 'Short', created: new Date().toISOString() });
fs.writeFileSync(
path.join(sessionsDir, 'short.jsonl'),
meta + '\n' + JSON.stringify({ role: 'user', content: 'What should we do about the API design?' }) + '\n',
);
expect(extractOpenQuestions(sessionsDir)).toEqual([]);
});
it('includes sessionId and date in results', () => {
writeSession('test-session-42', [
{ role: 'user', content: 'Should we use WebSocket or SSE for the streaming transport?' },
{ role: 'assistant', content: 'Both have tradeoffs.' },
{ role: 'user', content: 'Let me think.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result[0].sessionId).toBe('test-session-42');
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('detects "remains open" and "left open" patterns', () => {
writeSession('s1', [
{ role: 'assistant', content: 'The question of whether to use monorepo or polyrepo remains open for the new project.' },
{ role: 'user', content: 'Yes it does.' },
{ role: 'assistant', content: 'We should revisit this.' },
]);
const result = extractOpenQuestions(sessionsDir);
expect(result.length).toBeGreaterThanOrEqual(1);
});
});
describe('classifyThreads', () => {
let tmpDir: string;
let sessionsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-thread-test-'));
sessionsDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeSession(
id: string,
messages: Array<{ role: string; content: string }>,
opts?: { title?: string; ageMs?: number },
) {
const created = new Date(Date.now() - (opts?.ageMs ?? 0)).toISOString();
const meta = JSON.stringify({ type: 'meta', title: opts?.title ?? null, created });
const lines = [meta, ...messages.map(m => JSON.stringify(m))];
const filePath = path.join(sessionsDir, `${id}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
// Backdate the file's mtime if ageMs is provided
if (opts?.ageMs) {
const pastTime = new Date(Date.now() - opts.ageMs);
fs.utimesSync(filePath, pastTime, pastTime);
}
}
it('returns empty for non-existent dir', () => {
expect(classifyThreads('/nonexistent/path')).toEqual([]);
});
it('returns empty for empty dir', () => {
expect(classifyThreads(sessionsDir)).toEqual([]);
});
it('classifies a recent session as fresh', () => {
writeSession('s1', [
{ role: 'user', content: 'Let us work on the dashboard.' },
{ role: 'assistant', content: 'Sure, I will help with that.' },
], { title: 'Dashboard work' });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].title).toBe('Dashboard work');
expect(threads[0].freshness).toBe('fresh');
expect(threads[0].messageCount).toBe(2);
expect(threads[0].sessionId).toBe('s1');
});
it('classifies 3-day-old session as aging', () => {
const threeDays = 3 * 24 * 60 * 60 * 1000;
writeSession('s1', [
{ role: 'user', content: 'Working on auth.' },
{ role: 'assistant', content: 'Let me help.' },
], { title: 'Auth work', ageMs: threeDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].freshness).toBe('aging');
});
it('classifies 10-day-old session as stale', () => {
const tenDays = 10 * 24 * 60 * 60 * 1000;
writeSession('s1', [
{ role: 'user', content: 'Planning the architecture.' },
{ role: 'assistant', content: 'Here are my thoughts.' },
], { title: 'Architecture planning', ageMs: tenDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].freshness).toBe('stale');
// Stale means "not recently touched" — it says nothing about importance
expect(threads[0].title).toBe('Architecture planning');
});
it('skips sessions with fewer than 2 messages', () => {
const meta = JSON.stringify({ type: 'meta', title: 'Short', created: new Date().toISOString() });
fs.writeFileSync(
path.join(sessionsDir, 'short.jsonl'),
meta + '\n' + JSON.stringify({ role: 'user', content: 'Hi' }) + '\n',
);
expect(classifyThreads(sessionsDir)).toEqual([]);
});
it('falls back to first user message for title', () => {
writeSession('s1', [
{ role: 'user', content: 'Implementing the notification system for the app' },
{ role: 'assistant', content: 'Great, let me help with that.' },
]);
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(1);
expect(threads[0].title).toContain('notification system');
});
it('sorts threads by recency (most recent first)', () => {
const oneDay = 1 * 24 * 60 * 60 * 1000;
const fiveDays = 5 * 24 * 60 * 60 * 1000;
writeSession('old', [
{ role: 'user', content: 'Old thread content here.' },
{ role: 'assistant', content: 'Old response.' },
], { title: 'Old thread', ageMs: fiveDays });
writeSession('recent', [
{ role: 'user', content: 'Recent thread content here.' },
{ role: 'assistant', content: 'Recent response.' },
], { title: 'Recent thread', ageMs: oneDay });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(2);
expect(threads[0].title).toBe('Recent thread');
expect(threads[1].title).toBe('Old thread');
});
it('respects maxSessions limit', () => {
for (let i = 0; i < 5; i++) {
writeSession(`s${i}`, [
{ role: 'user', content: `Thread ${i} content here.` },
{ role: 'assistant', content: `Response ${i}.` },
], { title: `Thread ${i}` });
}
const threads = classifyThreads(sessionsDir, 3);
expect(threads).toHaveLength(3);
});
it('includes lastActive as ISO string', () => {
writeSession('s1', [
{ role: 'user', content: 'Working on tests.' },
{ role: 'assistant', content: 'Let me help.' },
], { title: 'Test work' });
const threads = classifyThreads(sessionsDir);
expect(threads[0].lastActive).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('truncates long titles to 80 chars', () => {
const longContent = 'A'.repeat(200);
writeSession('s1', [
{ role: 'user', content: longContent },
{ role: 'assistant', content: 'Noted.' },
]);
const threads = classifyThreads(sessionsDir);
expect(threads[0].title.length).toBeLessThanOrEqual(80);
});
it('handles mixed freshness levels across threads', () => {
const oneHour = 60 * 60 * 1000;
const fourDays = 4 * 24 * 60 * 60 * 1000;
const fifteenDays = 15 * 24 * 60 * 60 * 1000;
writeSession('fresh', [
{ role: 'user', content: 'Fresh work happening now.' },
{ role: 'assistant', content: 'On it.' },
], { title: 'Fresh', ageMs: oneHour });
writeSession('aging', [
{ role: 'user', content: 'Aging work from a few days ago.' },
{ role: 'assistant', content: 'Noted.' },
], { title: 'Aging', ageMs: fourDays });
writeSession('stale', [
{ role: 'user', content: 'Stale work from two weeks ago.' },
{ role: 'assistant', content: 'Archived.' },
], { title: 'Stale', ageMs: fifteenDays });
const threads = classifyThreads(sessionsDir);
expect(threads).toHaveLength(3);
const freshThread = threads.find(t => t.title === 'Fresh');
const agingThread = threads.find(t => t.title === 'Aging');
const staleThread = threads.find(t => t.title === 'Stale');
expect(freshThread?.freshness).toBe('fresh');
expect(agingThread?.freshness).toBe('aging');
expect(staleThread?.freshness).toBe('stale');
});
});
describe('extractSessionOutcome', () => {
function makeLines(messages: Array<{ role: string; content: string }>): string[] {
return messages.map(m => JSON.stringify(m));
}
it('returns null for fewer than 4 message lines', () => {
const lines = makeLines([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
]);
expect(extractSessionOutcome(lines)).toBeNull();
});
it('extracts whatChanged from completion signals', () => {
const lines = makeLines([
{ role: 'user', content: 'Can you fix the login bug?' },
{ role: 'assistant', content: 'Looking into it now.' },
{ role: 'assistant', content: 'I found the issue in the auth handler.' },
{ role: 'assistant', content: 'Fixed the authentication token validation logic.' },
{ role: 'user', content: 'Great, thanks!' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.toLowerCase()).toContain('authentication');
});
it('extracts openItems from unresolved signals', () => {
const lines = makeLines([
{ role: 'user', content: 'Let us work on the API endpoints.' },
{ role: 'assistant', content: 'I completed the GET endpoints.' },
{ role: 'assistant', content: 'We still need to implement the rate limiting middleware.' },
{ role: 'user', content: 'OK, we can do that next time.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.openItems).not.toBeNull();
expect(outcome!.openItems!.toLowerCase()).toContain('rate limiting');
});
it('extracts nextStep from forward-looking signals', () => {
const lines = makeLines([
{ role: 'user', content: 'How is the migration going?' },
{ role: 'assistant', content: 'I finished migrating the user table.' },
{ role: 'assistant', content: 'Next step: migrate the permissions table and update foreign keys.' },
{ role: 'user', content: 'Sounds good.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.nextStep).not.toBeNull();
expect(outcome!.nextStep!.toLowerCase()).toContain('permissions');
});
it('falls back to first substantive user message for whatChanged', () => {
const lines = makeLines([
{ role: 'user', content: 'Can you review the database schema design for the new feature?' },
{ role: 'assistant', content: 'Sure, let me take a look.' },
{ role: 'assistant', content: 'The schema looks reasonable.' },
{ role: 'user', content: 'OK thanks.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.toLowerCase()).toContain('database schema');
});
it('returns null for all-greeting sessions', () => {
const lines = makeLines([
{ role: 'user', content: 'Hi' },
{ role: 'assistant', content: 'Hello!' },
{ role: 'user', content: 'Thanks' },
{ role: 'assistant', content: 'You are welcome.' },
]);
expect(extractSessionOutcome(lines)).toBeNull();
});
it('handles outcome with all three fields populated', () => {
const lines = makeLines([
{ role: 'user', content: 'Let us finish the notification system.' },
{ role: 'assistant', content: 'I implemented the email notification sender module.' },
{ role: 'assistant', content: 'We still need to add SMS notification support.' },
{ role: 'assistant', content: 'Next step: integrate the Twilio API for SMS delivery.' },
{ role: 'user', content: 'Perfect, let us do that tomorrow.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged).toBeTruthy();
expect(outcome!.openItems).not.toBeNull();
expect(outcome!.nextStep).not.toBeNull();
});
it('truncates long content', () => {
const longTask = 'A'.repeat(200);
const lines = makeLines([
{ role: 'user', content: `Can you work on the feature?` },
{ role: 'assistant', content: `I completed ${longTask} successfully.` },
{ role: 'user', content: 'Great job.' },
{ role: 'assistant', content: 'All done.' },
]);
const outcome = extractSessionOutcome(lines);
expect(outcome).not.toBeNull();
expect(outcome!.whatChanged.length).toBeLessThanOrEqual(120);
});
});
describe('persistSessionOutcome', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-outcome-test-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
it('writes outcome to meta line', () => {
const filePath = path.join(tmpDir, 'test.jsonl');
const meta = { type: 'meta', title: 'Test', created: new Date().toISOString() };
const msg = { role: 'user', content: 'Hello' };
fs.writeFileSync(filePath, JSON.stringify(meta) + '\n' + JSON.stringify(msg) + '\n');
const outcome: SessionOutcome = {
whatChanged: 'Fixed the auth bug',
openItems: 'Rate limiting still needed',
nextStep: 'Add rate limiter',
};
persistSessionOutcome(filePath, outcome);
// Read back and verify
const content = fs.readFileSync(filePath, 'utf-8').trim();
const lines = content.split('\n');
const updatedMeta = JSON.parse(lines[0]);
expect(updatedMeta.outcome).toEqual(outcome);
// Original message should still be there
expect(lines).toHaveLength(2);
const originalMsg = JSON.parse(lines[1]);
expect(originalMsg.content).toBe('Hello');
});
it('preserves existing meta fields', () => {
const filePath = path.join(tmpDir, 'test.jsonl');
const meta = { type: 'meta', title: 'My Session', summary: 'A good session', created: '2026-03-14T00:00:00Z' };
fs.writeFileSync(filePath, JSON.stringify(meta) + '\n');
persistSessionOutcome(filePath, { whatChanged: 'Updated config', openItems: null, nextStep: null });
const content = fs.readFileSync(filePath, 'utf-8').trim();
const updatedMeta = JSON.parse(content.split('\n')[0]);
expect(updatedMeta.title).toBe('My Session');
expect(updatedMeta.summary).toBe('A good session');
expect(updatedMeta.outcome.whatChanged).toBe('Updated config');
});
});