debate-view.test.ts
6,558 bytes
| 1 | import { describe, expect, it } from 'vitest'; |
|---|---|
| 2 | import type { DebateEvent } from '@/core/events'; |
| 3 | import { |
| 4 | emptyUsage, |
| 5 | type AnswerRecord, |
| 6 | type ConvergenceRecord, |
| 7 | type CritiqueRecord, |
| 8 | type DebateConfig, |
| 9 | type DebateResult, |
| 10 | type Participant, |
| 11 | type RevisionRecord, |
| 12 | type SynthesisRecord, |
| 13 | } from '@/core/types'; |
| 14 | import { allCritiques, applyEvent, currentAnswers, fromResult, initialDebateView } from './debate-view'; |
| 15 | |
| 16 | const config: DebateConfig = { |
| 17 | question: 'Q?', |
| 18 | models: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet'], |
| 19 | chairmanModel: 'x-ai/grok-2', |
| 20 | convergenceModel: 'google/gemini-2.0-flash-001', |
| 21 | maxRounds: 2, |
| 22 | convergenceThreshold: 85, |
| 23 | temperature: 0.7, |
| 24 | perModelTimeoutMs: 90_000, |
| 25 | }; |
| 26 | const participants: Participant[] = [ |
| 27 | { id: 'p0', model: 'openai/gpt-4o', displayName: 'GPT 4o' }, |
| 28 | { id: 'p1', model: 'anthropic/claude-3.5-sonnet', displayName: 'Claude 3.5 Sonnet' }, |
| 29 | ]; |
| 30 | |
| 31 | const answer = (id: string, round: number, content: string): AnswerRecord => ({ |
| 32 | participantId: id, |
| 33 | model: id === 'p0' ? 'openai/gpt-4o' : 'anthropic/claude-3.5-sonnet', |
| 34 | round, |
| 35 | content, |
| 36 | usage: emptyUsage(), |
| 37 | latencyMs: 100, |
| 38 | }); |
| 39 | |
| 40 | function fold(events: DebateEvent[]) { |
| 41 | return events.reduce(applyEvent, initialDebateView()); |
| 42 | } |
| 43 | |
| 44 | describe('applyEvent', () => { |
| 45 | it('initializes from debate_started', () => { |
| 46 | const v = fold([ |
| 47 | { type: 'debate_started', debateId: 'd1', config, participants, chairmanModel: config.chairmanModel, chairmanProviderConflict: false }, |
| 48 | ]); |
| 49 | expect(v.debateId).toBe('d1'); |
| 50 | expect(v.status).toBe('running'); |
| 51 | expect(v.participants).toHaveLength(2); |
| 52 | expect(v.question).toBe('Q?'); |
| 53 | }); |
| 54 | |
| 55 | it('accumulates streaming token deltas and clears them on answer_completed', () => { |
| 56 | const mid = fold([ |
| 57 | { type: 'stage_started', round: 0, stage: 'answer', participantId: 'p0', model: 'openai/gpt-4o' }, |
| 58 | { type: 'token_delta', round: 0, stage: 'answer', participantId: 'p0', delta: 'Hel' }, |
| 59 | { type: 'token_delta', round: 0, stage: 'answer', participantId: 'p0', delta: 'lo' }, |
| 60 | ]); |
| 61 | expect(mid.streaming.p0).toBe('Hello'); |
| 62 | expect(mid.working.p0).toBe('answer'); |
| 63 | |
| 64 | const done = applyEvent(mid, { type: 'answer_completed', round: 0, record: answer('p0', 0, 'Hello world') }); |
| 65 | expect(done.streaming.p0).toBeUndefined(); |
| 66 | expect(done.working.p0).toBeUndefined(); |
| 67 | expect(done.initialAnswers.p0!.content).toBe('Hello world'); |
| 68 | }); |
| 69 | |
| 70 | it('builds rounds from critique/revision/convergence events', () => { |
| 71 | const critique: CritiqueRecord = { |
| 72 | round: 1, |
| 73 | reviewerParticipantId: 'p0', |
| 74 | reviewerModel: 'openai/gpt-4o', |
| 75 | reviews: [{ label: 'A', targetParticipantId: 'p1', weaknesses: [], strengths: [], score: 8, justification: '' }], |
| 76 | usage: emptyUsage(), |
| 77 | latencyMs: 0, |
| 78 | }; |
| 79 | const revision: RevisionRecord = { |
| 80 | round: 1, |
| 81 | participantId: 'p1', |
| 82 | model: 'anthropic/claude-3.5-sonnet', |
| 83 | content: 'revised', |
| 84 | changelog: { changed: true, summary: 'x', bullets: [] }, |
| 85 | usage: emptyUsage(), |
| 86 | latencyMs: 0, |
| 87 | }; |
| 88 | const convergence: ConvergenceRecord = { |
| 89 | round: 1, |
| 90 | model: 'google/gemini-2.0-flash-001', |
| 91 | score: 90, |
| 92 | converged: true, |
| 93 | disagreements: [], |
| 94 | usage: emptyUsage(), |
| 95 | latencyMs: 0, |
| 96 | }; |
| 97 | const v = fold([ |
| 98 | { type: 'critique_completed', round: 1, record: critique }, |
| 99 | { type: 'revision_completed', round: 1, record: revision }, |
| 100 | { type: 'convergence_result', round: 1, record: convergence }, |
| 101 | ]); |
| 102 | expect(v.rounds).toHaveLength(1); |
| 103 | expect(v.rounds[0]!.critiques).toHaveLength(1); |
| 104 | expect(v.rounds[0]!.revisions[0]!.content).toBe('revised'); |
| 105 | expect(v.rounds[0]!.convergence!.score).toBe(90); |
| 106 | expect(allCritiques(v)).toHaveLength(1); |
| 107 | }); |
| 108 | |
| 109 | it('records a dropped model and updates totals + status', () => { |
| 110 | const v = fold([ |
| 111 | { type: 'model_failed', round: 0, stage: 'answer', participantId: 'p1', model: 'anthropic/claude-3.5-sonnet', error: 'timeout', droppedFromDebate: true }, |
| 112 | { type: 'cost_update', totalCostUsd: 0.5, promptTokens: 10, completionTokens: 20, costByModel: { 'openai/gpt-4o': 0.5 } }, |
| 113 | { type: 'debate_completed', debateId: 'd1', status: 'completed', totalCostUsd: 0.5, rounds: 1, durationMs: 1000 }, |
| 114 | ]); |
| 115 | expect(v.droppedParticipants).toContain('p1'); |
| 116 | expect(v.failures).toHaveLength(1); |
| 117 | expect(v.totals.costUsd).toBe(0.5); |
| 118 | expect(v.status).toBe('completed'); |
| 119 | }); |
| 120 | |
| 121 | it('sets synthesis and marks debate_failed', () => { |
| 122 | const synth: SynthesisRecord = { model: 'x-ai/grok-2', finalAnswer: 'final', dissent: [], usage: emptyUsage(), latencyMs: 0 }; |
| 123 | const v1 = applyEvent(initialDebateView(), { type: 'synthesis_completed', record: synth }); |
| 124 | expect(v1.synthesis!.finalAnswer).toBe('final'); |
| 125 | const v2 = applyEvent(v1, { type: 'debate_failed', debateId: 'd1', error: 'boom' }); |
| 126 | expect(v2.status).toBe('failed'); |
| 127 | expect(v2.error).toBe('boom'); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | describe('currentAnswers', () => { |
| 132 | it('prefers the latest revision over the initial answer', () => { |
| 133 | const v = fold([ |
| 134 | { type: 'answer_completed', round: 0, record: answer('p0', 0, 'first') }, |
| 135 | { |
| 136 | type: 'revision_completed', |
| 137 | round: 1, |
| 138 | record: { |
| 139 | round: 1, |
| 140 | participantId: 'p0', |
| 141 | model: 'openai/gpt-4o', |
| 142 | content: 'second', |
| 143 | changelog: { changed: true, summary: '', bullets: [] }, |
| 144 | usage: emptyUsage(), |
| 145 | latencyMs: 0, |
| 146 | }, |
| 147 | }, |
| 148 | ]); |
| 149 | expect(currentAnswers(v).p0!.content).toBe('second'); |
| 150 | }); |
| 151 | }); |
| 152 | |
| 153 | describe('fromResult', () => { |
| 154 | it('lifts a persisted result into a renderable view', () => { |
| 155 | const result: DebateResult = { |
| 156 | debateId: 'd1', |
| 157 | config, |
| 158 | participants, |
| 159 | status: 'completed', |
| 160 | initialAnswers: [answer('p0', 0, 'a0'), answer('p1', 0, 'a1')], |
| 161 | rounds: [], |
| 162 | synthesis: { model: 'x-ai/grok-2', finalAnswer: 'done', dissent: [], usage: emptyUsage(), latencyMs: 0 }, |
| 163 | provenance: null, |
| 164 | failures: [], |
| 165 | finalAnswers: [answer('p0', 0, 'a0'), answer('p1', 0, 'a1')], |
| 166 | totals: { costUsd: 1, promptTokens: 5, completionTokens: 5, rounds: 0, durationMs: 100, costByModel: {} }, |
| 167 | }; |
| 168 | const v = fromResult(result); |
| 169 | expect(v.status).toBe('completed'); |
| 170 | expect(Object.keys(v.initialAnswers)).toHaveLength(2); |
| 171 | expect(v.synthesis!.finalAnswer).toBe('done'); |
| 172 | // chairman x-ai/grok-2 does not overlap openai/anthropic council |
| 173 | expect(v.chairmanProviderConflict).toBe(false); |
| 174 | }); |
| 175 | }); |
| 176 | |