GPU_GUARD_MONOREPO/packages/backend/test/session_tree_mysql_provider.test.ts

1254 lines
34 KiB
TypeScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
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<T> = {
where?: Partial<T>;
order?: Partial<Record<keyof T, SortDirection>>;
};
interface MockRepository<T extends object> {
find(options?: RepoFindOptions<T>): Promise<T[]>;
findOne(options: { where: Partial<T> }): Promise<T | null>;
findOneBy(where: Partial<T>): Promise<T | null>;
save(entity: T): Promise<T>;
delete(where: Partial<T>): Promise<void>;
allRows(): T[];
}
interface ProviderFixture {
provider: MySqlSessionTreeProvider;
sessionRepo: MockRepository<NetaClawAgentSessionEntity>;
entryRepo: MockRepository<NetaClawAgentSessionEntryEntity>;
}
class NonPlainRecord {
constructor(public readonly value: string) {}
}
function createRepositoryMock<T extends { id?: number } & object>(): MockRepository<T> {
const rows: T[] = [];
let nextId = 1;
return {
async find(options?: RepoFindOptions<T>): Promise<T[]> {
let result = rows.filter(row => matchesWhere(row, options?.where));
result = applyOrder(result, options?.order);
return result.map(cloneValue);
},
async findOne(options: { where: Partial<T> }): Promise<T | null> {
return cloneMaybe(rows.find(row => matchesWhere(row, options.where)) ?? null);
},
async findOneBy(where: Partial<T>): Promise<T | null> {
return cloneMaybe(rows.find(row => matchesWhere(row, where)) ?? null);
},
async save(entity: T): Promise<T> {
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<T>): Promise<void> {
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<T extends object>(
rows: T[],
order: Partial<Record<keyof T, SortDirection>> | 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<T extends object>(row: T, where: Partial<T> | undefined): boolean {
if (!where) {
return true;
}
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
function hasKey<K extends string>(value: object, key: K): value is object & Record<K, unknown> {
return key in value;
}
function assertNoDuplicateBusinessKey<T extends object>(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<T extends object>(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<T extends object>(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<T>(value: T | null): T | null {
return value === null ? null : cloneValue(value);
}
function cloneValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createFixture(): ProviderFixture {
const sessionRepo = createRepositoryMock<NetaClawAgentSessionEntity>();
const entryRepo = createRepositoryMock<NetaClawAgentSessionEntryEntity>();
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<NetaClawAgentSessionEntity>,
'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<NetaClawAgentSessionEntryEntity>,
'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<typeof seedEntryRow>;
}> = [
{
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<string, unknown>,
},
}),
},
{
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<string, unknown>,
},
})).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);
});
});