GPU_GUARD_MONOREPO/packages/backend/test/session_tree_file_provider.test.ts

883 lines
27 KiB
TypeScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
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);
});
});