285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/**
|
|
* Chat Persistence — I/O Tests
|
|
*
|
|
* Covers:
|
|
* chat-persistence.ts: persistMessage, loadSessionMessages
|
|
*
|
|
* Uses real temp directories to exercise file system behavior.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
persistMessage,
|
|
loadSessionMessages,
|
|
stripTrailingFailedPair,
|
|
} from '../../src/local/routes/chat-persistence.js';
|
|
import { GENERATION_FAILED_PREFIX } from '@waggle/shared';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
let tempDirs: string[] = [];
|
|
|
|
function makeTempDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-persist-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs) {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
tempDirs = [];
|
|
});
|
|
|
|
// ─── persistMessage ─────────────────────────────────────────────────
|
|
|
|
describe('persistMessage', () => {
|
|
it('creates sessions directory and .jsonl file when they do not exist', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'hello' });
|
|
|
|
const filePath = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-1.jsonl');
|
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
});
|
|
|
|
it('writes a meta line as the first line of a new session file', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'hi' });
|
|
|
|
const filePath = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-1.jsonl');
|
|
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n');
|
|
|
|
const meta = JSON.parse(lines[0]);
|
|
expect(meta.type).toBe('meta');
|
|
expect(meta).toHaveProperty('created');
|
|
expect(meta.title).toBeNull();
|
|
});
|
|
|
|
it('appends the message as a JSON line after the meta line', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'test message' });
|
|
|
|
const filePath = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-1.jsonl');
|
|
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n');
|
|
|
|
expect(lines).toHaveLength(2);
|
|
const msg = JSON.parse(lines[1]);
|
|
expect(msg.role).toBe('user');
|
|
expect(msg.content).toBe('test message');
|
|
expect(msg).toHaveProperty('timestamp');
|
|
});
|
|
|
|
it('appends multiple messages to the same session file', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'first' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'assistant', content: 'second' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'third' });
|
|
|
|
const filePath = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-1.jsonl');
|
|
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n');
|
|
|
|
// 1 meta + 3 messages
|
|
expect(lines).toHaveLength(4);
|
|
});
|
|
|
|
it('creates separate files for different session IDs', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-a', { role: 'user', content: 'a' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-b', { role: 'user', content: 'b' });
|
|
|
|
const fileA = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-a.jsonl');
|
|
const fileB = path.join(dataDir, 'workspaces', 'ws-1', 'sessions', 'sess-b.jsonl');
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
expect(fs.existsSync(fileB)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── loadSessionMessages ────────────────────────────────────────────
|
|
|
|
describe('loadSessionMessages', () => {
|
|
it('returns an empty array when the session file does not exist', () => {
|
|
const dataDir = makeTempDir();
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'nonexistent');
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns an empty array for an empty file', () => {
|
|
const dataDir = makeTempDir();
|
|
const sessDir = path.join(dataDir, 'workspaces', 'ws-1', 'sessions');
|
|
fs.mkdirSync(sessDir, { recursive: true });
|
|
fs.writeFileSync(path.join(sessDir, 'sess-1.jsonl'), '', 'utf-8');
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('skips meta lines and returns only chat messages', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'hello' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'assistant', content: 'hi there' });
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(result).toEqual([
|
|
{ role: 'user', content: 'hello' },
|
|
{ role: 'assistant', content: 'hi there' },
|
|
]);
|
|
});
|
|
|
|
it('strips timestamp from returned messages (only role + content)', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'test' });
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(result).toHaveLength(1);
|
|
expect(Object.keys(result[0])).toEqual(['role', 'content']);
|
|
});
|
|
|
|
it('skips malformed JSON lines without throwing', () => {
|
|
const dataDir = makeTempDir();
|
|
const sessDir = path.join(dataDir, 'workspaces', 'ws-1', 'sessions');
|
|
fs.mkdirSync(sessDir, { recursive: true });
|
|
|
|
const lines = [
|
|
JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString() }),
|
|
'{ INVALID JSON',
|
|
JSON.stringify({ role: 'user', content: 'valid message', timestamp: new Date().toISOString() }),
|
|
'not json at all',
|
|
JSON.stringify({ role: 'assistant', content: 'another valid', timestamp: new Date().toISOString() }),
|
|
];
|
|
fs.writeFileSync(path.join(sessDir, 'sess-1.jsonl'), lines.join('\n') + '\n', 'utf-8');
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(result).toEqual([
|
|
{ role: 'user', content: 'valid message' },
|
|
{ role: 'assistant', content: 'another valid' },
|
|
]);
|
|
});
|
|
|
|
it('skips lines that are valid JSON but lack role or content fields', () => {
|
|
const dataDir = makeTempDir();
|
|
const sessDir = path.join(dataDir, 'workspaces', 'ws-1', 'sessions');
|
|
fs.mkdirSync(sessDir, { recursive: true });
|
|
|
|
const lines = [
|
|
JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString() }),
|
|
JSON.stringify({ role: 'user' }), // missing content
|
|
JSON.stringify({ content: 'orphan' }), // missing role
|
|
JSON.stringify({ role: 'user', content: 'complete', timestamp: new Date().toISOString() }),
|
|
];
|
|
fs.writeFileSync(path.join(sessDir, 'sess-1.jsonl'), lines.join('\n') + '\n', 'utf-8');
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
// The line missing role is skipped because parsed.role is falsy
|
|
// The line missing content: content is undefined, and the check is `parsed.content !== undefined`
|
|
// so { role: 'user' } has content === undefined → skipped
|
|
expect(result).toEqual([
|
|
{ role: 'user', content: 'complete' },
|
|
]);
|
|
});
|
|
|
|
it('skips blank lines in the file', () => {
|
|
const dataDir = makeTempDir();
|
|
const sessDir = path.join(dataDir, 'workspaces', 'ws-1', 'sessions');
|
|
fs.mkdirSync(sessDir, { recursive: true });
|
|
|
|
const content = [
|
|
JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString() }),
|
|
'',
|
|
' ',
|
|
JSON.stringify({ role: 'user', content: 'msg', timestamp: new Date().toISOString() }),
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(sessDir, 'sess-1.jsonl'), content, 'utf-8');
|
|
|
|
const result = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(result).toEqual([{ role: 'user', content: 'msg' }]);
|
|
});
|
|
|
|
// ── Round-trip ────────────────────────────────────────────────────
|
|
|
|
it('round-trips: persist then load returns the same messages in order', () => {
|
|
const dataDir = makeTempDir();
|
|
const messages = [
|
|
{ role: 'user', content: 'What is 2+2?' },
|
|
{ role: 'assistant', content: 'The answer is 4.' },
|
|
{ role: 'user', content: 'Thanks!' },
|
|
{ role: 'assistant', content: 'You are welcome.' },
|
|
];
|
|
|
|
for (const msg of messages) {
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', msg);
|
|
}
|
|
|
|
const loaded = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(loaded).toEqual(messages);
|
|
});
|
|
|
|
it('handles messages with empty string content', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: '' });
|
|
|
|
const loaded = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
// content '' is !== undefined → should be included
|
|
expect(loaded).toEqual([{ role: 'user', content: '' }]);
|
|
});
|
|
|
|
it('handles messages with special characters and newlines in content', () => {
|
|
const dataDir = makeTempDir();
|
|
const content = 'line1\nline2\ttab "quotes" {braces}';
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content });
|
|
|
|
const loaded = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(loaded).toEqual([{ role: 'user', content }]);
|
|
});
|
|
});
|
|
|
|
// ─── stripTrailingFailedPair ────────────────────────────────────────
|
|
|
|
describe('stripTrailingFailedPair', () => {
|
|
it('strips a trailing failed user+assistant pair', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'ok turn' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'assistant', content: 'sure' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'reproduce this' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'assistant', content: `${GENERATION_FAILED_PREFIX}boom` });
|
|
|
|
const stripped = stripTrailingFailedPair(dataDir, 'ws-1', 'sess-1');
|
|
expect(stripped).toBe(true);
|
|
|
|
// Only the failed pair is dropped; the earlier successful pair survives.
|
|
const loaded = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(loaded).toEqual([
|
|
{ role: 'user', content: 'ok turn' },
|
|
{ role: 'assistant', content: 'sure' },
|
|
]);
|
|
});
|
|
|
|
it('is a no-op on a normal (non-failed) tail', () => {
|
|
const dataDir = makeTempDir();
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'user', content: 'hi' });
|
|
persistMessage(dataDir, 'ws-1', 'sess-1', { role: 'assistant', content: 'hello' });
|
|
|
|
const stripped = stripTrailingFailedPair(dataDir, 'ws-1', 'sess-1');
|
|
expect(stripped).toBe(false);
|
|
|
|
const loaded = loadSessionMessages(dataDir, 'ws-1', 'sess-1');
|
|
expect(loaded).toEqual([
|
|
{ role: 'user', content: 'hi' },
|
|
{ role: 'assistant', content: 'hello' },
|
|
]);
|
|
});
|
|
|
|
it('returns false when the session file does not exist', () => {
|
|
const dataDir = makeTempDir();
|
|
expect(stripTrailingFailedPair(dataDir, 'ws-1', 'missing')).toBe(false);
|
|
});
|
|
});
|