GPU_GUARD_MONOREPO/packages/backend/test/session_tree_context_builder.test.ts

518 lines
17 KiB
TypeScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
import { buildSessionContext } from '../src/modules/netaclaw/session-tree/context_builder.js';
import type {
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
CustomMessageEntry,
LabelEntry,
ModelChangeEntry,
SessionInfoEntry,
SessionMessageEntry,
SessionTreeEntry,
SessionTreeMessage,
ThinkingLevelChangeEntry,
} from '../src/modules/netaclaw/session-tree/types.js';
describe('session tree context builder', () => {
const createMessageEntry = (
id: string,
parentId: string | null,
timestamp: string,
message: SessionTreeMessage,
): SessionMessageEntry => ({
id,
parentId,
timestamp,
type: 'message',
message,
});
const createThinkingLevelEntry = (
id: string,
parentId: string | null,
timestamp: string,
thinkingLevel: string,
): ThinkingLevelChangeEntry => ({
id,
parentId,
timestamp,
type: 'thinking_level_change',
thinkingLevel,
});
const createModelChangeEntry = (
id: string,
parentId: string | null,
timestamp: string,
provider: string,
modelId: string,
): ModelChangeEntry => ({
id,
parentId,
timestamp,
type: 'model_change',
provider,
modelId,
});
const createCompactionEntry = (
id: string,
parentId: string | null,
timestamp: string,
summary: string,
firstKeptEntryId: string,
tokensBefore: number,
): CompactionEntry => ({
id,
parentId,
timestamp,
type: 'compaction',
summary,
firstKeptEntryId,
tokensBefore,
});
const createBranchSummaryEntry = (
id: string,
parentId: string | null,
timestamp: string,
fromId: string,
summary: string,
): BranchSummaryEntry => ({
id,
parentId,
timestamp,
type: 'branch_summary',
fromId,
summary,
});
const createCustomEntry = (
id: string,
parentId: string | null,
timestamp: string,
customType: string,
): CustomEntry => ({
id,
parentId,
timestamp,
type: 'custom',
customType,
});
const createCustomMessageEntry = (
id: string,
parentId: string | null,
timestamp: string,
customType: string,
content: unknown,
display: boolean,
details?: Record<string, unknown>,
): CustomMessageEntry => ({
id,
parentId,
timestamp,
type: 'custom_message',
customType,
content,
display,
details,
});
const createLabelEntry = (
id: string,
parentId: string | null,
timestamp: string,
targetId: string,
label: string | undefined,
): LabelEntry => ({
id,
parentId,
timestamp,
type: 'label',
targetId,
label,
});
const createSessionInfoEntry = (
id: string,
parentId: string | null,
timestamp: string,
name: string,
): SessionInfoEntry => ({
id,
parentId,
timestamp,
type: 'session_info',
name,
});
it('returns an empty context when leafId is null', () => {
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'ignored',
}),
];
expect(buildSessionContext(entries, null)).toEqual({
messages: [],
thinkingLevel: 'off',
model: null,
});
});
it('projects only the active path messages and ignores sibling branches', () => {
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
const mainLeafMessage = { role: 'assistant', content: 'main leaf' } satisfies SessionTreeMessage;
const sideLeafMessage = { role: 'assistant', content: 'side leaf' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createMessageEntry('main', 'root', '2026-04-19T00:01:00.000Z', mainLeafMessage),
createMessageEntry('side', 'root', '2026-04-19T00:02:00.000Z', sideLeafMessage),
];
const context = buildSessionContext(entries, 'main');
expect(context.messages).toEqual([rootMessage, mainLeafMessage]);
expect(context.messages).not.toContain(sideLeafMessage);
});
it('falls back to the last entry when leafId is undefined or missing', () => {
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
const mainLeafMessage = { role: 'assistant', content: 'main leaf' } satisfies SessionTreeMessage;
const lastLeafMessage = { role: 'assistant', content: 'last leaf' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createMessageEntry('main', 'root', '2026-04-19T00:01:00.000Z', mainLeafMessage),
createMessageEntry('last', 'root', '2026-04-19T00:02:00.000Z', lastLeafMessage),
];
expect(buildSessionContext(entries).messages).toEqual([rootMessage, lastLeafMessage]);
expect(buildSessionContext(entries, 'missing').messages).toEqual([rootMessage, lastLeafMessage]);
});
it('uses a Map index to resolve the leaf and parent path when entries are incomplete', () => {
const rootMessage = { role: 'user', content: 'root from index' } satisfies SessionTreeMessage;
const parentMessage = { role: 'assistant', content: 'parent from index' } satisfies SessionTreeMessage;
const leafMessage = { role: 'user', content: 'leaf from entries' } satisfies SessionTreeMessage;
const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage);
const parent = createMessageEntry('parent', 'root', '2026-04-19T00:01:00.000Z', parentMessage);
const leaf = createMessageEntry('leaf', 'parent', '2026-04-19T00:02:00.000Z', leafMessage);
const entries: SessionTreeEntry[] = [leaf];
const byId = new Map<string, SessionTreeEntry>([
[root.id, root],
[parent.id, parent],
[leaf.id, leaf],
]);
expect(buildSessionContext(entries, 'leaf', byId).messages).toEqual([
rootMessage,
parentMessage,
leafMessage,
]);
});
it('uses a Record index to resolve parent paths when entries are incomplete', () => {
const rootMessage = { role: 'user', content: 'root from record' } satisfies SessionTreeMessage;
const leafMessage = { role: 'assistant', content: 'leaf from entries' } satisfies SessionTreeMessage;
const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage);
const leaf = createMessageEntry('leaf', 'root', '2026-04-19T00:01:00.000Z', leafMessage);
const entries: SessionTreeEntry[] = [leaf];
const byId: Record<string, SessionTreeEntry> = {
[root.id]: root,
[leaf.id]: leaf,
};
expect(buildSessionContext(entries, 'leaf', byId).messages).toEqual([
rootMessage,
leafMessage,
]);
});
it('falls back to the last entry through the same normalized index when byId is incomplete', () => {
const rootMessage = { role: 'user', content: 'root from entries' } satisfies SessionTreeMessage;
const lastLeafMessage = { role: 'assistant', content: 'last leaf from entries' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createMessageEntry('last', 'root', '2026-04-19T00:01:00.000Z', lastLeafMessage),
];
const byId = new Map<string, SessionTreeEntry>();
expect(buildSessionContext(entries, 'missing', byId).messages).toEqual([
rootMessage,
lastLeafMessage,
]);
});
it('does not resolve missing leaf ids from Record prototype keys', () => {
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
const lastLeafMessage = { role: 'assistant', content: 'last leaf' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createMessageEntry('last', 'root', '2026-04-19T00:01:00.000Z', lastLeafMessage),
];
const byId: Record<string, SessionTreeEntry> = {
root: entries[0],
last: entries[1],
};
expect(buildSessionContext(entries, 'constructor', byId).messages).toEqual([
rootMessage,
lastLeafMessage,
]);
});
it('does not resolve missing parent ids from Record prototype keys', () => {
const leaf = createMessageEntry('leaf', 'constructor', '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'broken path',
});
const byId: Record<string, SessionTreeEntry> = {
[leaf.id]: leaf,
};
expect(() => buildSessionContext([leaf], 'leaf', byId)).toThrow(
'Parent entry constructor not found',
);
});
it('updates runtime settings from thinking and model changes without emitting them as messages', () => {
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
const leafMessage = { role: 'user', content: 'leaf' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createThinkingLevelEntry('thinking', 'root', '2026-04-19T00:01:00.000Z', 'high'),
createModelChangeEntry('model', 'thinking', '2026-04-19T00:02:00.000Z', 'openai', 'gpt-5'),
createMessageEntry('leaf', 'model', '2026-04-19T00:03:00.000Z', leafMessage),
];
expect(buildSessionContext(entries, 'leaf')).toEqual({
messages: [rootMessage, leafMessage],
thinkingLevel: 'high',
model: {
provider: 'openai',
modelId: 'gpt-5',
},
});
});
it('updates model from assistant messages and preserves the original message object', () => {
const userMessage = { role: 'user', content: 'hello' } satisfies SessionTreeMessage;
const assistantMessage = {
role: 'assistant',
content: 'world',
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
} satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('user', null, '2026-04-19T00:00:00.000Z', userMessage),
createMessageEntry('assistant', 'user', '2026-04-19T00:01:00.000Z', assistantMessage),
];
const context = buildSessionContext(entries, 'assistant');
expect(context.messages).toHaveLength(2);
expect(context.messages[1]).toBe(assistantMessage);
expect(context.model).toEqual({
provider: 'anthropic',
modelId: 'claude-sonnet-4-20250514',
});
});
it('includes branch summaries and custom messages but excludes non-message entry types', () => {
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
const leafMessage = { role: 'assistant', content: 'leaf' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
createCustomEntry('custom', 'root', '2026-04-19T00:01:00.000Z', 'agent_event'),
createBranchSummaryEntry(
'branch',
'custom',
'2026-04-19T00:02:00.000Z',
'root',
'branched away and returned',
),
createCustomMessageEntry(
'custom-message',
'branch',
'2026-04-19T00:03:00.000Z',
'notice',
'custom content',
true,
{ severity: 'info' },
),
createLabelEntry('label', 'custom-message', '2026-04-19T00:04:00.000Z', 'root', 'bookmark'),
createSessionInfoEntry('session-info', 'label', '2026-04-19T00:05:00.000Z', 'renamed session'),
createMessageEntry('leaf', 'session-info', '2026-04-19T00:06:00.000Z', leafMessage),
];
expect(buildSessionContext(entries, 'leaf').messages).toEqual([
rootMessage,
{
role: 'branchSummary',
summary: 'branched away and returned',
fromId: 'root',
timestamp: new Date('2026-04-19T00:02:00.000Z').getTime(),
},
{
role: 'custom',
customType: 'notice',
content: 'custom content',
display: true,
details: { severity: 'info' },
timestamp: new Date('2026-04-19T00:03:00.000Z').getTime(),
},
leafMessage,
]);
});
it('uses compaction summary plus kept entries and post-compaction messages', () => {
const droppedMessage = { role: 'user', content: 'drop me' } satisfies SessionTreeMessage;
const keptMessage = { role: 'assistant', content: 'keep me' } satisfies SessionTreeMessage;
const afterMessage = { role: 'assistant', content: 'after compaction' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'very old root',
}),
createMessageEntry('drop', 'root', '2026-04-19T00:01:00.000Z', droppedMessage),
createMessageEntry('keep', 'drop', '2026-04-19T00:02:00.000Z', keptMessage),
createCompactionEntry(
'compaction',
'keep',
'2026-04-19T00:03:00.000Z',
'summary text',
'keep',
4096,
),
createBranchSummaryEntry(
'branch',
'compaction',
'2026-04-19T00:04:00.000Z',
'drop',
'branch after compaction',
),
createCustomMessageEntry(
'custom-message',
'branch',
'2026-04-19T00:05:00.000Z',
'notice',
'after custom',
false,
),
createMessageEntry('after', 'custom-message', '2026-04-19T00:06:00.000Z', afterMessage),
];
expect(buildSessionContext(entries, 'after').messages).toEqual([
{
role: 'compactionSummary',
summary: 'summary text',
tokensBefore: 4096,
timestamp: new Date('2026-04-19T00:03:00.000Z').getTime(),
},
keptMessage,
{
role: 'branchSummary',
summary: 'branch after compaction',
fromId: 'drop',
timestamp: new Date('2026-04-19T00:04:00.000Z').getTime(),
},
{
role: 'custom',
customType: 'notice',
content: 'after custom',
display: false,
details: undefined,
timestamp: new Date('2026-04-19T00:05:00.000Z').getTime(),
},
afterMessage,
]);
});
it('uses the latest compaction on the active path', () => {
const firstKeptMessage = { role: 'user', content: 'first kept' } satisfies SessionTreeMessage;
const secondKeptMessage = { role: 'assistant', content: 'second kept' } satisfies SessionTreeMessage;
const finalMessage = { role: 'assistant', content: 'final' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'old root',
}),
createMessageEntry('first-kept', 'root', '2026-04-19T00:01:00.000Z', firstKeptMessage),
createCompactionEntry(
'compaction-1',
'first-kept',
'2026-04-19T00:02:00.000Z',
'old summary',
'first-kept',
100,
),
createMessageEntry('second-kept', 'compaction-1', '2026-04-19T00:03:00.000Z', secondKeptMessage),
createCompactionEntry(
'compaction-2',
'second-kept',
'2026-04-19T00:04:00.000Z',
'new summary',
'second-kept',
200,
),
createMessageEntry('final', 'compaction-2', '2026-04-19T00:05:00.000Z', finalMessage),
];
expect(buildSessionContext(entries, 'final').messages).toEqual([
{
role: 'compactionSummary',
summary: 'new summary',
tokensBefore: 200,
timestamp: new Date('2026-04-19T00:04:00.000Z').getTime(),
},
secondKeptMessage,
finalMessage,
]);
});
it('throws when a compaction first kept entry is missing from the active path', () => {
const finalMessage = { role: 'assistant', content: 'final' } satisfies SessionTreeMessage;
const entries: SessionTreeEntry[] = [
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'root',
}),
createCompactionEntry(
'compaction-missing-kept',
'root',
'2026-04-19T00:01:00.000Z',
'summary text',
'not-on-active-path',
100,
),
createMessageEntry('final', 'compaction-missing-kept', '2026-04-19T00:02:00.000Z', finalMessage),
];
expect(() => buildSessionContext(entries, 'final')).toThrow(
'Compaction compaction-missing-kept firstKeptEntryId not-on-active-path not found on active path',
);
});
it('throws when an active path parent is missing', () => {
const entries: SessionTreeEntry[] = [
createMessageEntry('orphan', 'missing-parent', '2026-04-19T00:00:00.000Z', {
role: 'user',
content: 'broken path',
}),
];
expect(() => buildSessionContext(entries, 'orphan')).toThrow(
'Parent entry missing-parent not found',
);
});
});