import { ROOT_PARENT_KEY, buildEntryIndex, getPathToLeaf, groupChildrenByParent, resolveLatestLabels } from '../src/modules/netaclaw/session-tree/path.js'; import { buildSessionContext } from '../src/modules/netaclaw/session-tree/context_builder.js'; import { SessionTreeProvider, SessionTreeProviderError, } from '../src/modules/netaclaw/session-tree/provider.js'; import type { AppendSessionTreeEntryInput, CreateSessionTreeInput, SessionTreeEntry, SessionTreeMessage, SessionTreeSession, SessionTreeSnapshot, UpdateSessionTreeEntryPatch, } from '../src/modules/netaclaw/session-tree/types.js'; type ProviderFactory = () => SessionTreeProvider; export function runSessionTreeProviderContract(name: string, createProvider: ProviderFactory): void { describe(`${name} session tree provider contract`, () => { it('createSession defaults active session pointers to null', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_001', provider: 'file', cwd: 'C:/workspace/neta', }); expect(session.sessionId).toBe('session_contract_001'); expect(session.rootEntryId).toBeNull(); expect(session.leafEntryId).toBeNull(); expect(session.status).toBe('active'); }); it('appendEntry advances root and leaf, and getActivePath returns the root-to-leaf path', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_002', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); const leaf = await provider.appendEntry(session.sessionId, { id: 'entry_leaf', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'message', message: { role: 'assistant', content: 'leaf', }, }); const updatedSession = await provider.getSession(session.sessionId); const activePath = await provider.getActivePath(session.sessionId); expect(updatedSession?.rootEntryId).toBe(root.id); expect(updatedSession?.leafEntryId).toBe(leaf.id); expect(activePath.map(entry => entry.id)).toEqual([root.id, leaf.id]); }); it('switchLeaf changes only the active branch and preserves sibling branches', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_003', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); const branchA = await provider.appendEntry(session.sessionId, { id: 'entry_branch_a', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'message', message: { role: 'assistant', content: 'branch a', }, }); const branchB = await provider.appendEntry(session.sessionId, { id: 'entry_branch_b', parentId: root.id, timestamp: '2026-04-19T00:02:00.000Z', type: 'message', message: { role: 'assistant', content: 'branch b', }, }); const switched = await provider.switchLeaf(session.sessionId, branchA.id); const entries = await provider.listEntries(session.sessionId); const activePath = await provider.getActivePath(session.sessionId); expect(switched.leafEntryId).toBe(branchA.id); expect(entries.map(entry => entry.id).sort()).toEqual([root.id, branchA.id, branchB.id].sort()); expect(activePath.map(entry => entry.id)).toEqual([root.id, branchA.id]); }); it('updateEntry only applies to existing entries and throws SessionTreeProviderError for missing ids', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_004', provider: 'file', }); await provider.appendEntry(session.sessionId, { id: 'entry_existing', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', customType: 'notice', content: 'before', display: true, }); const updated = await provider.updateEntry(session.sessionId, 'entry_existing', { content: 'after', }); expect(updated.type).toBe('custom_message'); if (updated.type !== 'custom_message') { throw new Error('expected custom_message entry'); } expect(updated.content).toBe('after'); await expect( provider.updateEntry(session.sessionId, 'entry_missing', { metadata: { updated: true, }, }), ).rejects.toBeInstanceOf(SessionTreeProviderError); }); it('appendEntry rejects missing parents, cross-session parents, and duplicate roots', async () => { const provider = createProvider(); const firstSession = await provider.createSession({ sessionId: 'session_contract_005', provider: 'file', }); const secondSession = await provider.createSession({ sessionId: 'session_contract_006', provider: 'file', }); const firstRoot = await provider.appendEntry(firstSession.sessionId, { id: 'entry_contract_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); await expect( provider.appendEntry(firstSession.sessionId, { id: 'entry_missing_parent', parentId: 'entry_missing', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', message: { role: 'assistant', content: 'missing parent', }, }), ).rejects.toBeInstanceOf(SessionTreeProviderError); await expect( provider.appendEntry(secondSession.sessionId, { id: 'entry_cross_session_parent', parentId: firstRoot.id, timestamp: '2026-04-19T00:02:00.000Z', type: 'message', message: { role: 'assistant', content: 'cross session parent', }, }), ).rejects.toBeInstanceOf(SessionTreeProviderError); await expect( provider.appendEntry(firstSession.sessionId, { id: 'entry_duplicate_root', parentId: null, timestamp: '2026-04-19T00:03:00.000Z', type: 'message', message: { role: 'assistant', content: 'duplicate root', }, }), ).rejects.toBeInstanceOf(SessionTreeProviderError); }); it('updateEntry rejects structural patches that mutate immutable entry fields', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_007', provider: 'file', }); const entry = await provider.appendEntry(session.sessionId, { id: 'entry_patch_target', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', customType: 'notice', content: 'before', display: true, }); const invalidPatches = [ { id: 'entry_other' }, { type: 'message' }, { parentId: 'entry_other_parent' }, { timestamp: '2026-04-19T00:05:00.000Z' }, ]; for (const patch of invalidPatches) { await expect( provider.updateEntry( session.sessionId, entry.id, patch as unknown as Partial, ), ).rejects.toBeInstanceOf(SessionTreeProviderError); } }); it('updateEntry rejects semantic patches that mutate append-only historical facts', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_007b', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_semantic_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_semantic_label', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'label', targetId: root.id, label: 'important', }); await provider.appendEntry(session.sessionId, { id: 'entry_semantic_branch_summary', parentId: root.id, timestamp: '2026-04-19T00:02:00.000Z', type: 'branch_summary', fromId: root.id, summary: 'branch summary', details: { source: 'contract', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_semantic_compaction', parentId: root.id, timestamp: '2026-04-19T00:03:00.000Z', type: 'compaction', summary: 'compacted', firstKeptEntryId: root.id, tokensBefore: 42, details: { source: 'contract', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_semantic_model_change', parentId: root.id, timestamp: '2026-04-19T00:04:00.000Z', type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', }); await provider.appendEntry(session.sessionId, { id: 'entry_semantic_thinking_level', parentId: root.id, timestamp: '2026-04-19T00:05:00.000Z', type: 'thinking_level_change', thinkingLevel: 'high', }); const invalidPatches: Array<{ entryId: string; patch: Partial; }> = [ { entryId: 'entry_semantic_label', patch: { targetId: 'entry_other', }, }, { entryId: 'entry_semantic_label', patch: { label: 'retagged', }, }, { entryId: 'entry_semantic_branch_summary', patch: { fromId: 'entry_other', }, }, { entryId: 'entry_semantic_branch_summary', patch: { summary: 'rewritten summary', }, }, { entryId: 'entry_semantic_compaction', patch: { firstKeptEntryId: 'entry_other', }, }, { entryId: 'entry_semantic_compaction', patch: { tokensBefore: 7, }, }, { entryId: 'entry_semantic_model_change', patch: { provider: 'anthropic', }, }, { entryId: 'entry_semantic_model_change', patch: { modelId: 'claude-sonnet-4.5', }, }, { entryId: 'entry_semantic_thinking_level', patch: { thinkingLevel: 'low', }, }, ]; for (const { entryId, patch } of invalidPatches) { await expect( provider.updateEntry( session.sessionId, entryId, patch as unknown as UpdateSessionTreeEntryPatch, ), ).rejects.toBeInstanceOf(SessionTreeProviderError); } }); it('createSession, getSession, listEntries, and getSnapshot return clones instead of live internal references', async () => { const provider = createProvider(); const created = await provider.createSession({ sessionId: 'session_contract_008', provider: 'file', metadata: { origin: 'contract', }, }); created.status = 'archived'; created.metadata = { origin: 'mutated create result', }; const root = await provider.appendEntry(created.sessionId, { id: 'entry_clone_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: { text: 'root', }, }, metadata: { source: 'original', }, }); const fetched = await provider.getSession(created.sessionId); if (!fetched) { throw new Error('expected fetched session'); } fetched.status = 'paused'; fetched.metadata = { origin: 'mutated fetched session', }; const entries = await provider.listEntries(created.sessionId); entries[0]!.metadata = { source: 'mutated list entry', }; if (entries[0]?.type === 'message' && typeof entries[0].message.content === 'object' && entries[0].message.content) { (entries[0].message.content as { text: string }).text = 'mutated list content'; } const snapshot = await provider.getSnapshot(created.sessionId); snapshot.session.status = 'deleted'; snapshot.entries[0]!.metadata = { source: 'mutated snapshot entry', }; const reloadedSession = await provider.getSession(created.sessionId); const reloadedEntries = await provider.listEntries(created.sessionId); const reloadedSnapshot = await provider.getSnapshot(created.sessionId); expect(root.metadata).toEqual({ source: 'original', }); expect(reloadedSession?.status).toBe('active'); expect(reloadedSession?.metadata).toEqual({ origin: 'contract', }); expect(reloadedEntries[0]?.metadata).toEqual({ source: 'original', }); if (reloadedEntries[0]?.type !== 'message') { throw new Error('expected message entry'); } expect(reloadedEntries[0].message.content).toEqual({ text: 'root', }); expect(reloadedSnapshot.session.status).toBe('active'); expect(reloadedSnapshot.entries[0]?.metadata).toEqual({ source: 'original', }); }); it('getSnapshot returns a reusable contract snapshot shape with runtime context derived from the active path', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_009', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_snapshot_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_snapshot_thinking', parentId: root.id, timestamp: '2026-04-19T00:00:30.000Z', type: 'thinking_level_change', thinkingLevel: 'high', }); await provider.appendEntry(session.sessionId, { id: 'entry_snapshot_model', parentId: 'entry_snapshot_thinking', timestamp: '2026-04-19T00:00:45.000Z', type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', }); const assistant = await provider.appendEntry(session.sessionId, { id: 'entry_snapshot_assistant', parentId: 'entry_snapshot_model', timestamp: '2026-04-19T00:00:50.000Z', type: 'message', message: { role: 'assistant', content: 'answer', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_snapshot_label', parentId: assistant.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'label', targetId: root.id, label: 'important', }); const snapshot = await provider.getSnapshot(session.sessionId); expect(snapshot.session.sessionId).toBe(session.sessionId); expect(snapshot.entries).toHaveLength(5); expect(snapshot.activePath.map(entry => entry.id)).toEqual([ root.id, 'entry_snapshot_thinking', 'entry_snapshot_model', assistant.id, 'entry_snapshot_label', ]); expect(snapshot.childrenByParentId[ROOT_PARENT_KEY]).toEqual([root.id]); expect(snapshot.labelsByEntryId[root.id]?.label).toBe('important'); expect(snapshot.runtimeContext.messages).toEqual([ { role: 'user', content: 'root', }, { role: 'assistant', content: 'answer', }, ]); expect(snapshot.runtimeContext.thinkingLevel).toBe('high'); expect(snapshot.runtimeContext.model).toEqual({ provider: 'openai', modelId: 'gpt-4.1', }); }); it('round-trips subagent batch metadata patches without leaking runtime-only events into runtime context', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_010', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_subagent_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'run subagents', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_subagent_batch', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'subagent_batch', content: { batchId: 'batch_001', mode: 'parallel', tasks: [ { id: 'task_1', prompt: 'collect data', }, ], status: 'running', parentEntryId: root.id, }, }); const runtimeEvent = { sequence: 1, timestamp: '2026-04-19T00:01:05.000Z', event: { kind: 'result', status: 'completed', subagent: { id: 'subagent_1', summary: 'done', }, }, }; await provider.updateEntry(session.sessionId, 'entry_subagent_batch', { content: { batchId: 'batch_001', mode: 'parallel', tasks: [ { id: 'task_1', prompt: 'collect data', }, ], status: 'completed', parentEntryId: root.id, }, metadata: { latestEvent: runtimeEvent, events: [runtimeEvent], finalResults: [ { id: 'subagent_1', summary: 'done', }, ], }, }); const entries = await provider.listEntries(session.sessionId); const activePath = await provider.getActivePath(session.sessionId); const snapshot = await provider.getSnapshot(session.sessionId); const subagentBatch = entries.find(entry => entry.id === 'entry_subagent_batch'); expect(activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_batch']); expect(subagentBatch).toMatchObject({ id: 'entry_subagent_batch', parentId: root.id, type: 'subagent_batch', content: { batchId: 'batch_001', mode: 'parallel', status: 'completed', parentEntryId: root.id, }, metadata: { latestEvent: runtimeEvent, events: [runtimeEvent], finalResults: [ { id: 'subagent_1', summary: 'done', }, ], }, }); expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_batch']); expect(snapshot.runtimeContext.messages).toEqual([ { role: 'user', content: 'run subagents', }, ]); expect(snapshot.runtimeContext.thinkingLevel).toBe('off'); expect(snapshot.runtimeContext.model).toBeNull(); expect(snapshot.entries.find(entry => entry.id === 'entry_subagent_batch')).toMatchObject({ metadata: { latestEvent: runtimeEvent, events: [runtimeEvent], finalResults: [ { id: 'subagent_1', summary: 'done', }, ], }, }); }); it('round-trips subagent result evidence and process metadata without leaking it into runtime context', async () => { const provider = createProvider(); const session = await provider.createSession({ sessionId: 'session_contract_011', provider: 'file', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_subagent_result_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'run subagents', }, }); 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', }; await provider.appendEntry(session.sessionId, { id: 'entry_subagent_result', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'subagent_result', content: { batchId: 'batch_001', results: [ { id: 'subagent_1', status: 'completed', summary: 'found 2 images', resultPayload: { evidenceSummary, processEvents: [processEvent], }, }, ], status: 'completed', parentEntryId: root.id, }, metadata: { evidenceSummaries: [evidenceSummary], processEvents: [processEvent], }, }); const entries = await provider.listEntries(session.sessionId); const activePath = await provider.getActivePath(session.sessionId); const snapshot = await provider.getSnapshot(session.sessionId); const subagentResult = entries.find(entry => entry.id === 'entry_subagent_result'); expect(activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_result']); expect(subagentResult).toMatchObject({ id: 'entry_subagent_result', parentId: root.id, type: 'subagent_result', content: { batchId: 'batch_001', status: 'completed', parentEntryId: root.id, }, metadata: { evidenceSummaries: [evidenceSummary], processEvents: [processEvent], }, }); expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_result']); expect(snapshot.entries.find(entry => entry.id === 'entry_subagent_result')).toMatchObject({ metadata: { evidenceSummaries: [evidenceSummary], processEvents: [processEvent], }, }); expect(snapshot.runtimeContext.messages).toEqual([ { role: 'user', content: 'run subagents', }, ]); expect(snapshot.runtimeContext.thinkingLevel).toBe('off'); expect(snapshot.runtimeContext.model).toBeNull(); }); }); } class InMemorySessionTreeProvider implements SessionTreeProvider { private readonly sessions = new Map(); private readonly entriesBySessionId = new Map(); async createSession(input: CreateSessionTreeInput): Promise { const session: SessionTreeSession = { sessionId: input.sessionId ?? `session_${this.sessions.size + 1}`, provider: input.provider ?? 'file', rootEntryId: input.rootEntryId ?? null, leafEntryId: input.leafEntryId ?? null, cwd: input.cwd, sessionFile: input.sessionFile, parentSessionId: input.parentSessionId, agentId: input.agentId, userId: input.userId, title: input.title, status: input.status ?? 'active', metadata: cloneValue(input.metadata), createdAt: input.createdAt, updatedAt: input.updatedAt, }; this.sessions.set(session.sessionId, cloneSession(session)); this.entriesBySessionId.set(session.sessionId, []); return cloneSession(session); } async getSession(sessionId: string): Promise { const session = this.sessions.get(sessionId); return session ? cloneSession(session) : null; } async updateSession(sessionId: string, patch: Partial): Promise { const existing = this.requireSession(sessionId); const updated = { ...existing, ...cloneSessionPatch(patch), sessionId: existing.sessionId, }; this.sessions.set(sessionId, cloneSession(updated)); return cloneSession(updated); } async deleteSession(sessionId: string): Promise { this.sessions.delete(sessionId); this.entriesBySessionId.delete(sessionId); } async listEntries(sessionId: string): Promise { return cloneEntries(this.requireEntries(sessionId)); } async appendEntry(sessionId: string, input: AppendSessionTreeEntryInput): Promise { const session = this.requireSession(sessionId); const entries = this.requireEntries(sessionId); this.assertAppendParentConstraint(session, entries, input.parentId); const entry: SessionTreeEntry = { ...input, id: input.id ?? `entry_${entries.length + 1}`, timestamp: input.timestamp ?? new Date(0).toISOString(), }; const storedEntry = cloneEntry(entry); entries.push(storedEntry); const updatedSession: SessionTreeSession = { ...session, rootEntryId: session.rootEntryId ?? storedEntry.id, leafEntryId: storedEntry.id, }; this.sessions.set(sessionId, cloneSession(updatedSession)); return cloneEntry(storedEntry); } async appendMessage(sessionId: string, message: SessionTreeMessage): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'message', message, }); } async appendThinkingLevelChange(sessionId: string, thinkingLevel: string): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'thinking_level_change', thinkingLevel, }); } async appendModelChange(sessionId: string, provider: string, modelId: string): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'model_change', provider, modelId, }); } async appendCompaction(sessionId: string, input: { summary: string; firstKeptEntryId: string; tokensBefore: number; details?: Record; }): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'compaction', ...input, }); } async appendBranchSummary(sessionId: string, input: { fromId: string; summary: string; details?: Record; }): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'branch_summary', ...input, }); } async appendLabelChange(sessionId: string, targetId: string, label: string | undefined): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'label', targetId, label, }); } async appendSessionInfo(sessionId: string, name?: string): Promise { const session = this.requireSession(sessionId); return this.appendEntry(sessionId, { parentId: session.leafEntryId, type: 'session_info', name, }); } async updateEntry(sessionId: string, id: string, patch: UpdateSessionTreeEntryPatch): Promise { const entries = this.requireEntries(sessionId); const index = entries.findIndex(entry => entry.id === id); if (index < 0) { throw new SessionTreeProviderError(`Entry ${id} not found in session ${sessionId}`); } assertImmutableEntryPatch(entries[index]!, patch as Record); const updated = { ...entries[index], ...cloneEntryPatch(patch), id, type: entries[index]!.type, parentId: entries[index]!.parentId, timestamp: entries[index]!.timestamp, } as SessionTreeEntry; entries[index] = cloneEntry(updated); return cloneEntry(updated); } async switchLeaf(sessionId: string, leafEntryId: string | null): Promise { if (leafEntryId !== null) { const exists = this.requireEntries(sessionId).some(entry => entry.id === leafEntryId); if (!exists) { throw new SessionTreeProviderError(`Leaf entry ${leafEntryId} not found in session ${sessionId}`); } } return this.updateSession(sessionId, { leafEntryId, }); } async resetLeaf(sessionId: string): Promise { return this.switchLeaf(sessionId, null); } async createBranchedSession( sessionId: string, leafEntryId: string, input: CreateSessionTreeInput = {}, ): Promise { const session = this.requireSession(sessionId); const path = getPathToLeaf(this.requireEntries(sessionId), leafEntryId); const created = await this.createSession({ ...input, parentSessionId: input.parentSessionId ?? sessionId, cwd: input.cwd ?? session.cwd, agentId: input.agentId ?? session.agentId, userId: input.userId ?? session.userId, title: input.title ?? session.title, metadata: input.metadata ?? session.metadata, }); for (const entry of path) { await this.appendEntry(created.sessionId, cloneEntry(entry) as AppendSessionTreeEntryInput); } await this.switchLeaf(created.sessionId, leafEntryId); return this.requireSession(created.sessionId); } async getActivePath(sessionId: string): Promise { const session = this.requireSession(sessionId); const entries = this.requireEntries(sessionId); if (session.leafEntryId === null) { return []; } return cloneEntries(getPathToLeaf(entries, session.leafEntryId)); } async getSnapshot(sessionId: string): Promise { const session = this.requireSession(sessionId); const entries = this.requireEntries(sessionId); const activePath = session.leafEntryId === null ? [] : getPathToLeaf(entries, session.leafEntryId); return cloneSnapshot({ session, entries, activePath, childrenByParentId: groupChildrenByParent(entries), labelsByEntryId: resolveLatestLabels(entries), runtimeContext: buildSessionContext(activePath, session.leafEntryId ?? undefined), }); } private requireSession(sessionId: string): SessionTreeSession { const session = this.sessions.get(sessionId); if (!session) { throw new SessionTreeProviderError(`Session ${sessionId} not found`); } return session; } private requireEntries(sessionId: string): SessionTreeEntry[] { const entries = this.entriesBySessionId.get(sessionId); if (!entries) { throw new SessionTreeProviderError(`Session ${sessionId} not found`); } return entries; } private assertAppendParentConstraint( session: SessionTreeSession, entries: SessionTreeEntry[], parentId: string | null, ): void { if (parentId === null) { if (session.rootEntryId !== null || entries.length > 0) { throw new SessionTreeProviderError(`Session ${session.sessionId} already has a root entry`); } return; } const hasParent = entries.some(entry => entry.id === parentId); if (!hasParent) { throw new SessionTreeProviderError( `Parent entry ${parentId} not found in session ${session.sessionId}`, ); } } } function assertImmutableEntryPatch( entry: SessionTreeEntry, patch: Record, ): void { const immutableKeys: Array> = [ 'id', 'type', 'parentId', 'timestamp', ]; for (const key of immutableKeys) { if (Object.prototype.hasOwnProperty.call(patch, key) && patch[key] !== entry[key]) { throw new SessionTreeProviderError(`Entry field ${key} is immutable`); } } const allowedKeys = getAllowedPatchKeys(entry.type); for (const key of Object.keys(patch)) { if (!allowedKeys.has(key)) { throw new SessionTreeProviderError( `Entry field ${key} cannot be updated for entry type ${entry.type}`, ); } } } function getAllowedPatchKeys(entryType: SessionTreeEntry['type']): ReadonlySet { switch (entryType) { case 'message': return new Set(['message', 'metadata']); case 'custom_message': case 'subagent_batch': case 'subagent_result': return new Set(['content', 'details', 'display', 'metadata']); case 'custom': return new Set(['data', 'metadata']); case 'thinking_level_change': case 'model_change': case 'compaction': case 'branch_summary': case 'label': case 'session_info': return new Set(['metadata']); default: { const exhaustive: never = entryType; return exhaustive; } } } function cloneSnapshot(snapshot: SessionTreeSnapshot): SessionTreeSnapshot { return { session: cloneSession(snapshot.session), entries: cloneEntries(snapshot.entries), activePath: cloneEntries(snapshot.activePath), childrenByParentId: cloneRecordOfArrays(snapshot.childrenByParentId), labelsByEntryId: cloneValue(snapshot.labelsByEntryId), runtimeContext: cloneValue(snapshot.runtimeContext), }; } function cloneEntries(entries: SessionTreeEntry[]): SessionTreeEntry[] { return entries.map(entry => cloneEntry(entry)); } function cloneEntry(entry: SessionTreeEntry): SessionTreeEntry { return cloneValue(entry); } function cloneEntryPatch(patch: UpdateSessionTreeEntryPatch): UpdateSessionTreeEntryPatch { return cloneValue(patch); } function cloneSession(session: SessionTreeSession): SessionTreeSession { return cloneValue(session); } function cloneSessionPatch(patch: Partial): Partial { return cloneValue(patch); } function cloneRecordOfArrays(value: Record): Record { return cloneValue(value); } function cloneValue(value: T): T { if (value === undefined) { return value; } return JSON.parse(JSON.stringify(value)) as T; } describe('session tree provider contract scaffold', () => { runSessionTreeProviderContract('in-memory fake', () => new InMemorySessionTreeProvider()); it('AppendSessionTreeEntryInput preserves the distributive union and rejects mixed legacy fields', () => { const messageInput: AppendSessionTreeEntryInput = { parentId: null, type: 'message', message: { role: 'assistant', content: 'hello', }, }; const labelInput: AppendSessionTreeEntryInput = { parentId: 'entry_target', type: 'label', targetId: 'entry_target', label: 'important', }; // @ts-expect-error message entries must keep role/content nested under message const invalidMessageTopLevel: AppendSessionTreeEntryInput = { parentId: null, type: 'message', role: 'assistant', content: 'hello' }; // @ts-expect-error label entries require targetId const invalidLabelMissingTargetId: AppendSessionTreeEntryInput = { parentId: null, type: 'label', label: 'missing target', }; // @ts-expect-error union members cannot mix unrelated fields const invalidLabelWithMessage: AppendSessionTreeEntryInput = { parentId: null, type: 'label', targetId: 'entry_target', label: 'mixed', message: { role: 'assistant', content: 'nope' } }; // @ts-expect-error different union members cannot cross-wire fields const invalidModelWithCustomField: AppendSessionTreeEntryInput = { parentId: null, type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', customType: 'invalid' }; expect(messageInput.parentId).toBeNull(); expect(messageInput.type).toBe('message'); if (messageInput.type !== 'message') { throw new Error('expected message input'); } expect(messageInput.message.role).toBe('assistant'); expect(labelInput.targetId).toBe('entry_target'); expect('content' in messageInput).toBe(false); expect('role' in messageInput).toBe(false); expect(invalidMessageTopLevel).toBeDefined(); expect(invalidLabelMissingTargetId).toBeDefined(); expect(invalidLabelWithMessage).toBeDefined(); expect(invalidModelWithCustomField).toBeDefined(); }); it('UpdateSessionTreeEntryPatch preserves entry-specific patch whitelists', () => { const messagePatch: UpdateSessionTreeEntryPatch = { message: { role: 'assistant' as const, content: 'updated', }, }; const customMessagePatch: UpdateSessionTreeEntryPatch = { content: 'updated', details: { stage: 'done', }, display: false, metadata: { source: 'contract', }, }; const labelMetadataPatch: UpdateSessionTreeEntryPatch = { metadata: { reviewed: true, }, }; // @ts-expect-error label targetId is append-only and cannot be updated const invalidLabelTargetPatch: UpdateSessionTreeEntryPatch = { targetId: 'entry_other' }; // @ts-expect-error label text is append-only and cannot be updated const invalidLabelValuePatch: UpdateSessionTreeEntryPatch = { label: 'updated' }; // @ts-expect-error branch summary source is append-only and cannot be updated const invalidBranchSummaryFromPatch: UpdateSessionTreeEntryPatch = { fromId: 'entry_other' }; // @ts-expect-error branch summary text is append-only and cannot be updated const invalidBranchSummarySummaryPatch: UpdateSessionTreeEntryPatch = { summary: 'updated summary' }; // @ts-expect-error compaction anchor is append-only and cannot be updated const invalidCompactionFirstKeptPatch: UpdateSessionTreeEntryPatch = { firstKeptEntryId: 'entry_other' }; // @ts-expect-error compaction token snapshot is append-only and cannot be updated const invalidCompactionTokensPatch: UpdateSessionTreeEntryPatch = { tokensBefore: 10 }; // @ts-expect-error model provider is append-only and cannot be updated const invalidModelProviderPatch: UpdateSessionTreeEntryPatch = { provider: 'anthropic' }; // @ts-expect-error model id is append-only and cannot be updated const invalidModelIdPatch: UpdateSessionTreeEntryPatch = { modelId: 'claude-sonnet-4.5' }; // @ts-expect-error thinking level is append-only and cannot be updated const invalidThinkingLevelPatch: UpdateSessionTreeEntryPatch = { thinkingLevel: 'low' }; expect(messagePatch.message?.role).toBe('assistant'); expect(customMessagePatch.display).toBe(false); expect(labelMetadataPatch.metadata).toEqual({ reviewed: true, }); expect(invalidLabelTargetPatch).toBeDefined(); expect(invalidLabelValuePatch).toBeDefined(); expect(invalidBranchSummaryFromPatch).toBeDefined(); expect(invalidBranchSummarySummaryPatch).toBeDefined(); expect(invalidCompactionFirstKeptPatch).toBeDefined(); expect(invalidCompactionTokensPatch).toBeDefined(); expect(invalidModelProviderPatch).toBeDefined(); expect(invalidModelIdPatch).toBeDefined(); expect(invalidThinkingLevelPatch).toBeDefined(); }); });