518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|