487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
import {
|
|
buildSubagentProjectionForEntry,
|
|
projectSubagentEntries,
|
|
} from '../src/modules/netaclaw/session-tree/subagent_projection.js';
|
|
import type { SessionTreeEntry } from '../src/modules/netaclaw/session-tree/types.js';
|
|
|
|
describe('session tree subagent projection', () => {
|
|
it('projects subagent_result entries into canonical panels, evidence, and process events', () => {
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-result',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T10:00:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: 'batch-1',
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: [
|
|
{
|
|
id: 11,
|
|
sortOrder: 0,
|
|
status: 'completed',
|
|
goal: '检查桌面图片',
|
|
summary: '共有 3 张图片',
|
|
resultPayload: {
|
|
evidenceSummary: {
|
|
kind: 'file-count',
|
|
title: '图片文件统计',
|
|
summary: '根据工具结果统计,图片文件共 3 个。',
|
|
files: ['111.jpg', '11123.jpg', '20260417-185140.jpg'],
|
|
count: 3,
|
|
},
|
|
processEvents: [
|
|
{
|
|
type: 'run_start',
|
|
runId: 'subagent-11',
|
|
timestamp: '2026-04-22T10:00:01.000Z',
|
|
},
|
|
{
|
|
type: 'tool_call',
|
|
runId: 'subagent-11',
|
|
timestamp: '2026-04-22T10:00:02.000Z',
|
|
toolCallId: 'call-images',
|
|
name: 'bash',
|
|
label: '执行命令',
|
|
args: { command: 'find ~/Desktop -maxdepth 1 -type f -iname "*.jpg"' },
|
|
},
|
|
{
|
|
type: 'tool_result',
|
|
runId: 'subagent-11',
|
|
timestamp: '2026-04-22T10:00:03.000Z',
|
|
toolCallId: 'call-images',
|
|
name: 'bash',
|
|
label: '执行命令',
|
|
result: '111.jpg\n11123.jpg\n20260417-185140.jpg',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
metadata: {},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection).toBeTruthy();
|
|
expect(projection).toMatchObject({
|
|
version: 1,
|
|
source: 'backend-projection',
|
|
diagnostics: {
|
|
projectionPath: 'canonical-backend-projection',
|
|
selectedSource: 'subagent_result',
|
|
inputSources: ['subagent_result'],
|
|
rawResultCount: 1,
|
|
dedupedResultCount: 1,
|
|
projectedTaskCount: 1,
|
|
skippedResultCount: 0,
|
|
fallbackUsed: false,
|
|
},
|
|
});
|
|
expect(projection?.taskPanels).toHaveLength(1);
|
|
expect(projection?.taskPanels[0]).toMatchObject({
|
|
key: '11',
|
|
title: '检查桌面图片',
|
|
status: 'completed',
|
|
sortOrder: 0,
|
|
summary: '共有 3 张图片',
|
|
});
|
|
expect(projection?.taskPanels[0].evidenceSummaries).toHaveLength(1);
|
|
expect(projection?.taskPanels[0].toolExecutions).toHaveLength(1);
|
|
expect(projection?.taskPanels[0].toolExecutions[0]).toMatchObject({
|
|
toolCallId: 'call-images',
|
|
status: 'completed',
|
|
result: '111.jpg\n11123.jpg\n20260417-185140.jpg',
|
|
});
|
|
expect(projection?.evidenceSummaries).toHaveLength(1);
|
|
expect(projection?.processEvents).toHaveLength(3);
|
|
});
|
|
|
|
it('prefers persisted subagent_result metadata.finalResults when present', () => {
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-result-metadata-final-results',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T10:30:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: 'batch-1',
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: [
|
|
{
|
|
id: 11,
|
|
sortOrder: 0,
|
|
status: 'completed',
|
|
goal: '旧 content 任务',
|
|
summary: '来自 content.results',
|
|
resultPayload: {},
|
|
},
|
|
],
|
|
},
|
|
metadata: {
|
|
finalResults: [
|
|
{
|
|
id: 22,
|
|
sortOrder: 1,
|
|
status: 'completed',
|
|
goal: '正式 metadata 任务',
|
|
summary: '来自 metadata.finalResults',
|
|
resultPayload: {},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection?.taskPanels).toHaveLength(1);
|
|
expect(projection?.taskPanels[0]).toMatchObject({
|
|
key: '22',
|
|
title: '正式 metadata 任务',
|
|
summary: '来自 metadata.finalResults',
|
|
sortOrder: 1,
|
|
});
|
|
expect(projection?.diagnostics).toMatchObject({
|
|
projectionPath: 'canonical-backend-projection',
|
|
selectedSource: 'subagent_result',
|
|
rawResultCount: 1,
|
|
dedupedResultCount: 1,
|
|
projectedTaskCount: 1,
|
|
fallbackUsed: false,
|
|
});
|
|
});
|
|
|
|
it('reads subagent_result top-level metadata evidence and process events through the canonical metadata path', () => {
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-result-metadata-replay',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T10:40:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: 'batch-metadata-replay',
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: [],
|
|
},
|
|
metadata: {
|
|
finalResults: [
|
|
{
|
|
id: 51,
|
|
sortOrder: 0,
|
|
status: 'completed',
|
|
goal: 'metadata replay task',
|
|
summary: 'metadata result summary',
|
|
resultPayload: {},
|
|
},
|
|
],
|
|
evidenceSummaries: [
|
|
{
|
|
kind: 'tool-preview',
|
|
title: 'Metadata Evidence',
|
|
summary: 'metadata evidence summary',
|
|
},
|
|
],
|
|
processEvents: [
|
|
{
|
|
type: 'run_start',
|
|
runId: 'subagent-51',
|
|
timestamp: '2026-04-22T10:40:01.000Z',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection?.taskPanels).toHaveLength(1);
|
|
expect(projection?.taskPanels[0]).toMatchObject({
|
|
key: '51',
|
|
title: 'metadata replay task',
|
|
summary: 'metadata result summary',
|
|
});
|
|
expect(projection?.evidenceSummaries).toEqual([
|
|
expect.objectContaining({
|
|
title: 'Metadata Evidence',
|
|
summary: 'metadata evidence summary',
|
|
}),
|
|
]);
|
|
expect(projection?.processEvents).toEqual([
|
|
expect.objectContaining({
|
|
type: 'run_start',
|
|
runId: 'subagent-51',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('projects assistant delegate_parallel metadata and ignores stale entry metadata when message metadata exists', () => {
|
|
const freshPayload = {
|
|
delegationMode: 'session-subagent',
|
|
taskResults: [
|
|
{
|
|
task: { sortOrder: 0, goal: 'message metadata task' },
|
|
result: {
|
|
id: 21,
|
|
status: 'completed',
|
|
summary: 'from message metadata',
|
|
resultPayload: {},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const stalePayload = {
|
|
delegationMode: 'session-subagent',
|
|
taskResults: [
|
|
{
|
|
task: { sortOrder: 1, goal: 'entry metadata task' },
|
|
result: {
|
|
id: 22,
|
|
status: 'completed',
|
|
summary: 'from entry metadata',
|
|
resultPayload: {},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-message',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T11:00:00.000Z',
|
|
type: 'message',
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
metadata: {
|
|
skillExecutions: [
|
|
{
|
|
name: 'delegate_parallel',
|
|
result: freshPayload,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
metadata: {
|
|
skillExecutions: [
|
|
{
|
|
name: 'delegate_parallel',
|
|
result: stalePayload,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection?.taskPanels).toHaveLength(1);
|
|
expect(projection?.taskPanels[0]).toMatchObject({
|
|
key: '21',
|
|
title: 'message metadata task',
|
|
summary: 'from message metadata',
|
|
});
|
|
expect(projection?.diagnostics).toMatchObject({
|
|
projectionPath: 'canonical-backend-projection',
|
|
selectedSource: 'message_metadata',
|
|
inputSources: ['message_metadata', 'entry_metadata'],
|
|
rawResultCount: 1,
|
|
dedupedResultCount: 1,
|
|
projectedTaskCount: 1,
|
|
skippedResultCount: 0,
|
|
fallbackUsed: false,
|
|
ignoredSources: ['entry_metadata'],
|
|
});
|
|
});
|
|
|
|
it('exposes projection diagnostics for fallback and dedupe decisions', () => {
|
|
const payload = {
|
|
delegationMode: 'session-subagent',
|
|
taskResults: [
|
|
{
|
|
task: { sortOrder: 0, goal: 'fallback task' },
|
|
result: {
|
|
id: 41,
|
|
status: 'completed',
|
|
summary: 'first',
|
|
resultPayload: {},
|
|
},
|
|
},
|
|
{
|
|
task: { sortOrder: 0, goal: 'fallback task duplicate' },
|
|
result: {
|
|
id: 41,
|
|
status: 'completed',
|
|
summary: 'duplicate',
|
|
resultPayload: {},
|
|
},
|
|
},
|
|
{
|
|
task: { sortOrder: 2, goal: 'invalid task' },
|
|
result: null,
|
|
},
|
|
],
|
|
};
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-fallback-diagnostics',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T11:30:00.000Z',
|
|
type: 'message',
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
metadata: {},
|
|
},
|
|
metadata: {
|
|
skillExecutions: [
|
|
{
|
|
name: 'delegate_parallel',
|
|
result: payload,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection?.taskPanels).toHaveLength(1);
|
|
expect(projection?.diagnostics).toMatchObject({
|
|
projectionPath: 'canonical-backend-projection',
|
|
selectedSource: 'entry_metadata',
|
|
inputSources: ['entry_metadata'],
|
|
rawResultCount: 3,
|
|
dedupedResultCount: 1,
|
|
projectedTaskCount: 1,
|
|
skippedResultCount: 1,
|
|
fallbackUsed: true,
|
|
ignoredSources: [],
|
|
});
|
|
});
|
|
|
|
it('returns entries with metadata.subagentProjection without mutating originals', () => {
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-result',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T12:00:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: 'batch-2',
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: [
|
|
{
|
|
id: 31,
|
|
status: 'completed',
|
|
summary: 'done',
|
|
resultPayload: {},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const projected = projectSubagentEntries([entry]);
|
|
|
|
expect(projected).not.toBe([entry]);
|
|
expect(projected[0]).not.toBe(entry);
|
|
expect(entry.metadata).toBeUndefined();
|
|
expect(projected[0].metadata?.subagentProjection).toMatchObject({
|
|
version: 1,
|
|
source: 'backend-projection',
|
|
taskPanels: [
|
|
{
|
|
key: '31',
|
|
summary: 'done',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('exposes replay limits and truncation state when projection data is clipped', () => {
|
|
const entry: SessionTreeEntry = {
|
|
id: 'entry-clipped-result',
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T13:00:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: 'batch-clipped',
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: Array.from({ length: 10 }, (_, index) => ({
|
|
id: `result-${index}`,
|
|
sortOrder: index,
|
|
status: 'completed',
|
|
summary: `summary ${index}`,
|
|
resultPayload: {
|
|
evidenceSummary: {
|
|
kind: 'tool-preview',
|
|
title: `Evidence ${index}`,
|
|
summary: `Evidence summary ${index}`,
|
|
},
|
|
processEvents: Array.from({ length: index === 0 ? 55 : 0 }, (_event, eventIndex) => ({
|
|
type: 'log',
|
|
runId: 'subagent-clipped',
|
|
text: `event ${eventIndex}`,
|
|
})),
|
|
},
|
|
})),
|
|
},
|
|
};
|
|
|
|
const projection = buildSubagentProjectionForEntry(entry);
|
|
|
|
expect(projection).toMatchObject({
|
|
replay: {
|
|
limits: {
|
|
evidenceSummaries: 8,
|
|
processEvents: 50,
|
|
taskProcessEvents: 50,
|
|
toolExecutions: 24,
|
|
},
|
|
truncated: {
|
|
evidenceSummaries: true,
|
|
processEvents: true,
|
|
taskPanels: false,
|
|
toolExecutions: false,
|
|
},
|
|
},
|
|
});
|
|
expect(projection?.evidenceSummaries).toHaveLength(8);
|
|
expect(projection?.processEvents).toHaveLength(50);
|
|
expect(projection?.taskPanels[0].processEvents).toHaveLength(50);
|
|
});
|
|
|
|
it('marks tool execution truncation only when raw executions exceed the limit', () => {
|
|
const buildEntry = (toolCallCount: number): SessionTreeEntry => ({
|
|
id: `entry-tool-executions-${toolCallCount}`,
|
|
parentId: 'parent',
|
|
timestamp: '2026-04-22T14:00:00.000Z',
|
|
type: 'subagent_result',
|
|
content: {
|
|
batchId: `batch-tool-executions-${toolCallCount}`,
|
|
status: 'completed',
|
|
parentEntryId: 'parent',
|
|
results: [
|
|
{
|
|
id: `result-tool-executions-${toolCallCount}`,
|
|
status: 'completed',
|
|
summary: 'tool execution count',
|
|
resultPayload: {
|
|
processEvents: Array.from({ length: toolCallCount }, (_event, index) => ({
|
|
type: 'tool_call',
|
|
runId: 'subagent-tools',
|
|
toolCallId: `call-${index}`,
|
|
name: 'bash',
|
|
})),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const exactlyAtLimit = buildSubagentProjectionForEntry(buildEntry(24));
|
|
const aboveLimit = buildSubagentProjectionForEntry(buildEntry(25));
|
|
|
|
expect(exactlyAtLimit?.taskPanels[0].toolExecutions).toHaveLength(24);
|
|
expect(exactlyAtLimit?.replay.truncated.toolExecutions).toBe(false);
|
|
expect(aboveLimit?.taskPanels[0].toolExecutions).toHaveLength(24);
|
|
expect(aboveLimit?.replay.truncated.toolExecutions).toBe(true);
|
|
});
|
|
});
|