1254 lines
34 KiB
TypeScript
1254 lines
34 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|