profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
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