import { buildSessionTreeSnapshot } from '../src/modules/netaclaw/session-tree/snapshot.js'; import { ROOT_PARENT_KEY } from '../src/modules/netaclaw/session-tree/path.js'; import type { LabelEntry, ModelChangeEntry, SessionMessageEntry, SessionTreeEntry, SessionTreeMessage, SessionTreeSession, SubagentBatchEntry, SubagentResultEntry, ThinkingLevelChangeEntry, } from '../src/modules/netaclaw/session-tree/types.js'; describe('session tree snapshot builder', () => { const createSession = (leafEntryId: string | null): SessionTreeSession => ({ sessionId: 'session_snapshot_001', provider: 'file', rootEntryId: 'root', leafEntryId, }); const createMessageEntry = ( id: string, parentId: string | null, timestamp: string, message: SessionTreeMessage, ): SessionMessageEntry => ({ id, parentId, timestamp, type: 'message', message, }); const createLabelEntry = ( id: string, parentId: string | null, timestamp: string, targetId: string, label: string | undefined, ): LabelEntry => ({ id, parentId, timestamp, type: 'label', targetId, label, }); 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 createSubagentBatchEntry = ( id: string, parentId: string | null, timestamp: string, status: 'running' | 'completed', metadata?: Record, ): SubagentBatchEntry => ({ id, parentId, timestamp, type: 'subagent_batch', content: { batchId: `${id}_batch`, mode: 'parallel', tasks: [ { id: 'task_1', prompt: 'collect data', }, ], status, parentEntryId: parentId, }, metadata, }); const createSubagentResultEntry = ( id: string, parentId: string | null, timestamp: string, metadata?: Record, ): SubagentResultEntry => ({ id, parentId, timestamp, type: 'subagent_result', content: { batchId: `${id}_batch`, results: [ { id: 'subagent_1', status: 'completed', summary: 'found 2 images', }, ], status: 'completed', parentEntryId: parentId, }, metadata, }); it('projects the active branch only and keeps the original session and entry references', () => { const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage; const branchAMessage = { role: 'assistant', content: 'branch-a' } satisfies SessionTreeMessage; const branchBMessage = { role: 'assistant', content: 'branch-b' } satisfies SessionTreeMessage; const session = createSession('branch-a'); const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage); const branchA = createMessageEntry('branch-a', 'root', '2026-04-19T00:01:00.000Z', branchAMessage); const branchB = createMessageEntry('branch-b', 'root', '2026-04-19T00:02:00.000Z', branchBMessage); const entries: SessionTreeEntry[] = [root, branchA, branchB]; const snapshot = buildSessionTreeSnapshot(session, entries); expect(snapshot.session).toBe(session); expect(snapshot.entries).toBe(entries); expect(snapshot.activePath).toEqual([root, branchA]); expect(snapshot.activePath).not.toContain(branchB); expect(snapshot.runtimeContext.messages).toEqual([rootMessage, branchAMessage]); expect(snapshot.runtimeContext.messages).not.toContain(branchBMessage); }); it('groups children under ROOT_PARENT_KEY and real parent ids', () => { const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'root', }), createMessageEntry('child-a', 'root', '2026-04-19T00:01:00.000Z', { role: 'assistant', content: 'child-a', }), createMessageEntry('child-b', 'root', '2026-04-19T00:02:00.000Z', { role: 'assistant', content: 'child-b', }), ]; const snapshot = buildSessionTreeSnapshot(createSession('child-b'), entries); expect(snapshot.childrenByParentId).toEqual({ [ROOT_PARENT_KEY]: ['root'], root: ['child-a', 'child-b'], }); }); it('uses the latest label state and respects undefined clears', () => { const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'root', }), createMessageEntry('leaf', 'root', '2026-04-19T00:00:01.000Z', { role: 'assistant', content: 'leaf', }), createLabelEntry('label-root-old', 'leaf', '2026-04-19T00:00:02.000Z', 'root', 'old'), createLabelEntry('label-root-new', 'leaf', '2026-04-19T00:00:03.000Z', 'root', 'new'), createLabelEntry('label-leaf', 'leaf', '2026-04-19T00:00:04.000Z', 'leaf', 'keep'), createLabelEntry('label-leaf-clear', 'leaf', '2026-04-19T00:00:05.000Z', 'leaf', undefined), ]; const snapshot = buildSessionTreeSnapshot(createSession('leaf'), entries); expect(snapshot.labelsByEntryId).toEqual({ root: { label: 'new', timestamp: '2026-04-19T00:00:03.000Z', entryId: 'label-root-new', }, }); }); it('returns an empty active path and empty runtime messages when leafEntryId is null', () => { const session = createSession(null); session.rootEntryId = null; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', { role: 'user', content: 'root', }), 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', { role: 'assistant', content: 'must not leak', }), ]; const snapshot = buildSessionTreeSnapshot(session, entries); expect(snapshot.activePath).toEqual([]); expect(snapshot.runtimeContext.messages).toEqual([]); expect(snapshot.runtimeContext.thinkingLevel).toBe('off'); expect(snapshot.runtimeContext.model).toBeNull(); }); it('propagates missing parent errors from the active path resolution', () => { const entries: SessionTreeEntry[] = [ createMessageEntry('orphan', 'missing-parent', '2026-04-19T00:00:00.000Z', { role: 'user', content: 'broken', }), ]; expect(() => buildSessionTreeSnapshot(createSession('orphan'), entries)).toThrow( 'Parent entry missing-parent not found', ); }); it('keeps subagent batch entries in the active path and snapshot entries but excludes them from runtime context', () => { const runtimeEvent = { sequence: 1, timestamp: '2026-04-19T00:01:05.000Z', event: { kind: 'result', status: 'completed', subagent: { id: 'subagent_1', summary: 'done', }, }, }; const rootMessage = { role: 'user', content: 'run subagents' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createSubagentBatchEntry('subagent-batch', 'root', '2026-04-19T00:01:00.000Z', 'completed', { latestEvent: runtimeEvent, events: [runtimeEvent], finalResults: [ { id: 'subagent_1', summary: 'done', }, ], }), ]; const snapshot = buildSessionTreeSnapshot(createSession('subagent-batch'), entries); expect(snapshot.activePath.map(entry => entry.id)).toEqual(['root', 'subagent-batch']); expect(snapshot.entries.find(entry => entry.id === 'subagent-batch')).toMatchObject({ metadata: { latestEvent: runtimeEvent, events: [runtimeEvent], finalResults: [ { id: 'subagent_1', summary: 'done', }, ], }, }); expect(snapshot.runtimeContext.messages).toEqual([rootMessage]); expect(snapshot.runtimeContext.thinkingLevel).toBe('off'); expect(snapshot.runtimeContext.model).toBeNull(); }); it('keeps subagent result evidence and process metadata in snapshots without leaking it into runtime context', () => { const evidenceSummary = { kind: 'file-count', title: '图片文件统计', count: 2, files: ['1.jpg', '2.png'], summary: ['根据工具结果统计,图片文件共 2 个。', '- 1.jpg', '- 2.png'].join('\n'), }; const processEvent = { type: 'tool_call', runId: 'subagent-101', timestamp: '2026-04-19T00:01:02.000Z', name: 'find_files', toolCallId: 'tool-call-1', }; const rootMessage = { role: 'user', content: 'run subagents' } satisfies SessionTreeMessage; const entries: SessionTreeEntry[] = [ createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage), createSubagentResultEntry('subagent-result', 'root', '2026-04-19T00:01:00.000Z', { evidenceSummaries: [evidenceSummary], processEvents: [processEvent], }), ]; const snapshot = buildSessionTreeSnapshot(createSession('subagent-result'), entries); expect(snapshot.activePath.map(entry => entry.id)).toEqual(['root', 'subagent-result']); expect(snapshot.entries.find(entry => entry.id === 'subagent-result')).toMatchObject({ type: 'subagent_result', metadata: { evidenceSummaries: [evidenceSummary], processEvents: [processEvent], }, }); expect(snapshot.runtimeContext.messages).toEqual([rootMessage]); expect(snapshot.runtimeContext.thinkingLevel).toBe('off'); expect(snapshot.runtimeContext.model).toBeNull(); }); });