/ tests / chat-service-stream-runid-tool-events.test.ts
chat-service-stream-runid-tool-events.test.ts
  1  import { randomUUID } from 'node:crypto'
  2  
  3  import { beforeEach, describe, expect, it, vi } from 'vitest'
  4  
  5  import type { ChatStreamEvent } from '@/lib/shared/chat'
  6  import { streamChatMessageEvents } from '@/server/chat/service'
  7  
  8  const streamReplyMock = vi.hoisted(() => vi.fn())
  9  
 10  vi.mock('@/server/chat/memory-retrieval', () => ({
 11    refreshMemoryEmbeddings: vi.fn(async () => {}),
 12    selectRelevantMemories: vi.fn(async () => []),
 13  }))
 14  
 15  vi.mock('@/server/providers', () => ({
 16    getChatProvider: vi.fn(() => ({
 17      streamReply: streamReplyMock,
 18      generateReply: vi.fn(async () => ({
 19        provider: 'mock-provider',
 20        mocked: false,
 21        text: 'unused',
 22      })),
 23    })),
 24  }))
 25  
 26  describe('streamChatMessageEvents runId and tool lifecycle passthrough', () => {
 27    beforeEach(() => {
 28      vi.clearAllMocks()
 29    })
 30  
 31    it('emits one runId for the full turn and forwards provider tool lifecycle chunks', async () => {
 32      const sessionId = randomUUID()
 33  
 34      streamReplyMock.mockImplementationOnce(async () => ({
 35        provider: 'openai-compatible',
 36        mocked: false,
 37        stream: (async function* () {
 38          yield {
 39            type: 'tool_start' as const,
 40            tool: 'bash',
 41            toolCallId: 'call_1',
 42            source: 'function' as const,
 43            input: { command: 'pwd' },
 44          }
 45          yield {
 46            type: 'tool_progress' as const,
 47            tool: 'bash',
 48            toolCallId: 'call_1',
 49            source: 'function' as const,
 50            progress: 'Executing tool locally...',
 51          }
 52          yield {
 53            type: 'tool_complete' as const,
 54            tool: 'bash',
 55            toolCallId: 'call_1',
 56            source: 'function' as const,
 57            ok: true,
 58            output: 'exit 0 | stdout: /Users/justinedwards/git/helper',
 59          }
 60          yield {
 61            type: 'thinking' as const,
 62            thinking: 'Summarizing tool output...',
 63          }
 64          yield {
 65            type: 'delta' as const,
 66            delta: 'Done.',
 67          }
 68        })(),
 69      }))
 70  
 71      const events: ChatStreamEvent[] = []
 72      for await (const event of streamChatMessageEvents({
 73        sessionId,
 74        text: 'run the command',
 75        dangerousTools: { bash: true },
 76      })) {
 77        events.push(event)
 78      }
 79  
 80      const sessionEvent = events.find(
 81        (event): event is Extract<ChatStreamEvent, { type: 'session' }> =>
 82          event.type === 'session',
 83      )
 84      expect(sessionEvent).toBeDefined()
 85      expect(sessionEvent?.runId).toBeTruthy()
 86  
 87      const runId = sessionEvent?.runId ?? ''
 88      for (const event of events) {
 89        if (event.type === 'session') continue
 90        expect(event.runId).toBe(runId)
 91      }
 92  
 93      expect(
 94        events.some(
 95          (event) =>
 96            event.type === 'tool_start' &&
 97            event.tool === 'bash' &&
 98            event.toolCallId === 'call_1',
 99        ),
100      ).toBe(true)
101      expect(
102        events.some(
103          (event) =>
104            event.type === 'tool_complete' &&
105            event.ok === true &&
106            event.output.includes('stdout'),
107        ),
108      ).toBe(true)
109      expect(
110        events.some(
111          (event) =>
112            event.type === 'thinking' &&
113            event.thinking.includes('Summarizing tool output'),
114        ),
115      ).toBe(true)
116  
117      const doneEvent = events.at(-1)
118      expect(doneEvent?.type).toBe('done')
119      if (doneEvent?.type === 'done') {
120        expect(doneEvent.assistantMessage.text).toBe('Done.')
121      }
122    })
123  })