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]>
120 lines
5.0 KiB
TypeScript
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()
|
|
})
|
|
})
|