Files
ChatLab/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts
T
digua 2d58fd8c62 fix(ai): track parallel tool lifecycles
Keep the shared runtime in tool_running until every concurrent call finishes. Route renderer progress and completion by toolCallId so one result cannot hide another active tool.
2026-08-18 17:27:48 +08:00

209 lines
6.3 KiB
TypeScript

/**
* Tests for shared AgentEventHandler.
*
* Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { AgentEventHandler, estimateTokensFromText } from '../event-handler'
import type { AgentStreamChunk } from '../event-handler'
describe('estimateTokensFromText', () => {
it('returns 0 for empty text', () => {
assert.equal(estimateTokensFromText(''), 0)
})
it('estimates latin text at ~4 chars per token', () => {
const estimate = estimateTokensFromText('hello world, this is a test')
assert.ok(estimate > 0)
assert.ok(estimate < 20)
})
it('estimates CJK text at ~1 char per token', () => {
const estimate = estimateTokensFromText('你好世界这是测试')
assert.ok(estimate >= 8)
})
})
describe('AgentEventHandler', () => {
it('tracks tool usage on tool_start events', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: (c) => chunks.push(c),
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent(
{ type: 'tool_start', toolCallId: 'call_1', toolName: 'search', toolParams: { q: 'test' } },
[]
)
assert.deepEqual(handler.toolsUsed, ['search'])
const startChunk = chunks.find((c) => c.type === 'tool_start')
assert.equal(startChunk?.toolCallId, 'call_1')
})
it('keeps tool_running status until every parallel tool call finishes', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: (chunk) => chunks.push(chunk),
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent({ type: 'tool_start', toolCallId: 'slow-call', toolName: 'slow_tool', toolParams: {} }, [])
handler.handleCoreEvent({ type: 'tool_start', toolCallId: 'fast-call', toolName: 'fast_tool', toolParams: {} }, [])
handler.handleCoreEvent(
{ type: 'tool_end', toolCallId: 'fast-call', toolName: 'fast_tool', toolResult: null, isError: false },
[]
)
const statusesAfterFirstResult = chunks.filter((chunk) => chunk.type === 'status')
assert.equal(statusesAfterFirstResult.at(-1)?.status?.phase, 'tool_running')
assert.equal(statusesAfterFirstResult.at(-1)?.status?.currentTool, 'slow_tool')
handler.handleCoreEvent(
{ type: 'tool_end', toolCallId: 'slow-call', toolName: 'slow_tool', toolResult: null, isError: false },
[]
)
const finalStatuses = chunks.filter((chunk) => chunk.type === 'status')
assert.equal(finalStatuses.at(-1)?.status?.phase, 'thinking')
})
it('forwards tool progress without changing the result payload', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: (chunk) => chunks.push(chunk),
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent(
{
type: 'tool_update',
toolCallId: 'call_1',
toolName: 'retrieve_chat_evidence',
progress: { phase: 'semantic_search' },
},
[]
)
assert.deepEqual(chunks, [
{
type: 'tool_update',
toolCallId: 'call_1',
toolName: 'retrieve_chat_evidence',
toolProgress: { phase: 'semantic_search' },
},
])
})
it('updates tool rounds on turn_end', () => {
const handler = new AgentEventHandler({
onChunk: () => {},
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent({ type: 'turn_end', round: 3, hadToolCalls: true }, [])
assert.equal(handler.toolRounds, 3)
})
it('cloneUsage returns independent copy', () => {
const handler = new AgentEventHandler({
onChunk: () => {},
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent(
{
type: 'usage_update',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheReadTokens: 30, cacheWriteTokens: 10 },
},
[]
)
const usage = handler.cloneUsage()
assert.equal(usage.totalTokens, 150)
assert.equal(usage.cacheReadTokens, 30)
assert.equal(usage.cacheWriteTokens, 10)
handler.handleCoreEvent(
{
type: 'usage_update',
usage: {
promptTokens: 200,
completionTokens: 100,
totalTokens: 300,
cacheReadTokens: 60,
cacheWriteTokens: 20,
},
},
[]
)
assert.equal(usage.totalTokens, 150, 'clone should be independent')
})
it('normalizes tool params with context limits', () => {
const handler = new AgentEventHandler({
onChunk: () => {},
context: { maxMessagesLimit: 10, timeFilter: { startTs: 100, endTs: 200 } },
systemPrompt: 'test',
})
const params = handler.normalizeToolParams('search_messages', { query: 'test' })
assert.equal(params.limit, 10)
assert.deepEqual(params._timeFilter, { startTs: 100, endTs: 200 })
})
it('emits content chunks', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: (c) => chunks.push(c),
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent({ type: 'content', content: 'Hello' }, [])
const contentChunks = chunks.filter((c) => c.type === 'content')
assert.equal(contentChunks.length, 1)
assert.equal(contentChunks[0].content, 'Hello')
})
it('passes chart tool results through unchanged', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: (c) => chunks.push(c),
context: {},
systemPrompt: 'test',
})
const toolResult = {
content: 'Chart generated.',
details: {
chart: {
version: 1,
type: 'pie',
title: 'Selected members',
data: [{ label: 'Alice', value: 3 }],
source: { rowCount: 1, truncated: false },
},
},
}
handler.handleCoreEvent(
{ type: 'tool_end', toolCallId: 'call_chart', toolName: 'render_chart', toolResult, isError: false },
[]
)
const resultChunk = chunks.find((c) => c.type === 'tool_result')
assert.equal(resultChunk?.toolName, 'render_chart')
assert.deepEqual(resultChunk?.toolResult, toolResult)
assert.equal(resultChunk?.toolCallId, 'call_chart')
assert.equal(resultChunk?.toolIsError, false)
})
})