883 lines
27 KiB
TypeScript
883 lines
27 KiB
TypeScript
|
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||
|
|
import { tmpdir } from 'node:os';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
|
||
|
|
import { FileSessionTreeProvider } from '../src/modules/netaclaw/session-tree/file_provider.js';
|
||
|
|
import { SessionTreeProviderError } from '../src/modules/netaclaw/session-tree/provider.js';
|
||
|
|
import { runSessionTreeProviderContract } from './session_tree_provider_contract.test.js';
|
||
|
|
|
||
|
|
describe('FileSessionTreeProvider', () => {
|
||
|
|
let tempRoot: string;
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
tempRoot = await mkdtemp(path.join(tmpdir(), 'neta-session-tree-'));
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
if (tempRoot) {
|
||
|
|
await rm(tempRoot, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
runSessionTreeProviderContract('file', () => new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
}));
|
||
|
|
|
||
|
|
it('writes one Pi-first JSONL file per session with header on the first line and entries after it', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_file_format',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_leaf',
|
||
|
|
parentId: 'entry_root',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'custom_message',
|
||
|
|
customType: 'note',
|
||
|
|
content: {
|
||
|
|
text: 'leaf',
|
||
|
|
},
|
||
|
|
display: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const files = await readdir(tempRoot);
|
||
|
|
expect(files).toHaveLength(1);
|
||
|
|
expect(files[0]).toMatch(/\.jsonl$/);
|
||
|
|
expect(files).not.toContain('session.json');
|
||
|
|
expect(files).not.toContain('entries.jsonl');
|
||
|
|
|
||
|
|
const sessionFile = session.sessionFile ?? path.join(tempRoot, files[0]!);
|
||
|
|
const lines = (await readFile(sessionFile, 'utf8')).trim().split('\n');
|
||
|
|
|
||
|
|
expect(lines).toHaveLength(3);
|
||
|
|
|
||
|
|
const header = JSON.parse(lines[0]!);
|
||
|
|
const root = JSON.parse(lines[1]!);
|
||
|
|
const leaf = JSON.parse(lines[2]!);
|
||
|
|
|
||
|
|
expect(header).toMatchObject({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_file_format',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
expect(header.entryId).toBeUndefined();
|
||
|
|
expect(header.parentEntryId).toBeUndefined();
|
||
|
|
expect(root).toMatchObject({
|
||
|
|
id: 'entry_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
});
|
||
|
|
expect(leaf).toMatchObject({
|
||
|
|
id: 'entry_leaf',
|
||
|
|
parentId: 'entry_root',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'custom_message',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reloads session, entries, and persisted active leaf from the JSONL file', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_reload',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
const root = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_reload_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const branchA = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_reload_a',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'branch-a',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_reload_b',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:03:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'branch-b',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.switchLeaf(session.sessionId, branchA.id);
|
||
|
|
|
||
|
|
const reloadedProvider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:10:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const reloadedSession = await reloadedProvider.getSession(session.sessionId);
|
||
|
|
const reloadedEntries = await reloadedProvider.listEntries(session.sessionId);
|
||
|
|
const activePath = await reloadedProvider.getActivePath(session.sessionId);
|
||
|
|
|
||
|
|
expect(reloadedSession).toMatchObject({
|
||
|
|
sessionId: session.sessionId,
|
||
|
|
rootEntryId: root.id,
|
||
|
|
leafEntryId: branchA.id,
|
||
|
|
status: 'active',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
expect(reloadedEntries.map(entry => entry.id)).toEqual([
|
||
|
|
root.id,
|
||
|
|
branchA.id,
|
||
|
|
'entry_reload_b',
|
||
|
|
]);
|
||
|
|
expect(activePath.map(entry => entry.id)).toEqual([
|
||
|
|
root.id,
|
||
|
|
branchA.id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const lines = (await readFile(reloadedSession?.sessionFile as string, 'utf8')).trim().split('\n');
|
||
|
|
const header = JSON.parse(lines[0]!);
|
||
|
|
expect(header.leafId).toBe(branchA.id);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rewrite operations preserve existing entry line content while updating the header', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_append_friendly',
|
||
|
|
provider: 'file',
|
||
|
|
});
|
||
|
|
|
||
|
|
const root = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_append_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const beforeSecondAppend = await readFile(session.sessionFile as string, 'utf8');
|
||
|
|
|
||
|
|
const leaf = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_append_leaf',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'leaf',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const afterSecondAppend = await readFile(session.sessionFile as string, 'utf8');
|
||
|
|
expect(afterSecondAppend.trim().split('\n')[1]).toBe(beforeSecondAppend.trim().split('\n')[1]);
|
||
|
|
|
||
|
|
await provider.switchLeaf(session.sessionId, root.id);
|
||
|
|
|
||
|
|
const afterSwitchLeaf = await readFile(session.sessionFile as string, 'utf8');
|
||
|
|
const appendLines = afterSecondAppend.trim().split('\n').slice(1);
|
||
|
|
const switchLines = afterSwitchLeaf.trim().split('\n').slice(1);
|
||
|
|
|
||
|
|
expect(switchLines).toEqual(appendLines);
|
||
|
|
expect(JSON.parse(afterSwitchLeaf.trim().split('\n')[0]!).leafId).toBe(root.id);
|
||
|
|
expect(leaf.id).toBe('entry_append_leaf');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('persists the active leaf after appendEntry even when a previous switchLeaf wrote header.leafId', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_leaf_persist_after_append',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
const root = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_leaf_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const branchA = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_leaf_branch_a',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'branch-a',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_leaf_branch_b',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:03:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'branch-b',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.switchLeaf(session.sessionId, branchA.id);
|
||
|
|
|
||
|
|
const providerAfterSwitch = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:10:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const branchAChild = await providerAfterSwitch.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_leaf_branch_a_child',
|
||
|
|
parentId: branchA.id,
|
||
|
|
timestamp: '2026-04-19T00:04:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'branch-a-child',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const reloadedProvider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:11:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const reloadedSession = await reloadedProvider.getSession(session.sessionId);
|
||
|
|
const activePath = await reloadedProvider.getActivePath(session.sessionId);
|
||
|
|
const snapshot = await reloadedProvider.getSnapshot(session.sessionId);
|
||
|
|
|
||
|
|
expect(reloadedSession?.leafEntryId).toBe(branchAChild.id);
|
||
|
|
expect(activePath.map(entry => entry.id)).toEqual([
|
||
|
|
root.id,
|
||
|
|
branchA.id,
|
||
|
|
branchAChild.id,
|
||
|
|
]);
|
||
|
|
expect(snapshot.activePath.map(entry => entry.id)).toEqual([
|
||
|
|
root.id,
|
||
|
|
branchA.id,
|
||
|
|
branchAChild.id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const lines = (await readFile(reloadedSession?.sessionFile as string, 'utf8')).trim().split('\n');
|
||
|
|
const header = JSON.parse(lines[0]!);
|
||
|
|
expect(header.leafId).toBe(branchAChild.id);
|
||
|
|
expect(header.updatedAt).toBe('2026-04-19T00:10:00.000Z');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('advances the leaf on appendEntry after switchLeaf(null) instead of preserving a null leaf', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_null_leaf_append',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
const root = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_null_leaf_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.switchLeaf(session.sessionId, null);
|
||
|
|
|
||
|
|
const child = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_null_leaf_child',
|
||
|
|
parentId: root.id,
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'child',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const reloadedProvider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:05:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const reloadedSession = await reloadedProvider.getSession(session.sessionId);
|
||
|
|
const activePath = await reloadedProvider.getActivePath(session.sessionId);
|
||
|
|
const snapshot = await reloadedProvider.getSnapshot(session.sessionId);
|
||
|
|
|
||
|
|
expect(reloadedSession?.leafEntryId).toBe(child.id);
|
||
|
|
expect(activePath.map(entry => entry.id)).toEqual([root.id, child.id]);
|
||
|
|
expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, child.id]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws SessionTreeProviderError with file path and line context for malformed JSONL files', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const emptyFile = provider.getSessionFilePath('session_empty');
|
||
|
|
await writeFile(emptyFile, '', 'utf8');
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_empty')).rejects.toThrow(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'SessionTreeProviderError',
|
||
|
|
message: expect.stringContaining(`${emptyFile}`),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await expect(provider.getSession('session_empty')).rejects.toThrow(/empty/i);
|
||
|
|
|
||
|
|
const badHeaderJsonFile = provider.getSessionFilePath('session_bad_header_json');
|
||
|
|
await writeFile(badHeaderJsonFile, '{"type":"session"\n', 'utf8');
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_bad_header_json')).rejects.toThrow(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'SessionTreeProviderError',
|
||
|
|
message: expect.stringContaining(`${badHeaderJsonFile}`),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await expect(provider.getSession('session_bad_header_json')).rejects.toThrow(/line 1/i);
|
||
|
|
|
||
|
|
const badEntryJsonFile = provider.getSessionFilePath('session_bad_entry_json');
|
||
|
|
await writeFile(
|
||
|
|
badEntryJsonFile,
|
||
|
|
`${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_bad_entry_json',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n{"id":"entry_broken"\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_bad_entry_json')).rejects.toThrow(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'SessionTreeProviderError',
|
||
|
|
message: expect.stringContaining(`${badEntryJsonFile}`),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await expect(provider.getSession('session_bad_entry_json')).rejects.toThrow(/line 2/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects leading blank lines instead of skipping them before the header', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const leadingBlankFile = provider.getSessionFilePath('session_leading_blank');
|
||
|
|
await writeFile(
|
||
|
|
leadingBlankFile,
|
||
|
|
`\n${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_leading_blank',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_leading_blank')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(provider.getSession('session_leading_blank')).rejects.toThrow(/line 1/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects blank lines between entries while allowing trailing blank lines', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const blankBetweenEntriesFile = provider.getSessionFilePath('session_blank_between_entries');
|
||
|
|
await writeFile(
|
||
|
|
blankBetweenEntriesFile,
|
||
|
|
`${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_blank_between_entries',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n${JSON.stringify({
|
||
|
|
id: 'entry_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
})}\n\n${JSON.stringify({
|
||
|
|
id: 'entry_child',
|
||
|
|
parentId: 'entry_root',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'child',
|
||
|
|
},
|
||
|
|
})}\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_blank_between_entries')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(provider.getSession('session_blank_between_entries')).rejects.toThrow(/line 3/i);
|
||
|
|
|
||
|
|
const trailingBlankFile = provider.getSessionFilePath('session_trailing_blank');
|
||
|
|
await writeFile(
|
||
|
|
trailingBlankFile,
|
||
|
|
`${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_trailing_blank',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n${JSON.stringify({
|
||
|
|
id: 'entry_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
})}\n\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
const session = await provider.getSession('session_trailing_blank');
|
||
|
|
expect(session?.sessionId).toBe('session_trailing_blank');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports malformed entry JSON with the physical line number after blank lines', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const badEntryAfterBlankFile = provider.getSessionFilePath('session_bad_entry_after_blank');
|
||
|
|
await writeFile(
|
||
|
|
badEntryAfterBlankFile,
|
||
|
|
`${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_bad_entry_after_blank',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n${JSON.stringify({
|
||
|
|
id: 'entry_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: 'root',
|
||
|
|
},
|
||
|
|
})}\n\n{"id":"entry_broken"\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(provider.listEntries('session_bad_entry_after_blank')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(provider.listEntries('session_bad_entry_after_blank')).rejects.toThrow(/line 4/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws SessionTreeProviderError when the header is missing required base fields or sessionId mismatches', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const invalidHeaders: Array<{
|
||
|
|
sessionId: string;
|
||
|
|
header: Record<string, unknown>;
|
||
|
|
expected: RegExp;
|
||
|
|
}> = [
|
||
|
|
{
|
||
|
|
sessionId: 'session_missing_type',
|
||
|
|
header: {
|
||
|
|
version: 1,
|
||
|
|
id: 'session_missing_type',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
},
|
||
|
|
expected: /type/i,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
sessionId: 'session_missing_version',
|
||
|
|
header: {
|
||
|
|
type: 'session',
|
||
|
|
id: 'session_missing_version',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
},
|
||
|
|
expected: /version/i,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
sessionId: 'session_missing_id',
|
||
|
|
header: {
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
},
|
||
|
|
expected: /id/i,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
sessionId: 'session_missing_timestamp',
|
||
|
|
header: {
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_missing_timestamp',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
},
|
||
|
|
expected: /timestamp/i,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
sessionId: 'session_missing_cwd',
|
||
|
|
header: {
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_missing_cwd',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
},
|
||
|
|
expected: /cwd/i,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const { sessionId, header, expected } of invalidHeaders) {
|
||
|
|
const file = provider.getSessionFilePath(sessionId);
|
||
|
|
await writeFile(file, `${JSON.stringify(header)}\n`, 'utf8');
|
||
|
|
|
||
|
|
await expect(provider.getSession(sessionId)).rejects.toThrow(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'SessionTreeProviderError',
|
||
|
|
message: expect.stringContaining(file),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await expect(provider.getSession(sessionId)).rejects.toThrow(expected);
|
||
|
|
await expect(provider.getSession(sessionId)).rejects.toThrow(/line 1/i);
|
||
|
|
}
|
||
|
|
|
||
|
|
const mismatchedIdFile = provider.getSessionFilePath('session_requested_id');
|
||
|
|
await writeFile(
|
||
|
|
mismatchedIdFile,
|
||
|
|
`${JSON.stringify({
|
||
|
|
type: 'session',
|
||
|
|
version: 1,
|
||
|
|
id: 'session_actual_id',
|
||
|
|
timestamp: '2026-04-19T00:00:00.000Z',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
})}\n`,
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
|
||
|
|
await expect(provider.getSession('session_requested_id')).rejects.toThrow(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'SessionTreeProviderError',
|
||
|
|
message: expect.stringContaining(mismatchedIdFile),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await expect(provider.getSession('session_requested_id')).rejects.toThrow(/session_requested_id/);
|
||
|
|
await expect(provider.getSession('session_requested_id')).rejects.toThrow(/session_actual_id/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps session ids to safe stable base64url file names while preserving the original header id', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const conSession = await provider.createSession({
|
||
|
|
sessionId: 'CON',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
const complexSessionId = 'folder/name:with:colon';
|
||
|
|
const complexSession = await provider.createSession({
|
||
|
|
sessionId: complexSessionId,
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
const files = (await readdir(tempRoot)).sort();
|
||
|
|
expect(files).toHaveLength(2);
|
||
|
|
expect(files).toEqual([
|
||
|
|
'Q09O.jsonl',
|
||
|
|
'Zm9sZGVyL25hbWU6d2l0aDpjb2xvbg.jsonl',
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect(path.basename(conSession.sessionFile as string)).toBe('Q09O.jsonl');
|
||
|
|
expect(path.basename(complexSession.sessionFile as string)).toBe('Zm9sZGVyL25hbWU6d2l0aDpjb2xvbg.jsonl');
|
||
|
|
|
||
|
|
const conHeader = JSON.parse((await readFile(conSession.sessionFile as string, 'utf8')).trim().split('\n')[0]!);
|
||
|
|
const complexHeader = JSON.parse((await readFile(complexSession.sessionFile as string, 'utf8')).trim().split('\n')[0]!);
|
||
|
|
|
||
|
|
expect(conHeader.id).toBe('CON');
|
||
|
|
expect(complexHeader.id).toBe(complexSessionId);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects non-JSON-serializable input at file provider boundaries while preserving undefined clear semantics', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
provider.createSession({
|
||
|
|
sessionId: 'session_bad_metadata',
|
||
|
|
provider: 'file',
|
||
|
|
metadata: {
|
||
|
|
invalid: Symbol('bad'),
|
||
|
|
} as unknown as Record<string, unknown>,
|
||
|
|
}),
|
||
|
|
).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_json_serializable',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_non_json_append',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'custom_message',
|
||
|
|
customType: 'note',
|
||
|
|
content: {
|
||
|
|
invalid: BigInt(1),
|
||
|
|
},
|
||
|
|
display: true,
|
||
|
|
}),
|
||
|
|
).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
|
||
|
|
const label = await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_label_clear_semantics',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'entry_target',
|
||
|
|
label: undefined,
|
||
|
|
});
|
||
|
|
if (label.type !== 'label') {
|
||
|
|
throw new Error('expected label entry');
|
||
|
|
}
|
||
|
|
expect(label.label).toBeUndefined();
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
provider.updateEntry(session.sessionId, label.id, {
|
||
|
|
metadata: {
|
||
|
|
invalid: () => 'nope',
|
||
|
|
} as unknown as Record<string, unknown>,
|
||
|
|
}),
|
||
|
|
).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects non-JSON-serializable updateSession metadata with SessionTreeProviderError', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const invalidMetadataCases: Array<{
|
||
|
|
name: string;
|
||
|
|
metadata: Record<string, unknown>;
|
||
|
|
}> = [
|
||
|
|
{
|
||
|
|
name: 'date',
|
||
|
|
metadata: new Date('2026-04-19T00:00:00.000Z') as unknown as Record<string, unknown>,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'symbol',
|
||
|
|
metadata: {
|
||
|
|
invalid: Symbol('bad'),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'bigint',
|
||
|
|
metadata: {
|
||
|
|
invalid: BigInt(1),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'function',
|
||
|
|
metadata: {
|
||
|
|
invalid: () => 'bad',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'non_plain_object',
|
||
|
|
metadata: {
|
||
|
|
invalid: new (class CustomMetadata {
|
||
|
|
value = 'bad';
|
||
|
|
})(),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const { name, metadata } of invalidMetadataCases) {
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: `session_update_metadata_${name}`,
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
provider.updateSession(session.sessionId, {
|
||
|
|
metadata,
|
||
|
|
}),
|
||
|
|
).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rewrites the target entry line and updates the header timestamp for updateEntry', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:10:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
const session = await provider.createSession({
|
||
|
|
sessionId: 'session_update_entry_rewrite',
|
||
|
|
provider: 'file',
|
||
|
|
cwd: 'C:/workspace/neta',
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.appendEntry(session.sessionId, {
|
||
|
|
id: 'entry_update_root',
|
||
|
|
parentId: null,
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'custom_message',
|
||
|
|
customType: 'notice',
|
||
|
|
content: {
|
||
|
|
text: 'before',
|
||
|
|
},
|
||
|
|
details: {
|
||
|
|
stage: 'before',
|
||
|
|
},
|
||
|
|
display: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
await provider.updateEntry(session.sessionId, 'entry_update_root', {
|
||
|
|
content: {
|
||
|
|
text: 'after',
|
||
|
|
},
|
||
|
|
details: {
|
||
|
|
stage: 'after',
|
||
|
|
},
|
||
|
|
display: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
const lines = (await readFile(session.sessionFile as string, 'utf8')).trim().split('\n');
|
||
|
|
const header = JSON.parse(lines[0]!);
|
||
|
|
const entry = JSON.parse(lines[1]!);
|
||
|
|
|
||
|
|
expect(header.updatedAt).toBe('2026-04-19T00:10:00.000Z');
|
||
|
|
expect(entry).toMatchObject({
|
||
|
|
id: 'entry_update_root',
|
||
|
|
content: {
|
||
|
|
text: 'after',
|
||
|
|
},
|
||
|
|
details: {
|
||
|
|
stage: 'after',
|
||
|
|
},
|
||
|
|
display: false,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns null for missing getSession and throws SessionTreeProviderError for other require-session APIs', async () => {
|
||
|
|
const provider = new FileSessionTreeProvider({
|
||
|
|
rootDir: tempRoot,
|
||
|
|
cwd: () => 'C:/workspace/neta',
|
||
|
|
now: () => '2026-04-19T00:00:00.000Z',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(await provider.getSession('session_missing')).toBeNull();
|
||
|
|
|
||
|
|
await expect(provider.listEntries('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(provider.getActivePath('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(provider.getSnapshot('session_missing')).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
await expect(
|
||
|
|
provider.updateEntry('session_missing', 'entry_missing', {
|
||
|
|
metadata: {
|
||
|
|
test: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).rejects.toBeInstanceOf(SessionTreeProviderError);
|
||
|
|
});
|
||
|
|
});
|