| import { describe, it, expect, vi } from 'vitest' |
| import { createRenderingLoopHook } from '../../../src/rendering/hooks/rendering-loop-hook.js' |
| import type { RenderingSignalBus } from '../../../src/rendering/signal-bus.js' |
| import type { AfterToolExecutionContext } from '../../../src/im/loop-hooks.js' |
| import type { ToolTurn } from '../../../src/im/databus.js' |
|
|
| const mkTurn = (overrides: Partial<ToolTurn> = {}): ToolTurn => ({ |
| id: 't1', |
| role: 'tool', |
| toolCallId: 'tc-1', |
| content: '{}', |
| sourceAgentId: 'main', |
| at: 1, |
| ...overrides, |
| }) |
|
|
| const mkCtx = (toolResults: ToolTurn[]): AfterToolExecutionContext => ({ |
| turnId: 'turn-1', |
| stepNumber: 1, |
| signal: undefined, |
| toolResults, |
| errorCount: 0, |
| }) |
|
|
| type EmitCall = { toolName: string; turn: ToolTurn } |
|
|
| |
| const mkMockBus = (): { bus: RenderingSignalBus; emitCalls: EmitCall[] } => { |
| const emitCalls: EmitCall[] = [] |
| const bus: RenderingSignalBus = { |
| registerRule: () => () => {}, |
| onSignal: () => () => {}, |
| emit: async (toolName, turn) => { |
| emitCalls.push({ toolName, turn }) |
| }, |
| } |
| return { bus, emitCalls } |
| } |
|
|
| describe('rendering/rendering-loop-hook', () => { |
| it('forwards each toolResult to bus.emit in order, with the turn itself', async () => { |
| const { bus, emitCalls } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
| const turn1 = mkTurn({ id: 't1', toolCallId: 'tc-1', toolName: 'wiki__render_md', content: '{"a":1}' }) |
| const turn2 = mkTurn({ id: 't2', toolCallId: 'tc-2', toolName: 'grep', content: 'plain' }) |
|
|
| await hook(mkCtx([turn1, turn2])) |
|
|
| expect(emitCalls).toHaveLength(2) |
| expect(emitCalls[0]).toEqual({ toolName: 'wiki__render_md', turn: turn1 }) |
| expect(emitCalls[1]).toEqual({ toolName: 'grep', turn: turn2 }) |
| }) |
|
|
| it('returns undefined — the LoopHooks "not intervening" signal', async () => { |
| const { bus } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
|
|
| const result = await hook(mkCtx([mkTurn()])) |
|
|
| expect(result).toBeUndefined() |
| }) |
|
|
| it('empty toolResults — emit is never called', async () => { |
| const { bus, emitCalls } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
|
|
| await hook(mkCtx([])) |
|
|
| expect(emitCalls).toHaveLength(0) |
| }) |
|
|
| it('single turn — emit called exactly once', async () => { |
| const { bus, emitCalls } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
| const turn = mkTurn({ toolName: 'bash', content: 'ok' }) |
|
|
| await hook(mkCtx([turn])) |
|
|
| expect(emitCalls).toHaveLength(1) |
| expect(emitCalls[0]?.toolName).toBe('bash') |
| expect(emitCalls[0]?.turn).toBe(turn) |
| }) |
|
|
| it('turn without toolName — forwards "" as toolName (bus match-all-false → silent)', async () => { |
| |
| |
| const { bus, emitCalls } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
| |
| const { toolName: _omit, ...base } = mkTurn({ toolName: 'x' }) |
| const turn: ToolTurn = base |
|
|
| await hook(mkCtx([turn])) |
|
|
| expect(emitCalls).toHaveLength(1) |
| expect(emitCalls[0]?.toolName).toBe('') |
| expect(emitCalls[0]?.turn).toBe(turn) |
| }) |
|
|
| it('hook type: is the AfterToolExecutionHook signature (async, ctx in, Result|undefined out)', async () => { |
| const { bus } = mkMockBus() |
| const hook = createRenderingLoopHook(bus) |
| expect(typeof hook).toBe('function') |
| expect(hook.constructor.name).toBe('AsyncFunction') |
| }) |
| }) |
|
|