test(v2): add vitest and cargo tests for sdk-messages, agent-tree, session, ctx
Frontend (vitest): - sdk-messages.test.ts: adaptSDKMessage() for all 9 message types - agent-tree.test.ts: buildAgentTree(), countTreeNodes(), subtreeCost() - vite.config.ts: vitest test config (src/**/*.test.ts) - package.json: vitest ^4.0.18 dev dep, "test" script Backend (cargo): - session.rs: SessionDb CRUD tests (sessions, SSH, settings, layout) with tempfile - ctx.rs: CtxDb error handling tests with missing database - Cargo.toml: tempfile 3 dev dependency
This commit is contained in:
parent
7e6e777713
commit
35a515db25
9 changed files with 1482 additions and 3 deletions
446
v2/src/lib/adapters/sdk-messages.test.ts
Normal file
446
v2/src/lib/adapters/sdk-messages.test.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { adaptSDKMessage } from './sdk-messages';
|
||||
import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent, ErrorContent } from './sdk-messages';
|
||||
|
||||
// Mock crypto.randomUUID for deterministic IDs when uuid is missing
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => 'fallback-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptSDKMessage', () => {
|
||||
describe('system/init messages', () => {
|
||||
it('adapts a system init message', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'sys-001',
|
||||
session_id: 'sess-abc',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
cwd: '/home/user/project',
|
||||
tools: ['Read', 'Write', 'Bash'],
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('sys-001');
|
||||
expect(result[0].type).toBe('init');
|
||||
const content = result[0].content as InitContent;
|
||||
expect(content.sessionId).toBe('sess-abc');
|
||||
expect(content.model).toBe('claude-sonnet-4-20250514');
|
||||
expect(content.cwd).toBe('/home/user/project');
|
||||
expect(content.tools).toEqual(['Read', 'Write', 'Bash']);
|
||||
});
|
||||
|
||||
it('defaults tools to empty array when missing', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'sys-002',
|
||||
session_id: 'sess-abc',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
cwd: '/tmp',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as InitContent;
|
||||
expect(content.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system/status messages (non-init subtypes)', () => {
|
||||
it('adapts a system status message', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'api_key_check',
|
||||
uuid: 'sys-003',
|
||||
status: 'API key is valid',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('status');
|
||||
const content = result[0].content as StatusContent;
|
||||
expect(content.subtype).toBe('api_key_check');
|
||||
expect(content.message).toBe('API key is valid');
|
||||
});
|
||||
|
||||
it('handles missing status field', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'some_event',
|
||||
uuid: 'sys-004',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as StatusContent;
|
||||
expect(content.subtype).toBe('some_event');
|
||||
expect(content.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/text messages', () => {
|
||||
it('adapts a single text block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-001',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Hello, world!' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[0].id).toBe('asst-001-text-0');
|
||||
const content = result[0].content as TextContent;
|
||||
expect(content.text).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('preserves parentId on assistant messages', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-002',
|
||||
parent_tool_use_id: 'tool-parent-123',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'subagent response' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].parentId).toBe('tool-parent-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/thinking messages', () => {
|
||||
it('adapts a thinking block with thinking field', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-003',
|
||||
message: {
|
||||
content: [{ type: 'thinking', thinking: 'Let me consider...', text: 'fallback' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('thinking');
|
||||
expect(result[0].id).toBe('asst-003-think-0');
|
||||
const content = result[0].content as ThinkingContent;
|
||||
expect(content.text).toBe('Let me consider...');
|
||||
});
|
||||
|
||||
it('falls back to text field when thinking is absent', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-004',
|
||||
message: {
|
||||
content: [{ type: 'thinking', text: 'Thinking via text field' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as ThinkingContent;
|
||||
expect(content.text).toBe('Thinking via text field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/tool_use messages', () => {
|
||||
it('adapts a tool_use block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-005',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_abc123',
|
||||
name: 'Read',
|
||||
input: { file_path: '/src/main.ts' },
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_call');
|
||||
expect(result[0].id).toBe('asst-005-tool-0');
|
||||
const content = result[0].content as ToolCallContent;
|
||||
expect(content.toolUseId).toBe('toolu_abc123');
|
||||
expect(content.name).toBe('Read');
|
||||
expect(content.input).toEqual({ file_path: '/src/main.ts' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages with multiple content blocks', () => {
|
||||
it('produces one AgentMessage per content block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-multi',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Hmm...' },
|
||||
{ type: 'text', text: 'Here is the answer.' },
|
||||
{ type: 'tool_use', id: 'toolu_xyz', name: 'Bash', input: { command: 'ls' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].type).toBe('thinking');
|
||||
expect(result[0].id).toBe('asst-multi-think-0');
|
||||
expect(result[1].type).toBe('text');
|
||||
expect(result[1].id).toBe('asst-multi-text-1');
|
||||
expect(result[2].type).toBe('tool_call');
|
||||
expect(result[2].id).toBe('asst-multi-tool-2');
|
||||
});
|
||||
|
||||
it('skips unknown content block types silently', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-unk-block',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', data: 'base64...' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user/tool_result messages', () => {
|
||||
it('adapts a tool_result block', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-001',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_abc123',
|
||||
content: 'file contents here',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_result');
|
||||
expect(result[0].id).toBe('user-001-result-0');
|
||||
const content = result[0].content as ToolResultContent;
|
||||
expect(content.toolUseId).toBe('toolu_abc123');
|
||||
expect(content.output).toBe('file contents here');
|
||||
});
|
||||
|
||||
it('falls back to tool_use_result when block content is missing', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-002',
|
||||
tool_use_result: { status: 'success', output: 'done' },
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_def456',
|
||||
// no content field
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as ToolResultContent;
|
||||
expect(content.output).toEqual({ status: 'success', output: 'done' });
|
||||
});
|
||||
|
||||
it('preserves parentId on user messages', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-003',
|
||||
parent_tool_use_id: 'parent-tool-id',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_ghi',
|
||||
content: 'ok',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].parentId).toBe('parent-tool-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('result/cost messages', () => {
|
||||
it('adapts a full result message', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-001',
|
||||
total_cost_usd: 0.0125,
|
||||
duration_ms: 4500,
|
||||
usage: { input_tokens: 1000, output_tokens: 500 },
|
||||
num_turns: 3,
|
||||
is_error: false,
|
||||
result: 'Task completed successfully.',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('cost');
|
||||
expect(result[0].id).toBe('res-001');
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.totalCostUsd).toBe(0.0125);
|
||||
expect(content.durationMs).toBe(4500);
|
||||
expect(content.inputTokens).toBe(1000);
|
||||
expect(content.outputTokens).toBe(500);
|
||||
expect(content.numTurns).toBe(3);
|
||||
expect(content.isError).toBe(false);
|
||||
expect(content.result).toBe('Task completed successfully.');
|
||||
expect(content.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults numeric fields to 0 when missing', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-002',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.totalCostUsd).toBe(0);
|
||||
expect(content.durationMs).toBe(0);
|
||||
expect(content.inputTokens).toBe(0);
|
||||
expect(content.outputTokens).toBe(0);
|
||||
expect(content.numTurns).toBe(0);
|
||||
expect(content.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('includes errors array when present', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-003',
|
||||
is_error: true,
|
||||
errors: ['Rate limit exceeded', 'Retry failed'],
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.isError).toBe(true);
|
||||
expect(content.errors).toEqual(['Rate limit exceeded', 'Retry failed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns unknown type for unrecognized message types', () => {
|
||||
const raw = {
|
||||
type: 'something_new',
|
||||
uuid: 'unk-001',
|
||||
data: 'arbitrary',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('unknown');
|
||||
expect(result[0].id).toBe('unk-001');
|
||||
expect(result[0].content).toBe(raw);
|
||||
});
|
||||
|
||||
it('uses crypto.randomUUID when uuid is missing', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
total_cost_usd: 0.001,
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].id).toBe('fallback-uuid');
|
||||
});
|
||||
|
||||
it('returns empty array when assistant message has no message field', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-empty',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when assistant message.content is not an array', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-bad-content',
|
||||
message: { content: 'not-an-array' },
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when user message has no message field', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-empty',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when user message.content is not an array', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-bad',
|
||||
message: { content: 'string' },
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores non-tool_result blocks in user messages', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-text',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'User typed something' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_result');
|
||||
});
|
||||
|
||||
it('sets timestamp on every message', () => {
|
||||
const before = Date.now();
|
||||
const result = adaptSDKMessage({
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'ts-test',
|
||||
session_id: 's',
|
||||
model: 'm',
|
||||
cwd: '/',
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
expect(result[0].timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(result[0].timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
});
|
||||
297
v2/src/lib/utils/agent-tree.test.ts
Normal file
297
v2/src/lib/utils/agent-tree.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree';
|
||||
import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/sdk-messages';
|
||||
import type { AgentTreeNode } from './agent-tree';
|
||||
|
||||
// Helper to create typed AgentMessages
|
||||
function makeToolCall(
|
||||
uuid: string,
|
||||
toolUseId: string,
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'tool_call',
|
||||
parentId,
|
||||
content: { toolUseId, name, input: {} } satisfies ToolCallContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolResult(uuid: string, toolUseId: string, parentId?: string): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'tool_result',
|
||||
parentId,
|
||||
content: { toolUseId, output: 'ok' } satisfies ToolResultContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTextMessage(uuid: string, text: string, parentId?: string): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'text',
|
||||
parentId,
|
||||
content: { text },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAgentTree', () => {
|
||||
it('creates a root node with no children from empty messages', () => {
|
||||
const tree = buildAgentTree('session-1', [], 'done', 0.05, 1500);
|
||||
|
||||
expect(tree.id).toBe('session-1');
|
||||
expect(tree.label).toBe('session-');
|
||||
expect(tree.status).toBe('done');
|
||||
expect(tree.costUsd).toBe(0.05);
|
||||
expect(tree.tokens).toBe(1500);
|
||||
expect(tree.children).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps running/starting status to running', () => {
|
||||
const tree1 = buildAgentTree('s1', [], 'running', 0, 0);
|
||||
expect(tree1.status).toBe('running');
|
||||
|
||||
const tree2 = buildAgentTree('s2', [], 'starting', 0, 0);
|
||||
expect(tree2.status).toBe('running');
|
||||
});
|
||||
|
||||
it('maps error status to error', () => {
|
||||
const tree = buildAgentTree('s3', [], 'error', 0, 0);
|
||||
expect(tree.status).toBe('error');
|
||||
});
|
||||
|
||||
it('maps other statuses to done', () => {
|
||||
const tree = buildAgentTree('s4', [], 'completed', 0, 0);
|
||||
expect(tree.status).toBe('done');
|
||||
});
|
||||
|
||||
it('adds tool_call messages as children of root', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Read'),
|
||||
makeToolCall('m2', 'tool-2', 'Write'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(2);
|
||||
expect(tree.children[0].id).toBe('tool-1');
|
||||
expect(tree.children[0].label).toBe('Read');
|
||||
expect(tree.children[0].toolName).toBe('Read');
|
||||
expect(tree.children[1].id).toBe('tool-2');
|
||||
expect(tree.children[1].label).toBe('Write');
|
||||
});
|
||||
|
||||
it('marks tool nodes as running until a result arrives', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Bash'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'running', 0, 0);
|
||||
expect(tree.children[0].status).toBe('running');
|
||||
});
|
||||
|
||||
it('marks tool nodes as done when result arrives', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Bash'),
|
||||
makeToolResult('m2', 'tool-1'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
expect(tree.children[0].status).toBe('done');
|
||||
});
|
||||
|
||||
it('nests subagent tool calls under their parent tool node', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-parent', 'Agent'),
|
||||
makeToolCall('m2', 'tool-child', 'Read', 'tool-parent'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const parentNode = tree.children[0];
|
||||
expect(parentNode.id).toBe('tool-parent');
|
||||
expect(parentNode.children).toHaveLength(1);
|
||||
expect(parentNode.children[0].id).toBe('tool-child');
|
||||
expect(parentNode.children[0].label).toBe('Read');
|
||||
});
|
||||
|
||||
it('handles deeply nested subagents (3 levels)', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'level-1', 'Agent'),
|
||||
makeToolCall('m2', 'level-2', 'SubAgent', 'level-1'),
|
||||
makeToolCall('m3', 'level-3', 'Read', 'level-2'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].children).toHaveLength(1);
|
||||
expect(tree.children[0].children[0].children).toHaveLength(1);
|
||||
expect(tree.children[0].children[0].children[0].id).toBe('level-3');
|
||||
});
|
||||
|
||||
it('attaches to root when parentId references a non-existent tool node', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'orphan-tool', 'Bash', 'nonexistent-parent'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].id).toBe('orphan-tool');
|
||||
});
|
||||
|
||||
it('ignores non-tool messages (text, thinking, etc.)', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeTextMessage('m1', 'Hello'),
|
||||
makeToolCall('m2', 'tool-1', 'Read'),
|
||||
makeTextMessage('m3', 'Done'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].id).toBe('tool-1');
|
||||
});
|
||||
|
||||
it('handles tool_result for a non-existent tool gracefully', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolResult('m1', 'nonexistent-tool'),
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
expect(tree.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('truncates session ID to 8 chars for label', () => {
|
||||
const tree = buildAgentTree('abcdefghijklmnop', [], 'done', 0, 0);
|
||||
expect(tree.label).toBe('abcdefgh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countTreeNodes', () => {
|
||||
it('returns 1 for a leaf node', () => {
|
||||
const leaf: AgentTreeNode = {
|
||||
id: 'leaf',
|
||||
label: 'leaf',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [],
|
||||
};
|
||||
expect(countTreeNodes(leaf)).toBe(1);
|
||||
});
|
||||
|
||||
it('counts all nodes in a flat tree', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'c', label: 'c', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(countTreeNodes(root)).toBe(4);
|
||||
});
|
||||
|
||||
it('counts all nodes in a nested tree', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{
|
||||
id: 'a',
|
||||
label: 'a',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'a2', label: 'a2', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
},
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(countTreeNodes(root)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtreeCost', () => {
|
||||
it('returns own cost for a leaf node', () => {
|
||||
const leaf: AgentTreeNode = {
|
||||
id: 'leaf',
|
||||
label: 'leaf',
|
||||
status: 'done',
|
||||
costUsd: 0.05,
|
||||
tokens: 0,
|
||||
children: [],
|
||||
};
|
||||
expect(subtreeCost(leaf)).toBe(0.05);
|
||||
});
|
||||
|
||||
it('aggregates cost across children', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0.10,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0.03, tokens: 0, children: [] },
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0.02, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBeCloseTo(0.15);
|
||||
});
|
||||
|
||||
it('aggregates cost recursively across nested children', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 1.0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{
|
||||
id: 'a',
|
||||
label: 'a',
|
||||
status: 'done',
|
||||
costUsd: 0.5,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0.25, tokens: 0, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBeCloseTo(1.75);
|
||||
});
|
||||
|
||||
it('returns 0 for a tree with all zero costs', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue