mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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.
209 lines
6.3 KiB
TypeScript
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)
|
|
})
|
|
})
|