GPU_GUARD_MONOREPO/packages/backend/test/session_tree_snapshot.test.ts

332 lines
10 KiB
TypeScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
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();
});
});