import { buildSessionContext } from '../src/modules/netaclaw/session-tree/context_builder.js'; import type { BranchSummaryEntry, CompactionEntry, CustomEntry, CustomMessageEntry, LabelEntry, ModelChangeEntry, SessionInfoEntry, SessionMessageEntry, SessionTreeEntry, SessionTreeMessage, ThinkingLevelChangeEntry, } from '../src/modules/netaclaw/session-tree/types.js'; describe('session tree context builder', () => { const createMessageEntry = ( id: string, parentId: string | null, timestamp: string, message: SessionTreeMessage, ): SessionMessageEntry => ({ id, parentId, timestamp, type: 'message', message, }); const createThinkingLevelEntry = ( id: string, parentId: string | null, timestamp: string, thinkingLevel: string, ): ThinkingLevelChangeEntry => ({ id, parentId, timestamp, type: 'thinking_level_change', thinkingLevel, }); const createModelChangeEntry = ( id: string, parentId: string | null, timestamp: string, provider: string, modelId: string, ): ModelChangeEntry => ({ id, parentId, timestamp, type: 'model_change', provider, modelId, }); const createCompactionEntry = ( id: string, parentId: string | null, timestamp: string, summary: string, firstKeptEntryId: string, tokensBefore: number, ): CompactionEntry => ({ id, parentId, timestamp, type: 'compaction', summary, firstKeptEntryId, tokensBefore, }); const createBranchSummaryEntry = ( id: string, parentId: string | null, timestamp: string, fromId: string, summary: string, ): BranchSummaryEntry => ({ id, parentId, timestamp, type: 'branch_summary', fromId, summary, }); const createCustomEntry = ( id: string, parentId: string | null, timestamp: string, customType: string, ): CustomEntry => ({ id, parentId, timestamp, type: 'custom', customType, }); const createCustomMessageEntry = ( id: string, parentId: string | null, timestamp: string, customType: string, content: unknown, display: boolean, details?: Record, ): CustomMessageEntry => ({ id, parentId, timestamp, type: 'custom_message', customType, content, display, details, }); const createLabelEntry = ( id: string, parentId: string | null, timestamp: string, targetId: string, label: string | undefined, ): LabelEntry => ({ id, parentId, timestamp, type: 'label', targetId, label, }); const createSessionInfoEntry = ( id: string, parentId: string | null, timestamp: string, name: string, ): SessionInfoEntry => ({ id, parentId, timestamp, type: 'session_info', name, }); it('returns an empty context when leafId is null', () => { const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'ignored', }), ]; expect(buildSessionContext(entries, null)).toEqual({ messages: [], thinkingLevel: 'off', model: null, }); }); it('projects only the active path messages and ignores sibling branches', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const mainLeafMessage = { role: 'assistant', content: 'main leaf' } satisfies SessionTreeMessage; const sideLeafMessage = { role: 'assistant', content: 'side leaf' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createMessageEntry('main', 'root', '2026-04-19T00:01:00.000Z', mainLeafMessage), createMessageEntry('side', 'root', '2026-04-19T00:02:00.000Z', sideLeafMessage), ]; const context = buildSessionContext(entries, 'main'); expect(context.messages).toEqual([rootMessage, mainLeafMessage]); expect(context.messages).not.toContain(sideLeafMessage); }); it('falls back to the last entry when leafId is undefined or missing', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const mainLeafMessage = { role: 'assistant', content: 'main leaf' } satisfies SessionTreeMessage; const lastLeafMessage = { role: 'assistant', content: 'last leaf' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createMessageEntry('main', 'root', '2026-04-19T00:01:00.000Z', mainLeafMessage), createMessageEntry('last', 'root', '2026-04-19T00:02:00.000Z', lastLeafMessage), ]; expect(buildSessionContext(entries).messages).toEqual([rootMessage, lastLeafMessage]); expect(buildSessionContext(entries, 'missing').messages).toEqual([rootMessage, lastLeafMessage]); }); it('uses a Map index to resolve the leaf and parent path when entries are incomplete', () => { const rootMessage = { role: 'user', content: 'root from index' } satisfies SessionTreeMessage; const parentMessage = { role: 'assistant', content: 'parent from index' } satisfies SessionTreeMessage; const leafMessage = { role: 'user', content: 'leaf from entries' } satisfies SessionTreeMessage; const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage); const parent = createMessageEntry('parent', 'root', '2026-04-19T00:01:00.000Z', parentMessage); const leaf = createMessageEntry('leaf', 'parent', '2026-04-19T00:02:00.000Z', leafMessage); const entries: SessionTreeEntry[] = [leaf]; const byId = new Map([ [root.id, root], [parent.id, parent], [leaf.id, leaf], ]); expect(buildSessionContext(entries, 'leaf', byId).messages).toEqual([ rootMessage, parentMessage, leafMessage, ]); }); it('uses a Record index to resolve parent paths when entries are incomplete', () => { const rootMessage = { role: 'user', content: 'root from record' } satisfies SessionTreeMessage; const leafMessage = { role: 'assistant', content: 'leaf from entries' } satisfies SessionTreeMessage; const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage); const leaf = createMessageEntry('leaf', 'root', '2026-04-19T00:01:00.000Z', leafMessage); const entries: SessionTreeEntry[] = [leaf]; const byId: Record = { [root.id]: root, [leaf.id]: leaf, }; expect(buildSessionContext(entries, 'leaf', byId).messages).toEqual([ rootMessage, leafMessage, ]); }); it('falls back to the last entry through the same normalized index when byId is incomplete', () => { const rootMessage = { role: 'user', content: 'root from entries' } satisfies SessionTreeMessage; const lastLeafMessage = { role: 'assistant', content: 'last leaf from entries' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createMessageEntry('last', 'root', '2026-04-19T00:01:00.000Z', lastLeafMessage), ]; const byId = new Map(); expect(buildSessionContext(entries, 'missing', byId).messages).toEqual([ rootMessage, lastLeafMessage, ]); }); it('does not resolve missing leaf ids from Record prototype keys', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const lastLeafMessage = { role: 'assistant', content: 'last leaf' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createMessageEntry('last', 'root', '2026-04-19T00:01:00.000Z', lastLeafMessage), ]; const byId: Record = { root: entries[0], last: entries[1], }; expect(buildSessionContext(entries, 'constructor', byId).messages).toEqual([ rootMessage, lastLeafMessage, ]); }); it('does not resolve missing parent ids from Record prototype keys', () => { const leaf = createMessageEntry('leaf', 'constructor', '2026-04-19T00:00:00.000Z', { role: 'user', content: 'broken path', }); const byId: Record = { [leaf.id]: leaf, }; expect(() => buildSessionContext([leaf], 'leaf', byId)).toThrow( 'Parent entry constructor not found', ); }); it('updates runtime settings from thinking and model changes without emitting them as messages', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const leafMessage = { role: 'user', content: 'leaf' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createThinkingLevelEntry('thinking', 'root', '2026-04-19T00:01:00.000Z', 'high'), createModelChangeEntry('model', 'thinking', '2026-04-19T00:02:00.000Z', 'openai', 'gpt-5'), createMessageEntry('leaf', 'model', '2026-04-19T00:03:00.000Z', leafMessage), ]; expect(buildSessionContext(entries, 'leaf')).toEqual({ messages: [rootMessage, leafMessage], thinkingLevel: 'high', model: { provider: 'openai', modelId: 'gpt-5', }, }); }); it('updates model from assistant messages and preserves the original message object', () => { const userMessage = { role: 'user', content: 'hello' } satisfies SessionTreeMessage; const assistantMessage = { role: 'assistant', content: 'world', provider: 'anthropic', model: 'claude-sonnet-4-20250514', } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('user', null, '2026-04-19T00:00:00.000Z', userMessage), createMessageEntry('assistant', 'user', '2026-04-19T00:01:00.000Z', assistantMessage), ]; const context = buildSessionContext(entries, 'assistant'); expect(context.messages).toHaveLength(2); expect(context.messages[1]).toBe(assistantMessage); expect(context.model).toEqual({ provider: 'anthropic', modelId: 'claude-sonnet-4-20250514', }); }); it('includes branch summaries and custom messages but excludes non-message entry types', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const leafMessage = { role: 'assistant', content: 'leaf' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createCustomEntry('custom', 'root', '2026-04-19T00:01:00.000Z', 'agent_event'), createBranchSummaryEntry( 'branch', 'custom', '2026-04-19T00:02:00.000Z', 'root', 'branched away and returned', ), createCustomMessageEntry( 'custom-message', 'branch', '2026-04-19T00:03:00.000Z', 'notice', 'custom content', true, { severity: 'info' }, ), createLabelEntry('label', 'custom-message', '2026-04-19T00:04:00.000Z', 'root', 'bookmark'), createSessionInfoEntry('session-info', 'label', '2026-04-19T00:05:00.000Z', 'renamed session'), createMessageEntry('leaf', 'session-info', '2026-04-19T00:06:00.000Z', leafMessage), ]; expect(buildSessionContext(entries, 'leaf').messages).toEqual([ rootMessage, { role: 'branchSummary', summary: 'branched away and returned', fromId: 'root', timestamp: new Date('2026-04-19T00:02:00.000Z').getTime(), }, { role: 'custom', customType: 'notice', content: 'custom content', display: true, details: { severity: 'info' }, timestamp: new Date('2026-04-19T00:03:00.000Z').getTime(), }, leafMessage, ]); }); it('uses compaction summary plus kept entries and post-compaction messages', () => { const droppedMessage = { role: 'user', content: 'drop me' } satisfies SessionTreeMessage; const keptMessage = { role: 'assistant', content: 'keep me' } satisfies SessionTreeMessage; const afterMessage = { role: 'assistant', content: 'after compaction' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'very old root', }), createMessageEntry('drop', 'root', '2026-04-19T00:01:00.000Z', droppedMessage), createMessageEntry('keep', 'drop', '2026-04-19T00:02:00.000Z', keptMessage), createCompactionEntry( 'compaction', 'keep', '2026-04-19T00:03:00.000Z', 'summary text', 'keep', 4096, ), createBranchSummaryEntry( 'branch', 'compaction', '2026-04-19T00:04:00.000Z', 'drop', 'branch after compaction', ), createCustomMessageEntry( 'custom-message', 'branch', '2026-04-19T00:05:00.000Z', 'notice', 'after custom', false, ), createMessageEntry('after', 'custom-message', '2026-04-19T00:06:00.000Z', afterMessage), ]; expect(buildSessionContext(entries, 'after').messages).toEqual([ { role: 'compactionSummary', summary: 'summary text', tokensBefore: 4096, timestamp: new Date('2026-04-19T00:03:00.000Z').getTime(), }, keptMessage, { role: 'branchSummary', summary: 'branch after compaction', fromId: 'drop', timestamp: new Date('2026-04-19T00:04:00.000Z').getTime(), }, { role: 'custom', customType: 'notice', content: 'after custom', display: false, details: undefined, timestamp: new Date('2026-04-19T00:05:00.000Z').getTime(), }, afterMessage, ]); }); it('uses the latest compaction on the active path', () => { const firstKeptMessage = { role: 'user', content: 'first kept' } satisfies SessionTreeMessage; const secondKeptMessage = { role: 'assistant', content: 'second kept' } satisfies SessionTreeMessage; const finalMessage = { role: 'assistant', content: 'final' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'old root', }), createMessageEntry('first-kept', 'root', '2026-04-19T00:01:00.000Z', firstKeptMessage), createCompactionEntry( 'compaction-1', 'first-kept', '2026-04-19T00:02:00.000Z', 'old summary', 'first-kept', 100, ), createMessageEntry('second-kept', 'compaction-1', '2026-04-19T00:03:00.000Z', secondKeptMessage), createCompactionEntry( 'compaction-2', 'second-kept', '2026-04-19T00:04:00.000Z', 'new summary', 'second-kept', 200, ), createMessageEntry('final', 'compaction-2', '2026-04-19T00:05:00.000Z', finalMessage), ]; expect(buildSessionContext(entries, 'final').messages).toEqual([ { role: 'compactionSummary', summary: 'new summary', tokensBefore: 200, timestamp: new Date('2026-04-19T00:04:00.000Z').getTime(), }, secondKeptMessage, finalMessage, ]); }); it('throws when a compaction first kept entry is missing from the active path', () => { const finalMessage = { role: 'assistant', content: 'final' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'root', }), createCompactionEntry( 'compaction-missing-kept', 'root', '2026-04-19T00:01:00.000Z', 'summary text', 'not-on-active-path', 100, ), createMessageEntry('final', 'compaction-missing-kept', '2026-04-19T00:02:00.000Z', finalMessage), ]; expect(() => buildSessionContext(entries, 'final')).toThrow( 'Compaction compaction-missing-kept firstKeptEntryId not-on-active-path not found on active path', ); }); it('throws when an active path parent is missing', () => { const entries: SessionTreeEntry[] = [ createMessageEntry('orphan', 'missing-parent', '2026-04-19T00:00:00.000Z', { role: 'user', content: 'broken path', }), ]; expect(() => buildSessionContext(entries, 'orphan')).toThrow( 'Parent entry missing-parent not found', ); }); });