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; 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, }), ).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, }), ).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; }> = [ { name: 'date', metadata: new Date('2026-04-19T00:00:00.000Z') as unknown as Record, }, { 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); }); });