profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

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 +});