import { describe, it, expect } from 'vitest'; import type { Broker } from '@boocode/server/broker'; import { makeFrameEmitter } from '../frame-emitter.js'; import { makeDcpStreamStripper } from '../dcp-strip.js'; import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; /** * makeFrameEmitter (v2.7 audit reshape): the AgentEvent → WS-frame mapping + turn * accumulators extracted from AcpStreamContext. Pure-ish over an injected broker. */ function fakeBroker(): { broker: Broker; frames: Array<{ sid: string; frame: Record }> } { const frames: Array<{ sid: string; frame: Record }> = []; const broker = { publishFrame: (sid: string, frame: unknown) => { frames.push({ sid, frame: frame as Record }); }, } as unknown as Broker; return { broker, frames }; } const toolSnap: AcpToolSnapshot = { toolCallId: 'c1', title: 'grep', status: 'completed', rawOutput: 'x' }; describe('makeFrameEmitter — streaming frames', () => { it('maps text/reasoning/tool events to delta/reasoning_delta/tool_call frames', () => { const { broker, frames } = fakeBroker(); const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); em.onEvent({ type: 'text', text: 'hello ' }); em.onEvent({ type: 'reasoning', text: 'mulling' }); em.onEvent({ type: 'tool_call', toolCall: toolSnap }); expect(frames.map((f) => f.frame.type)).toEqual(['delta', 'reasoning_delta', 'tool_call']); expect(frames[0]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1', content: 'hello ' }); expect(frames[2]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1' }); expect(em.output).toBe('hello '); expect(em.reasoningText).toBe('mulling'); expect(em.snapshots).toHaveLength(1); }); it('publishes a tool_call frame for BOTH tool_call and tool_update events', () => { const { broker, frames } = fakeBroker(); const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); em.onEvent({ type: 'tool_update', toolCall: toolSnap }); expect(frames).toHaveLength(1); expect(frames[0]!.frame.type).toBe('tool_call'); }); it('publishes an agent_commands frame and merges the command cache', () => { const { broker, frames } = fakeBroker(); const taskId = `task-fe-${Math.floor(performance.now())}-${frames.length}`; const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', taskId }); em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] }); expect(frames).toHaveLength(1); expect(frames[0]!.frame).toMatchObject({ type: 'agent_commands', task_id: taskId, session_id: 's1' }); }); it('does not publish a commands frame without a taskId', () => { const { broker, frames } = fakeBroker(); const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] }); expect(frames).toHaveLength(0); }); }); describe('makeFrameEmitter — no broker (one-shot accumulation)', () => { it('accumulates output/reasoning/snapshots but publishes nothing', () => { const em = makeFrameEmitter({ sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); em.onEvent({ type: 'text', text: 'abc' }); em.onEvent({ type: 'reasoning', text: 'r' }); em.onEvent({ type: 'tool_call', toolCall: toolSnap }); expect(em.output).toBe('abc'); expect(em.reasoningText).toBe('r'); expect(em.snapshots).toHaveLength(1); }); }); describe('makeFrameEmitter — dcp stripping (opencode path contract)', () => { it('strips a split dcp tag across deltas and flushes the tail on finalize', () => { const { broker, frames } = fakeBroker(); const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', dcp: makeDcpStreamStripper() }); for (const chunk of ['Answer.', 'm1', ' tail']) { em.onEvent({ type: 'text', text: chunk }); } em.finalize(); expect(em.output).toBe('Answer. tail'); const published = frames.filter((f) => f.frame.type === 'delta').map((f) => f.frame.content).join(''); expect(published).toBe('Answer. tail'); }); it('finalize is a no-op without a dcp stripper', () => { const { broker, frames } = fakeBroker(); const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); em.onEvent({ type: 'text', text: 'raw m' }); em.finalize(); // No stripping without a stripper — verbatim text (prior ACP-path behavior). expect(em.output).toBe('raw m'); expect(frames).toHaveLength(1); }); });