import { NetaClawAgentSessionEntryEntity } from '../src/modules/netaclaw/entity/agent_session_entry.js'; import { NetaClawAgentSessionEntity } from '../src/modules/netaclaw/entity/agent_session.js'; import { MySqlSessionTreeProvider } from '../src/modules/netaclaw/session-tree/mysql_provider.js'; import { SessionTreeProviderError } from '../src/modules/netaclaw/session-tree/provider.js'; import type { SessionTreeEntry, SessionTreeSession, } from '../src/modules/netaclaw/session-tree/types.js'; import { runSessionTreeProviderContract } from './session_tree_provider_contract.test.js'; type SortDirection = 'ASC' | 'DESC' | 'asc' | 'desc'; type RepoFindOptions = { where?: Partial; order?: Partial>; }; interface MockRepository { find(options?: RepoFindOptions): Promise; findOne(options: { where: Partial }): Promise; findOneBy(where: Partial): Promise; save(entity: T): Promise; delete(where: Partial): Promise; allRows(): T[]; } interface ProviderFixture { provider: MySqlSessionTreeProvider; sessionRepo: MockRepository; entryRepo: MockRepository; } class NonPlainRecord { constructor(public readonly value: string) {} } function createRepositoryMock(): MockRepository { const rows: T[] = []; let nextId = 1; return { async find(options?: RepoFindOptions): Promise { let result = rows.filter(row => matchesWhere(row, options?.where)); result = applyOrder(result, options?.order); return result.map(cloneValue); }, async findOne(options: { where: Partial }): Promise { return cloneMaybe(rows.find(row => matchesWhere(row, options.where)) ?? null); }, async findOneBy(where: Partial): Promise { return cloneMaybe(rows.find(row => matchesWhere(row, where)) ?? null); }, async save(entity: T): Promise { const cloned = cloneValue(entity); const entityId = typeof cloned.id === 'number' ? cloned.id : undefined; if (entityId !== undefined) { const existingIndex = rows.findIndex(row => row.id === entityId); if (existingIndex >= 0) { rows[existingIndex] = cloned; } else { assertNoDuplicateBusinessKey(rows, cloned); rows.push(cloned); nextId = Math.max(nextId, entityId + 1); } return cloneValue(cloned); } assertNoDuplicateBusinessKey(rows, cloned); cloned.id = nextId++; rows.push(cloned); return cloneValue(cloned); }, async delete(where: Partial): Promise { for (let index = rows.length - 1; index >= 0; index -= 1) { if (matchesWhere(rows[index]!, where)) { rows.splice(index, 1); } } }, allRows(): T[] { return rows.map(cloneValue); }, }; } function applyOrder( rows: T[], order: Partial> | undefined, ): T[] { if (!order || Object.keys(order).length === 0) { return [...rows]; } const entries = Object.entries(order) as Array<[keyof T, SortDirection]>; return [...rows].sort((left, right) => { for (const [key, direction] of entries) { const normalized = String(direction).toUpperCase() === 'DESC' ? -1 : 1; const leftValue = left[key]; const rightValue = right[key]; if (leftValue === rightValue) { continue; } if (leftValue === undefined || leftValue === null) { return -1 * normalized; } if (rightValue === undefined || rightValue === null) { return 1 * normalized; } if (leftValue < rightValue) { return -1 * normalized; } if (leftValue > rightValue) { return 1 * normalized; } } return 0; }); } function matchesWhere(row: T, where: Partial | undefined): boolean { if (!where) { return true; } return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); } function hasKey(value: object, key: K): value is object & Record { return key in value; } function assertNoDuplicateBusinessKey(rows: T[], entity: T): void { if (rows.some(row => isSameBusinessRow(row, entity))) { throw new Error(`Duplicate business key insert without primary key: ${describeBusinessKey(entity)}`); } } function isSameBusinessRow(left: T, right: T): boolean { if (hasKey(left, 'entryId') && hasKey(right, 'entryId') && hasKey(left, 'sessionId') && hasKey(right, 'sessionId')) { return left.entryId === right.entryId && left.sessionId === right.sessionId; } if (hasKey(left, 'sessionId') && hasKey(right, 'sessionId')) { return left.sessionId === right.sessionId; } return false; } function describeBusinessKey(entity: T): string { if (hasKey(entity, 'entryId') && hasKey(entity, 'sessionId')) { return `sessionId=${String(entity.sessionId)}, entryId=${String(entity.entryId)}`; } if (hasKey(entity, 'sessionId')) { return `sessionId=${String(entity.sessionId)}`; } return 'unknown'; } function cloneMaybe(value: T | null): T | null { return value === null ? null : cloneValue(value); } function cloneValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function createFixture(): ProviderFixture { const sessionRepo = createRepositoryMock(); const entryRepo = createRepositoryMock(); return { sessionRepo, entryRepo, provider: new MySqlSessionTreeProvider({ sessionRepo, entryRepo, now: () => '2026-04-19T00:00:00.000Z', sessionIdGenerator: () => 'session_generated', entryIdGenerator: () => 'entry_generated', }), }; } function seedSessionRow( value: Omit< Partial, 'sessionId' | 'provider' | 'rootEntryId' | 'leafEntryId' | 'createTime' | 'updateTime' > & { sessionId: string; provider: string; rootEntryId: string | null; leafEntryId: string | null; createTime?: string | Date; updateTime?: string | Date; }, ): NetaClawAgentSessionEntity { return value as unknown as NetaClawAgentSessionEntity; } function seedEntryRow( value: Omit< Partial, 'sessionId' | 'entryId' | 'parentEntryId' | 'timestamp' | 'type' | 'createTime' | 'updateTime' > & { sessionId: string; entryId: string; parentEntryId: string | null; timestamp: string; type: string; createTime?: string | Date; updateTime?: string | Date; }, ): NetaClawAgentSessionEntryEntity { return value as unknown as NetaClawAgentSessionEntryEntity; } describe('MySqlSessionTreeProvider', () => { runSessionTreeProviderContract('mysql', () => createFixture().provider); it('createSession maps defaults into mysql-backed session state', async () => { const { provider, sessionRepo } = createFixture(); const session = await provider.createSession({ sessionId: 'session_mysql_smoke', cwd: 'C:/workspace/neta', metadata: { source: 'test', }, }); expect(session).toEqual({ sessionId: 'session_mysql_smoke', provider: 'mysql', rootEntryId: null, leafEntryId: null, cwd: 'C:/workspace/neta', sessionFile: undefined, parentSessionId: undefined, agentId: undefined, userId: undefined, title: undefined, status: 'active', metadata: { source: 'test', }, createdAt: '2026-04-19T00:00:00.000Z', updatedAt: '2026-04-19T00:00:00.000Z', }); const saved = await sessionRepo.findOneBy({ sessionId: 'session_mysql_smoke', }); expect(saved).toMatchObject({ sessionId: 'session_mysql_smoke', provider: 'mysql', rootEntryId: null, leafEntryId: null, cwd: 'C:/workspace/neta', status: 'active', metadata: { source: 'test', }, }); }); it('maps mysql entry entities back to Pi-first ids and keeps stable list order', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_mapping', provider: 'mysql', rootEntryId: 'entry_root', leafEntryId: 'entry_child_b', cwd: 'C:/workspace/neta', sessionFile: null, parentSessionId: null, agentId: null, userId: null, title: null, status: 'active', metadata: { source: 'seed', }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_mapping', entryId: 'entry_child_b', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', metadata: { branch: 'b', }, payload: { message: { role: 'assistant', content: 'child-b', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_mapping', entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', metadata: null, payload: { message: { role: 'user', content: 'root', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_mapping', entryId: 'entry_child_a', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', metadata: { branch: 'a', }, payload: { message: { role: 'assistant', content: 'child-a', }, }, })); const session = await provider.getSession('session_mapping'); const entries = await provider.listEntries('session_mapping'); const activePath = await provider.getActivePath('session_mapping'); const snapshot = await provider.getSnapshot('session_mapping'); expect(session).toMatchObject({ sessionId: 'session_mapping', rootEntryId: 'entry_root', leafEntryId: 'entry_child_b', provider: 'mysql', }); expect(entries.map(entry => entry.id)).toEqual([ 'entry_root', 'entry_child_a', 'entry_child_b', ]); expect(entries[0]).toMatchObject({ id: 'entry_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', }); expect(activePath.map(entry => entry.id)).toEqual([ 'entry_root', 'entry_child_b', ]); expect(snapshot.activePath.map(entry => entry.id)).toEqual([ 'entry_root', 'entry_child_b', ]); }); it('derives public rootEntryId from entries instead of trusting the stored session row', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_wrong_root', provider: 'mysql', rootEntryId: 'entry_wrong_root', leafEntryId: 'entry_child', status: 'active', })); await entryRepo.save(seedEntryRow({ sessionId: 'session_wrong_root', entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'user', content: 'root', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_wrong_root', entryId: 'entry_child', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: 'child', }, }, })); await expect(provider.getSession('session_wrong_root')).resolves.toMatchObject({ sessionId: 'session_wrong_root', rootEntryId: 'entry_root', leafEntryId: 'entry_child', }); }); it('falls back to the last entry when the stored leafEntryId points to a missing entry', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_wrong_leaf', provider: 'mysql', rootEntryId: 'entry_root', leafEntryId: 'entry_missing', status: 'active', })); await entryRepo.save(seedEntryRow({ sessionId: 'session_wrong_leaf', entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'user', content: 'root', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_wrong_leaf', entryId: 'entry_child_a', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: 'child-a', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_wrong_leaf', entryId: 'entry_child_b', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:02:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: 'child-b', }, }, })); await expect(provider.getSession('session_wrong_leaf')).resolves.toMatchObject({ sessionId: 'session_wrong_leaf', rootEntryId: 'entry_root', leafEntryId: 'entry_child_b', }); await expect(provider.getActivePath('session_wrong_leaf')).resolves.toMatchObject([ { id: 'entry_root' }, { id: 'entry_child_b' }, ]); }); it('preserves an explicit null leafEntryId and returns an empty active path', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_null_leaf', provider: 'mysql', rootEntryId: 'entry_wrong_root', leafEntryId: null, status: 'active', })); await entryRepo.save(seedEntryRow({ sessionId: 'session_null_leaf', entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'user', content: 'root', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_null_leaf', entryId: 'entry_child', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: 'child', }, }, })); await expect(provider.getSession('session_null_leaf')).resolves.toMatchObject({ sessionId: 'session_null_leaf', rootEntryId: 'entry_root', leafEntryId: null, }); await expect(provider.getActivePath('session_null_leaf')).resolves.toEqual([]); }); it('builds snapshots from the normalized session view instead of raw mysql row pointers', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_snapshot_normalized', provider: 'mysql', rootEntryId: 'entry_wrong_root', leafEntryId: 'entry_missing', status: 'active', })); await entryRepo.save(seedEntryRow({ sessionId: 'session_snapshot_normalized', entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'user', content: 'root', }, }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_snapshot_normalized', entryId: 'entry_child', parentEntryId: 'entry_root', timestamp: '2026-04-19T00:01:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: 'child', }, }, })); const snapshot = await provider.getSnapshot('session_snapshot_normalized'); expect(snapshot.session).toMatchObject({ sessionId: 'session_snapshot_normalized', rootEntryId: 'entry_root', leafEntryId: 'entry_child', }); expect(snapshot.activePath.map(entry => entry.id)).toEqual([ 'entry_root', 'entry_child', ]); }); it('appendEntry persists mysql entity fields and payload split, then updateEntry respects whitelist mapping', async () => { const { provider, entryRepo } = createFixture(); const session = await provider.createSession({ sessionId: 'session_update_mapping', cwd: 'C:/workspace/neta', }); await provider.appendEntry(session.sessionId, { id: 'entry_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', customType: 'notice', content: { text: 'before', }, details: { stage: 'before', }, display: true, metadata: { source: 'append', }, }); const appendedRow = await entryRepo.findOneBy({ sessionId: session.sessionId, entryId: 'entry_root', }); expect(appendedRow).toMatchObject({ sessionId: session.sessionId, entryId: 'entry_root', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', metadata: { source: 'append', }, payload: { customType: 'notice', content: { text: 'before', }, details: { stage: 'before', }, display: true, }, }); const updated = await provider.updateEntry(session.sessionId, 'entry_root', { content: { text: 'after', }, details: { stage: 'after', }, display: false, metadata: { source: 'update', }, }); expect(updated).toMatchObject({ id: 'entry_root', parentId: null, type: 'custom_message', customType: 'notice', content: { text: 'after', }, details: { stage: 'after', }, display: false, metadata: { source: 'update', }, }); const updatedRow = await entryRepo.findOneBy({ sessionId: session.sessionId, entryId: 'entry_root', }); expect(updatedRow?.payload).toEqual({ customType: 'notice', content: { text: 'after', }, details: { stage: 'after', }, display: false, }); await expect(provider.updateEntry(session.sessionId, 'entry_root', { customType: 'mutated', } as never)).rejects.toBeInstanceOf(SessionTreeProviderError); }); it('requires a primary key for updateSession and updateEntry saves instead of business-key upsert semantics', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); const session = await provider.createSession({ sessionId: 'session_real_save_semantics', cwd: 'C:/workspace/neta', title: 'before', }); await provider.appendEntry(session.sessionId, { id: 'entry_real_save_semantics', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', customType: 'notice', content: { text: 'before', }, display: true, }); await expect(provider.updateSession(session.sessionId, { title: 'after', })).resolves.toMatchObject({ sessionId: session.sessionId, title: 'after', }); await expect(provider.updateEntry(session.sessionId, 'entry_real_save_semantics', { content: { text: 'after', }, })).resolves.toMatchObject({ id: 'entry_real_save_semantics', type: 'custom_message', content: { text: 'after', }, }); expect(sessionRepo.allRows()).toHaveLength(1); expect(entryRepo.allRows()).toHaveLength(1); }); it('round-trips non-message entries through mysql payload mapping while preserving Pi shape', async () => { const { provider } = createFixture(); const session = await provider.createSession({ sessionId: 'session_roundtrip', }); const root = await provider.appendEntry(session.sessionId, { id: 'entry_roundtrip_root', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'root', }, }); await provider.appendEntry(session.sessionId, { id: 'entry_roundtrip_model', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', }); await provider.appendEntry(session.sessionId, { id: 'entry_roundtrip_label', parentId: 'entry_roundtrip_model', timestamp: '2026-04-19T00:02:00.000Z', type: 'label', targetId: root.id, label: 'important', }); await provider.appendEntry(session.sessionId, { id: 'entry_roundtrip_custom_message', parentId: 'entry_roundtrip_label', timestamp: '2026-04-19T00:03:00.000Z', type: 'custom_message', customType: 'notice', content: { text: 'hello', }, details: { from: 'test', }, display: true, }); const entries = await provider.listEntries(session.sessionId); const byId = Object.fromEntries(entries.map(entry => [entry.id, entry] satisfies [string, SessionTreeEntry])); expect(byId.entry_roundtrip_model).toEqual({ id: 'entry_roundtrip_model', parentId: root.id, timestamp: '2026-04-19T00:01:00.000Z', type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', }); expect(byId.entry_roundtrip_label).toEqual({ id: 'entry_roundtrip_label', parentId: 'entry_roundtrip_model', timestamp: '2026-04-19T00:02:00.000Z', type: 'label', targetId: root.id, label: 'important', }); expect(byId.entry_roundtrip_custom_message).toEqual({ id: 'entry_roundtrip_custom_message', parentId: 'entry_roundtrip_label', timestamp: '2026-04-19T00:03:00.000Z', type: 'custom_message', customType: 'notice', content: { text: 'hello', }, details: { from: 'test', }, display: true, }); }); it('round-trips full mysql row payloads including undefined label clearing and time normalization', async () => { const { provider, sessionRepo, entryRepo } = createFixture(); await provider.createSession({ sessionId: 'session_roundtrip_full', cwd: 'C:/workspace/neta', agentId: 42, userId: 'user-7', title: 'Roundtrip', metadata: { source: 'seed', }, }); const storedSession = await sessionRepo.findOneBy({ sessionId: 'session_roundtrip_full', }); if (!storedSession) { throw new Error('expected stored session row'); } await sessionRepo.save(seedSessionRow({ ...storedSession, sessionId: 'session_roundtrip_full', provider: 'mysql', rootEntryId: 'entry_message', leafEntryId: 'entry_branch_summary', createTime: '2026-04-19 00:00:00', updateTime: new Date('2026-04-19T00:09:00.000Z'), })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_message', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'assistant', content: { text: 'hello', }, metadata: { trace: 1, }, }, }, metadata: { note: 'message', }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_label_clear', parentEntryId: 'entry_message', timestamp: '2026-04-19T00:01:00.000Z', type: 'label', payload: { targetId: 'entry_message', }, metadata: null, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_custom', parentEntryId: 'entry_label_clear', timestamp: '2026-04-19T00:02:00.000Z', type: 'custom', payload: { customType: 'artifact', data: { format: 'json', }, }, metadata: null, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_session_info', parentEntryId: 'entry_custom', timestamp: '2026-04-19T00:03:00.000Z', type: 'session_info', payload: { name: 'Session name', }, metadata: null, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_compaction', parentEntryId: 'entry_session_info', timestamp: '2026-04-19T00:04:00.000Z', type: 'compaction', payload: { summary: 'Compacted', firstKeptEntryId: 'entry_message', tokensBefore: 128, details: { reason: 'trim', }, fromHook: false, }, metadata: { source: 'hook', }, })); await entryRepo.save(seedEntryRow({ sessionId: 'session_roundtrip_full', entryId: 'entry_branch_summary', parentEntryId: 'entry_compaction', timestamp: '2026-04-19T00:05:00.000Z', type: 'branch_summary', payload: { fromId: 'entry_custom', summary: 'Branch summary', details: { branch: 'left', }, fromHook: true, }, metadata: null, })); const loadedSession = await provider.getSession('session_roundtrip_full'); const loadedEntries = await provider.listEntries('session_roundtrip_full'); const byId = Object.fromEntries(loadedEntries.map(entry => [entry.id, entry] satisfies [string, SessionTreeEntry])); expect(loadedSession).toEqual({ sessionId: 'session_roundtrip_full', provider: 'mysql', rootEntryId: 'entry_message', leafEntryId: 'entry_branch_summary', cwd: 'C:/workspace/neta', sessionFile: undefined, parentSessionId: undefined, agentId: 42, userId: 'user-7', title: 'Roundtrip', status: 'active', metadata: { source: 'seed', }, createdAt: '2026-04-19T00:00:00.000Z', updatedAt: '2026-04-19T00:09:00.000Z', }); expect(byId.entry_message).toEqual({ id: 'entry_message', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', metadata: { note: 'message', }, message: { role: 'assistant', content: { text: 'hello', }, metadata: { trace: 1, }, }, }); expect(byId.entry_label_clear).toEqual({ id: 'entry_label_clear', parentId: 'entry_message', timestamp: '2026-04-19T00:01:00.000Z', type: 'label', targetId: 'entry_message', label: undefined, }); expect(byId.entry_custom).toEqual({ id: 'entry_custom', parentId: 'entry_label_clear', timestamp: '2026-04-19T00:02:00.000Z', type: 'custom', customType: 'artifact', data: { format: 'json', }, }); expect(byId.entry_session_info).toEqual({ id: 'entry_session_info', parentId: 'entry_custom', timestamp: '2026-04-19T00:03:00.000Z', type: 'session_info', name: 'Session name', }); expect(byId.entry_compaction).toEqual({ id: 'entry_compaction', parentId: 'entry_session_info', timestamp: '2026-04-19T00:04:00.000Z', type: 'compaction', metadata: { source: 'hook', }, summary: 'Compacted', firstKeptEntryId: 'entry_message', tokensBefore: 128, details: { reason: 'trim', }, fromHook: false, }); expect(byId.entry_branch_summary).toEqual({ id: 'entry_branch_summary', parentId: 'entry_compaction', timestamp: '2026-04-19T00:05:00.000Z', type: 'branch_summary', fromId: 'entry_custom', summary: 'Branch summary', details: { branch: 'left', }, fromHook: true, }); }); it('fails fast on malformed mysql rows instead of loosely coercing payload values', async () => { const invalidCases: Array<{ name: string; row: ReturnType; }> = [ { name: 'message payload must be a plain object', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_message_shape', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: 'not-an-object' as unknown as Record, }, }), }, { name: 'message role must be a string', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_message_role', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: true, content: 'x', }, }, }), }, { name: 'message content is required', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_message_content', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', payload: { message: { role: 'assistant', }, }, }), }, { name: 'custom_message display must be boolean', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_custom_display', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom_message', payload: { customType: 'notice', content: 'hello', display: 'false', }, }), }, { name: 'compaction fromHook must be boolean', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_compaction_from_hook', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'compaction', payload: { summary: 's', firstKeptEntryId: 'entry_root', tokensBefore: 1, fromHook: 'true', }, }), }, { name: 'branch_summary fromHook must be boolean', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_branch_from_hook', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'branch_summary', payload: { fromId: 'entry_root', summary: 's', fromHook: 1, }, }), }, { name: 'label must be string or undefined', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_label', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'label', payload: { targetId: 'entry_root', label: 7, }, }), }, { name: 'session_info name must be string', row: seedEntryRow({ sessionId: 'session_bad_payload', entryId: 'entry_bad_session_name', parentEntryId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'session_info', payload: { name: false, }, }), }, ]; for (const invalidCase of invalidCases) { const { provider, sessionRepo, entryRepo } = createFixture(); await sessionRepo.save(seedSessionRow({ sessionId: 'session_bad_payload', provider: 'mysql', rootEntryId: invalidCase.row.entryId, leafEntryId: invalidCase.row.entryId, })); await entryRepo.save(invalidCase.row); await expect(provider.listEntries('session_bad_payload')).rejects.toBeInstanceOf(SessionTreeProviderError); } }); it('rejects non-JSON-serializable plain-data boundaries for mysql metadata and payloads', async () => { const { provider } = createFixture(); const session = await provider.createSession({ sessionId: 'session_json_boundaries', }); await expect(provider.createSession({ sessionId: 'session_bad_metadata_fn', metadata: { bad: (() => 'x') as unknown as never, }, })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.updateSession(session.sessionId, { metadata: { bad: Symbol('x') as unknown as never, }, })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.appendEntry(session.sessionId, { id: 'entry_bad_metadata_bigint', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'message', message: { role: 'user', content: 'hello', }, metadata: { bad: BigInt(1) as unknown as never, }, })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.appendEntry(session.sessionId, { id: 'entry_bad_payload_non_plain', parentId: null, timestamp: '2026-04-19T00:00:00.000Z', type: 'custom', customType: 'artifact', data: { nested: new NonPlainRecord('x') as unknown as Record, }, })).rejects.toBeInstanceOf(SessionTreeProviderError); }); it('getSession returns null and require-session APIs throw for missing sessions', async () => { const { provider } = createFixture(); expect(await provider.getSession('missing')).toBeNull(); await expect(provider.listEntries('missing')).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.getActivePath('missing')).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.getSnapshot('missing')).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.appendEntry('missing', { parentId: null, type: 'message', message: { role: 'user', content: 'root', }, })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.updateSession('missing', { title: 'missing', })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.updateEntry('missing', 'entry_missing', { metadata: { updated: true, }, })).rejects.toBeInstanceOf(SessionTreeProviderError); await expect(provider.switchLeaf('missing', null)).rejects.toBeInstanceOf(SessionTreeProviderError); }); });