Commit
test debate-view reducer
commit
161b29e
1 changed file with +174 and −0
added src/lib/debate-view.test.ts +174 −0
| @@ -0,0 +1,174 @@ | ||
| 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 | + failures: [], | |
| 164 | + finalAnswers: [answer('p0', 0, 'a0'), answer('p1', 0, 'a1')], | |
| 165 | + totals: { costUsd: 1, promptTokens: 5, completionTokens: 5, rounds: 0, durationMs: 100, costByModel: {} }, | |
| 166 | + }; | |
| 167 | + const v = fromResult(result); | |
| 168 | + expect(v.status).toBe('completed'); | |
| 169 | + expect(Object.keys(v.initialAnswers)).toHaveLength(2); | |
| 170 | + expect(v.synthesis!.finalAnswer).toBe('done'); | |
| 171 | + // chairman x-ai/grok-2 does not overlap openai/anthropic council | |
| 172 | + expect(v.chairmanProviderConflict).toBe(false); | |
| 173 | + }); | |
| 174 | +}); |