import { describe, it, expect } from 'vitest'; import type { Event, Part } from '@opencode-ai/sdk/v2/client'; import { stripDcpTags, eventSessionId, resolvePartDedupeKey, mapToolStatus, toolPartToSnapshot, toolCalledSnapshot, toolSuccessSnapshot, toolFailedSnapshot, classifyPartDelta, classifyUpdatedPart, errToString, errMsg, type DedupState, } from '../opencode-event-map.js'; /** * Pure opencode Event → AgentEvent translation + dedup gate (v2.7 audit reshape). * Mirrors the original `dispatchEvent` / `handleUpdatedPart` arms verbatim — no * I/O, so it's unit-testable. The slimmed backend keeps the routing + side effects. */ function freshDedup(): DedupState { return { streamedPartKeys: new Set(), partTypeById: new Map() }; } describe('stripDcpTags', () => { it('removes a complete dcp tag', () => { expect(stripDcpTags('hi m1 there')).toBe('hi there'); }); it('leaves untagged text untouched', () => { expect(stripDcpTags('plain text
')).toBe('plain text
'); }); }); describe('eventSessionId', () => { it('reads properties.sessionID for a normal event', () => { const ev = { type: 'session.idle', properties: { sessionID: 's1' } } as unknown as Event; expect(eventSessionId(ev)).toBe('s1'); }); it('reads properties.part.sessionID for message.part.updated', () => { const ev = { type: 'message.part.updated', properties: { part: { sessionID: 's2' } }, } as unknown as Event; expect(eventSessionId(ev)).toBe('s2'); }); it('returns null when there is no session', () => { const ev = { type: 'server.connected', properties: {} } as unknown as Event; expect(eventSessionId(ev)).toBeNull(); }); }); describe('resolvePartDedupeKey', () => { it('prefers the part id', () => { expect(resolvePartDedupeKey({ id: 'p1', messageID: 'm1' }, 'text')).toBe('text:p1'); }); it('falls back to the message id', () => { expect(resolvePartDedupeKey({ id: ' ', messageID: 'm1' }, 'reasoning')).toBe('reasoning:message:m1'); }); it('returns null when neither is present', () => { expect(resolvePartDedupeKey({ id: '', messageID: '' }, 'text')).toBeNull(); }); }); describe('mapToolStatus', () => { it('maps the opencode tool states to ACP statuses', () => { expect(mapToolStatus('pending')).toBe('pending'); expect(mapToolStatus('running')).toBe('in_progress'); expect(mapToolStatus('completed')).toBe('completed'); expect(mapToolStatus('error')).toBe('failed'); expect(mapToolStatus(undefined)).toBeNull(); }); }); describe('session.next.tool.* snapshot builders', () => { it('toolCalledSnapshot → in_progress with tool title + raw input', () => { expect(toolCalledSnapshot({ callID: 'c1', tool: 'read_file', input: { path: 'a.ts' } })).toEqual({ toolCallId: 'c1', title: 'read_file', kind: null, status: 'in_progress', rawInput: { path: 'a.ts' }, rawOutput: undefined, }); }); it('toolSuccessSnapshot → completed with joined text content', () => { const snap = toolSuccessSnapshot({ callID: 'c1', content: [{ text: 'foo' }, { text: 'bar' }, { other: 1 }] }); expect(snap.status).toBe('completed'); expect(snap.title).toBe('c1'); expect(snap.rawOutput).toBe('foobar'); }); it('toolSuccessSnapshot → empty output when content is missing', () => { expect(toolSuccessSnapshot({ callID: 'c1' }).rawOutput).toBe(''); }); it('toolFailedSnapshot → failed with stringified error', () => { const snap = toolFailedSnapshot({ callID: 'c1', error: 'boom' }); expect(snap.status).toBe('failed'); expect(snap.title).toBe('c1'); expect(snap.rawOutput).toBe('boom'); }); }); describe('toolPartToSnapshot', () => { it('extracts input/output/title/status from the tool state', () => { const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'completed', input: { q: 'x' }, output: 'result', title: 'Grep run' }, } as unknown as Parameters[0]; expect(toolPartToSnapshot(part)).toEqual({ toolCallId: 'c1', title: 'Grep run', kind: null, status: 'completed', rawInput: { q: 'x' }, rawOutput: 'result', }); }); it('falls back to the tool name and uses error as output', () => { const part = { type: 'tool', callID: 'c2', tool: 'edit', state: { status: 'error', error: 'nope' }, } as unknown as Parameters[0]; const snap = toolPartToSnapshot(part); expect(snap.title).toBe('edit'); expect(snap.status).toBe('failed'); expect(snap.rawOutput).toBe('nope'); }); }); describe('classifyPartDelta (message.part.delta dedup recording)', () => { it('records a reasoning key and emits a reasoning event', () => { const st = freshDedup(); const e = classifyPartDelta({ partID: 'p1', field: 'reasoning', delta: 'thinking' }, st); expect(e).toEqual({ type: 'reasoning', text: 'thinking' }); expect(st.streamedPartKeys.has('reasoning:p1')).toBe(true); }); it('records a text key, strips dcp, and emits text', () => { const st = freshDedup(); const e = classifyPartDelta({ partID: 'p2', field: 'text', delta: 'hi m' }, st); expect(e).toEqual({ type: 'text', text: 'hi ' }); expect(st.streamedPartKeys.has('text:p2')).toBe(true); }); it('still records the text key even when the cleaned delta is empty', () => { const st = freshDedup(); const e = classifyPartDelta({ partID: 'p3', field: 'text', delta: 'm' }, st); expect(e).toBeNull(); expect(st.streamedPartKeys.has('text:p3')).toBe(true); }); it('uses the recorded part type when the field is absent', () => { const st = freshDedup(); st.partTypeById.set('p4', 'reasoning'); const e = classifyPartDelta({ partID: 'p4', delta: 'more' }, st); expect(e).toEqual({ type: 'reasoning', text: 'more' }); }); it('returns null for an unknown field', () => { expect(classifyPartDelta({ partID: 'p5', field: 'other', delta: 'x' }, freshDedup())).toBeNull(); }); }); describe('classifyUpdatedPart (message.part.updated dedup gate)', () => { function textPart(over: Partial = {}): Part { return { type: 'text', id: 'p1', messageID: 'm1', sessionID: 's1', text: 'final text', time: { start: 1, end: 2 }, ...over, } as unknown as Part; } it('drops a terminal part already streamed via deltas', () => { const st = freshDedup(); st.streamedPartKeys.add('text:p1'); expect(classifyUpdatedPart(textPart(), st)).toBeNull(); // the key is consumed expect(st.streamedPartKeys.has('text:p1')).toBe(false); }); it('emits a finished (ended) text part not seen via deltas', () => { const st = freshDedup(); expect(classifyUpdatedPart(textPart(), st)).toEqual({ type: 'text', text: 'final text' }); expect(st.partTypeById.get('p1')).toBe('text'); }); it('does not emit a part that has not ended yet', () => { const st = freshDedup(); expect(classifyUpdatedPart(textPart({ time: { start: 1 } as never }), st)).toBeNull(); }); it('strips dcp tags from the finished text', () => { const st = freshDedup(); const part = textPart({ text: 'a mb' }); expect(classifyUpdatedPart(part, st)).toEqual({ type: 'text', text: 'a b' }); }); it('maps a running tool part to tool_call', () => { const st = freshDedup(); const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'running' } } as unknown as Part; const e = classifyUpdatedPart(part, st); expect(e?.type).toBe('tool_call'); }); it('maps a completed tool part to tool_update', () => { const st = freshDedup(); const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'completed', output: 'x' } } as unknown as Part; const e = classifyUpdatedPart(part, st); expect(e?.type).toBe('tool_update'); }); }); describe('error formatters', () => { it('errMsg unwraps Error.message', () => { expect(errMsg(new Error('x'))).toBe('x'); expect(errMsg('plain')).toBe('plain'); }); it('errToString handles null/string/Error/object', () => { expect(errToString(null)).toBe('unknown error'); expect(errToString('s')).toBe('s'); expect(errToString(new Error('e'))).toBe('e'); expect(errToString({ a: 1 })).toBe('{"a":1}'); }); });