Files
apress/api/src/nodes.test.ts
T
Omar SobhandClaude Opus 4.8 8ea9c256b4 fix(nodes): surface the tool result as the agent's reply, not just "finished"
The Module 2 chat showed lifecycle labels ("Agent started / Running i2c_scan /
Agent finished") but never the actual answer. mapNodeEvent dropped the
`tool_call_result` log line, and the observability `agent_end` event is slow and
content-free (the native tool path leaves the final text empty). The real reply
lives in tool_call_result.attributes.output — e.g. i2c_scan →
"No I2C devices responded on the bus." Map it to a `response` activity (or
`error` on a failed tool), so the chat shows the outcome. Falls back to a
"<tool> ✓" marker when a tool returns no output (e.g. a matrix write).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:37:17 -07:00

120 lines
5.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { createNodeRegistry, mapNodeEvent } from './nodes'
describe('node registry', () => {
it('registers, gets, lists and removes nodes', () => {
const reg = createNodeRegistry()
expect(reg.list()).toEqual([])
reg.register({ teamId: 't1', url: 'http://127.0.0.1:8080', token: 'zc_a' })
reg.register({ teamId: 't2', url: 'http://127.0.0.1:8081', token: 'zc_b' })
expect(reg.get('t1')).toEqual({ teamId: 't1', url: 'http://127.0.0.1:8080', token: 'zc_a' })
expect(reg.list()).toHaveLength(2)
// re-registering the same team replaces, not duplicates
reg.register({ teamId: 't1', url: 'http://127.0.0.1:9090', token: 'zc_c' })
expect(reg.list()).toHaveLength(2)
expect(reg.get('t1')?.url).toBe('http://127.0.0.1:9090')
reg.remove('t1')
expect(reg.get('t1')).toBeUndefined()
expect(reg.list()).toHaveLength(1)
})
it('seeds from an initial list', () => {
const reg = createNodeRegistry([{ teamId: 't1', url: 'u', token: 'k' }])
expect(reg.get('t1')?.token).toBe('k')
})
})
describe('mapNodeEvent — ZeroClaw /api/events → WsEvent', () => {
it('maps agent_start to a thinking activity', () => {
const ev = mapNodeEvent('t1', { type: 'agent_start', timestamp: '2026-07-03T11:00:00Z' })
expect(ev).toEqual({
type: 'node:activity',
teamId: 't1',
kind: 'thinking',
label: expect.any(String),
ts: '2026-07-03T11:00:00Z',
})
})
it('classifies a uno_q_flash tool call as a flash activity', () => {
const ev = mapNodeEvent('t1', { type: 'tool_call_start', tool: 'uno_q_flash', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'flash' })
})
it('classifies a non-flash tool call as a tool activity', () => {
const ev = mapNodeEvent('t1', { type: 'tool_call_start', tool: 'sysfs_led', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'tool', label: expect.stringContaining('sysfs_led') })
})
it('maps a successful flash log message to a flash activity with the address', () => {
const ev = mapNodeEvent('t1', {
'@timestamp': '2026-07-03T11:34:00Z',
message: "Sketch compiled and flashed to the Uno Q MCU sketch partition at 0x80F0000. 'reset run' issued.",
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'flash' })
expect((ev as { label: string }).label).toContain('0x80F0000')
expect((ev as { ts: string }).ts).toBe('2026-07-03T11:34:00Z')
})
it('maps a compile error / failure to an error activity', () => {
const ev = mapNodeEvent('t1', { severity_text: 'ERROR', message: 'Arduino compile error — fix the sketch' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'error' })
})
it('maps a cloud-exhausted / failover log to a fallback activity (not an error)', () => {
const ev = mapNodeEvent('t1', {
severity_text: 'ERROR',
message: 'Exhausted retries, trying next model_provider/model',
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'fallback' })
expect((ev as { label: string }).label).toMatch(/on-board Qwen/i)
})
it('maps a mid-retry ModelProvider failure to a fallback activity', () => {
const ev = mapNodeEvent('t1', { message: 'ModelProvider call failed, retrying' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'fallback' })
})
it('maps agent_end to a response activity', () => {
const ev = mapNodeEvent('t1', { type: 'agent_end', timestamp: 'T' })
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response' })
})
it('surfaces a tool_call_result output as the response text (the real answer)', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'No I2C devices responded on the bus.', error_reason: null },
event: { action: 'complete', category: 'tool', outcome: 'success' },
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response', label: 'No I2C devices responded on the bus.' })
})
it('falls back to a tool ✓ marker when a tool result carries no output', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'matrix_text', output: '' },
event: { outcome: 'success' },
})
expect(ev).toMatchObject({ kind: 'response', label: 'matrix_text ✓' })
})
it('maps a failed tool result to an error activity', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'bridge unreachable', error_reason: 'timeout' },
event: { outcome: 'failure' },
})
expect(ev).toMatchObject({ kind: 'error', label: 'bridge unreachable' })
})
it('ignores noisy/internal events (llm_request, plain notes, non-objects)', () => {
expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull()
expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull()
expect(mapNodeEvent('t1', null)).toBeNull()
expect(mapNodeEvent('t1', 'nope')).toBeNull()
})
})