File size: 3,691 Bytes
06c3755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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 }

/** Mock bus that only records emit calls — matching/broadcast not exercised here. */
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 () => {
    // D3a: ToolTurn.toolName is optional; the hook is a pure type bridge,
    // mapping undefined → ''. No matching/extraction logic lives here.
    const { bus, emitCalls } = mkMockBus()
    const hook = createRenderingLoopHook(bus)
    // exactOptionalPropertyTypes: an absent toolName is the "undefined" case.
    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')
  })
})