332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import { buildSessionTreeSnapshot } from '../src/modules/netaclaw/session-tree/snapshot.js';
|
|
import { ROOT_PARENT_KEY } from '../src/modules/netaclaw/session-tree/path.js';
|
|
import type {
|
|
LabelEntry,
|
|
ModelChangeEntry,
|
|
SessionMessageEntry,
|
|
SessionTreeEntry,
|
|
SessionTreeMessage,
|
|
SessionTreeSession,
|
|
SubagentBatchEntry,
|
|
SubagentResultEntry,
|
|
ThinkingLevelChangeEntry,
|
|
} from '../src/modules/netaclaw/session-tree/types.js';
|
|
|
|
describe('session tree snapshot builder', () => {
|
|
const createSession = (leafEntryId: string | null): SessionTreeSession => ({
|
|
sessionId: 'session_snapshot_001',
|
|
provider: 'file',
|
|
rootEntryId: 'root',
|
|
leafEntryId,
|
|
});
|
|
|
|
const createMessageEntry = (
|
|
id: string,
|
|
parentId: string | null,
|
|
timestamp: string,
|
|
message: SessionTreeMessage,
|
|
): SessionMessageEntry => ({
|
|
id,
|
|
parentId,
|
|
timestamp,
|
|
type: 'message',
|
|
message,
|
|
});
|
|
|
|
const createLabelEntry = (
|
|
id: string,
|
|
parentId: string | null,
|
|
timestamp: string,
|
|
targetId: string,
|
|
label: string | undefined,
|
|
): LabelEntry => ({
|
|
id,
|
|
parentId,
|
|
timestamp,
|
|
type: 'label',
|
|
targetId,
|
|
label,
|
|
});
|
|
|
|
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 createSubagentBatchEntry = (
|
|
id: string,
|
|
parentId: string | null,
|
|
timestamp: string,
|
|
status: 'running' | 'completed',
|
|
metadata?: Record<string, unknown>,
|
|
): SubagentBatchEntry => ({
|
|
id,
|
|
parentId,
|
|
timestamp,
|
|
type: 'subagent_batch',
|
|
content: {
|
|
batchId: `${id}_batch`,
|
|
mode: 'parallel',
|
|
tasks: [
|
|
{
|
|
id: 'task_1',
|
|
prompt: 'collect data',
|
|
},
|
|
],
|
|
status,
|
|
parentEntryId: parentId,
|
|
},
|
|
metadata,
|
|
});
|
|
|
|
const createSubagentResultEntry = (
|
|
id: string,
|
|
parentId: string | null,
|
|
timestamp: string,
|
|
metadata?: Record<string, unknown>,
|
|
): SubagentResultEntry => ({
|
|
id,
|
|
parentId,
|
|
timestamp,
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: `${id}_batch`,
|
|
results: [
|
|
{
|
|
id: 'subagent_1',
|
|
status: 'completed',
|
|
summary: 'found 2 images',
|
|
},
|
|
],
|
|
status: 'completed',
|
|
parentEntryId: parentId,
|
|
},
|
|
metadata,
|
|
});
|
|
|
|
it('projects the active branch only and keeps the original session and entry references', () => {
|
|
const rootMessage = { role: 'user', content: 'root' } satisfies SessionTreeMessage;
|
|
const branchAMessage = { role: 'assistant', content: 'branch-a' } satisfies SessionTreeMessage;
|
|
const branchBMessage = { role: 'assistant', content: 'branch-b' } satisfies SessionTreeMessage;
|
|
const session = createSession('branch-a');
|
|
const root = createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage);
|
|
const branchA = createMessageEntry('branch-a', 'root', '2026-04-19T00:01:00.000Z', branchAMessage);
|
|
const branchB = createMessageEntry('branch-b', 'root', '2026-04-19T00:02:00.000Z', branchBMessage);
|
|
const entries: SessionTreeEntry[] = [root, branchA, branchB];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(session, entries);
|
|
|
|
expect(snapshot.session).toBe(session);
|
|
expect(snapshot.entries).toBe(entries);
|
|
expect(snapshot.activePath).toEqual([root, branchA]);
|
|
expect(snapshot.activePath).not.toContain(branchB);
|
|
expect(snapshot.runtimeContext.messages).toEqual([rootMessage, branchAMessage]);
|
|
expect(snapshot.runtimeContext.messages).not.toContain(branchBMessage);
|
|
});
|
|
|
|
it('groups children under ROOT_PARENT_KEY and real parent ids', () => {
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
|
|
role: 'user',
|
|
content: 'root',
|
|
}),
|
|
createMessageEntry('child-a', 'root', '2026-04-19T00:01:00.000Z', {
|
|
role: 'assistant',
|
|
content: 'child-a',
|
|
}),
|
|
createMessageEntry('child-b', 'root', '2026-04-19T00:02:00.000Z', {
|
|
role: 'assistant',
|
|
content: 'child-b',
|
|
}),
|
|
];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(createSession('child-b'), entries);
|
|
|
|
expect(snapshot.childrenByParentId).toEqual({
|
|
[ROOT_PARENT_KEY]: ['root'],
|
|
root: ['child-a', 'child-b'],
|
|
});
|
|
});
|
|
|
|
it('uses the latest label state and respects undefined clears', () => {
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
|
|
role: 'user',
|
|
content: 'root',
|
|
}),
|
|
createMessageEntry('leaf', 'root', '2026-04-19T00:00:01.000Z', {
|
|
role: 'assistant',
|
|
content: 'leaf',
|
|
}),
|
|
createLabelEntry('label-root-old', 'leaf', '2026-04-19T00:00:02.000Z', 'root', 'old'),
|
|
createLabelEntry('label-root-new', 'leaf', '2026-04-19T00:00:03.000Z', 'root', 'new'),
|
|
createLabelEntry('label-leaf', 'leaf', '2026-04-19T00:00:04.000Z', 'leaf', 'keep'),
|
|
createLabelEntry('label-leaf-clear', 'leaf', '2026-04-19T00:00:05.000Z', 'leaf', undefined),
|
|
];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(createSession('leaf'), entries);
|
|
|
|
expect(snapshot.labelsByEntryId).toEqual({
|
|
root: {
|
|
label: 'new',
|
|
timestamp: '2026-04-19T00:00:03.000Z',
|
|
entryId: 'label-root-new',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns an empty active path and empty runtime messages when leafEntryId is null', () => {
|
|
const session = createSession(null);
|
|
session.rootEntryId = null;
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', {
|
|
role: 'user',
|
|
content: 'root',
|
|
}),
|
|
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', {
|
|
role: 'assistant',
|
|
content: 'must not leak',
|
|
}),
|
|
];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(session, entries);
|
|
|
|
expect(snapshot.activePath).toEqual([]);
|
|
expect(snapshot.runtimeContext.messages).toEqual([]);
|
|
expect(snapshot.runtimeContext.thinkingLevel).toBe('off');
|
|
expect(snapshot.runtimeContext.model).toBeNull();
|
|
});
|
|
|
|
it('propagates missing parent errors from the active path resolution', () => {
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('orphan', 'missing-parent', '2026-04-19T00:00:00.000Z', {
|
|
role: 'user',
|
|
content: 'broken',
|
|
}),
|
|
];
|
|
|
|
expect(() => buildSessionTreeSnapshot(createSession('orphan'), entries)).toThrow(
|
|
'Parent entry missing-parent not found',
|
|
);
|
|
});
|
|
|
|
it('keeps subagent batch entries in the active path and snapshot entries but excludes them from runtime context', () => {
|
|
const runtimeEvent = {
|
|
sequence: 1,
|
|
timestamp: '2026-04-19T00:01:05.000Z',
|
|
event: {
|
|
kind: 'result',
|
|
status: 'completed',
|
|
subagent: {
|
|
id: 'subagent_1',
|
|
summary: 'done',
|
|
},
|
|
},
|
|
};
|
|
|
|
const rootMessage = { role: 'user', content: 'run subagents' } satisfies SessionTreeMessage;
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
|
|
createSubagentBatchEntry('subagent-batch', 'root', '2026-04-19T00:01:00.000Z', 'completed', {
|
|
latestEvent: runtimeEvent,
|
|
events: [runtimeEvent],
|
|
finalResults: [
|
|
{
|
|
id: 'subagent_1',
|
|
summary: 'done',
|
|
},
|
|
],
|
|
}),
|
|
];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(createSession('subagent-batch'), entries);
|
|
|
|
expect(snapshot.activePath.map(entry => entry.id)).toEqual(['root', 'subagent-batch']);
|
|
expect(snapshot.entries.find(entry => entry.id === 'subagent-batch')).toMatchObject({
|
|
metadata: {
|
|
latestEvent: runtimeEvent,
|
|
events: [runtimeEvent],
|
|
finalResults: [
|
|
{
|
|
id: 'subagent_1',
|
|
summary: 'done',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(snapshot.runtimeContext.messages).toEqual([rootMessage]);
|
|
expect(snapshot.runtimeContext.thinkingLevel).toBe('off');
|
|
expect(snapshot.runtimeContext.model).toBeNull();
|
|
});
|
|
|
|
it('keeps subagent result evidence and process metadata in snapshots without leaking it into runtime context', () => {
|
|
const evidenceSummary = {
|
|
kind: 'file-count',
|
|
title: '图片文件统计',
|
|
count: 2,
|
|
files: ['1.jpg', '2.png'],
|
|
summary: ['根据工具结果统计,图片文件共 2 个。', '- 1.jpg', '- 2.png'].join('\n'),
|
|
};
|
|
const processEvent = {
|
|
type: 'tool_call',
|
|
runId: 'subagent-101',
|
|
timestamp: '2026-04-19T00:01:02.000Z',
|
|
name: 'find_files',
|
|
toolCallId: 'tool-call-1',
|
|
};
|
|
const rootMessage = { role: 'user', content: 'run subagents' } satisfies SessionTreeMessage;
|
|
const entries: SessionTreeEntry[] = [
|
|
createMessageEntry('root', null, '2026-04-19T00:00:00.000Z', rootMessage),
|
|
createSubagentResultEntry('subagent-result', 'root', '2026-04-19T00:01:00.000Z', {
|
|
evidenceSummaries: [evidenceSummary],
|
|
processEvents: [processEvent],
|
|
}),
|
|
];
|
|
|
|
const snapshot = buildSessionTreeSnapshot(createSession('subagent-result'), entries);
|
|
|
|
expect(snapshot.activePath.map(entry => entry.id)).toEqual(['root', 'subagent-result']);
|
|
expect(snapshot.entries.find(entry => entry.id === 'subagent-result')).toMatchObject({
|
|
type: 'subagent_result',
|
|
metadata: {
|
|
evidenceSummaries: [evidenceSummary],
|
|
processEvents: [processEvent],
|
|
},
|
|
});
|
|
expect(snapshot.runtimeContext.messages).toEqual([rootMessage]);
|
|
expect(snapshot.runtimeContext.thinkingLevel).toBe('off');
|
|
expect(snapshot.runtimeContext.model).toBeNull();
|
|
});
|
|
});
|