From cb59ecf3ac3b38ba883fc74bf810ae9e82e2a469 Mon Sep 17 00:00:00 2001
From: YurenHao0426 <blackhao0426@gmail.com>
Date: Fri, 13 Feb 2026 23:08:05 +0000
Subject: Add LLM Debate mode for multi-round iterative model discussions

Implements a debate feature alongside Council mode where 2-6 models
engage in multi-round discussions with configurable judge modes
(external judge, self-convergence, display-only), debate formats
(free discussion, structured opposition, iterative improvement, custom),
and early termination conditions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 frontend/src/components/Sidebar.tsx | 695 +++++++++++++++++++++++++++++++++++-
 1 file changed, 681 insertions(+), 14 deletions(-)

(limited to 'frontend/src/components/Sidebar.tsx')

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 64cd79a..aeb164b 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
 import { useReactFlow } from 'reactflow';
 import useFlowStore from '../store/flowStore';
 import { useAuthStore } from '../store/authStore';
-import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig } from '../store/flowStore';
+import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData, CouncilMemberConfig, DebateData, DebateRound } from '../store/flowStore';
 import type { Edge } from 'reactflow';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
@@ -17,7 +17,7 @@ const preprocessLaTeX = (content: string): string => {
     .replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math}$`);
 };
 
-import { Play, Settings, Info, ChevronLeft, ChevronRight, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users } from 'lucide-react';
+import { Play, Settings, Info, ChevronLeft, ChevronRight, ChevronDown, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users, MessageSquare } from 'lucide-react';
 
 interface SidebarProps {
   isOpen: boolean;
@@ -109,6 +109,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const [quickChatCouncilData, setQuickChatCouncilData] = useState<CouncilData | null>(null);
   const [quickChatCouncilConfigOpen, setQuickChatCouncilConfigOpen] = useState(false);
 
+  // Debate mode states
+  const [debateStage, setDebateStage] = useState<string>('');
+  const [debateStreamBuffer, setDebateStreamBuffer] = useState('');
+  const [debateTab, setDebateTab] = useState<'final' | 'timeline' | 'per-model'>('timeline');
+  const [debateTimelineExpandedRounds, setDebateTimelineExpandedRounds] = useState<Set<number>>(new Set());
+  const [debatePerModelSelected, setDebatePerModelSelected] = useState<string>('');
+
   const selectedNode = nodes.find((n) => n.id === selectedNodeId);
 
   // Reset stream buffer and modal states when node changes
@@ -640,6 +647,263 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     }
   };
 
+  // Debate mode: multi-round LLM debate execution
+  const handleRunDebate = async () => {
+    if (!selectedNode) return;
+    const debateModels: CouncilMemberConfig[] = selectedNode.data.debateModels || [];
+    if (debateModels.length < 2) return;
+
+    const tracesCheck = checkActiveTracesComplete();
+    if (!tracesCheck.complete) return;
+
+    const runningNodeId = selectedNode.id;
+    const runningPrompt = selectedNode.data.userPrompt;
+    const querySentAt = Date.now();
+    const judgeMode = selectedNode.data.debateJudgeMode || 'external_judge';
+    const debateFormat = selectedNode.data.debateFormat || 'free_discussion';
+    const maxRounds = selectedNode.data.debateMaxRounds || 5;
+
+    updateNodeData(runningNodeId, {
+      status: 'loading',
+      response: '',
+      querySentAt,
+      debateData: {
+        rounds: [],
+        finalVerdict: null,
+        config: { judgeMode, format: debateFormat, maxRounds },
+      },
+    });
+    setStreamBuffer('');
+    setDebateStreamBuffer('');
+    setDebateStage('Starting debate...');
+    setStreamingNodeId(runningNodeId);
+
+    const context = getActiveContext(runningNodeId);
+    const projectPath = currentBlueprintPath || 'untitled';
+    const traceNodeIds = new Set<string>();
+    traceNodeIds.add(runningNodeId);
+    const visited = new Set<string>();
+    const queue = [runningNodeId];
+    while (queue.length > 0) {
+      const currentNodeId = queue.shift()!;
+      if (visited.has(currentNodeId)) continue;
+      visited.add(currentNodeId);
+      const incomingEdges = edges.filter(e => e.target === currentNodeId);
+      for (const edge of incomingEdges) {
+        if (!visited.has(edge.source)) {
+          traceNodeIds.add(edge.source);
+          queue.push(edge.source);
+        }
+      }
+    }
+    const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`);
+
+    const attachedFiles = selectedNode.data.attachedFileIds || [];
+    const effectivePrompt = runningPrompt?.trim()
+      ? runningPrompt
+      : attachedFiles.length > 0
+        ? 'Please analyze the attached files.'
+        : '';
+
+    try {
+      const judgeModelConfig = selectedNode.data.judgeModel || debateModels[0];
+      const response = await fetch(`/api/run_debate_stream?user=${encodeURIComponent(user?.username || 'test')}`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+        body: JSON.stringify({
+          node_id: runningNodeId,
+          incoming_contexts: [{ messages: context }],
+          user_prompt: effectivePrompt,
+          debate_models: debateModels.map((cfg) => ({
+            model_name: cfg.model,
+            temperature: cfg.temperature ?? null,
+            reasoning_effort: cfg.reasoningEffort ?? null,
+            enable_google_search: cfg.enableWebSearch ?? null,
+          })),
+          judge_model: judgeMode === 'external_judge' ? {
+            model_name: judgeModelConfig.model,
+            temperature: judgeModelConfig.temperature ?? null,
+            reasoning_effort: judgeModelConfig.reasoningEffort ?? null,
+            enable_google_search: judgeModelConfig.enableWebSearch ?? null,
+          } : null,
+          judge_mode: judgeMode,
+          debate_format: debateFormat,
+          custom_format_prompt: selectedNode.data.debateCustomPrompt || null,
+          max_rounds: maxRounds,
+          system_prompt: selectedNode.data.systemPrompt || null,
+          temperature: selectedNode.data.temperature,
+          reasoning_effort: selectedNode.data.reasoningEffort || 'medium',
+          enable_google_search: selectedNode.data.enableGoogleSearch !== false,
+          merge_strategy: selectedNode.data.mergeStrategy || 'smart',
+          attached_file_ids: attachedFiles,
+          scopes,
+        }),
+      });
+
+      if (!response.body) return;
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let sseBuffer = '';
+      const debateRounds: DebateRound[] = [];
+      let currentRound = 0;
+      let currentRoundResponses: Array<{ model: string; response: string }> = [];
+      let finalModel = '';
+      let finalFull = '';
+
+      while (true) {
+        const { value, done } = await reader.read();
+        if (done) break;
+        sseBuffer += decoder.decode(value, { stream: true });
+
+        const parts = sseBuffer.split('\n\n');
+        sseBuffer = parts.pop() || '';
+
+        for (const part of parts) {
+          const line = part.trim();
+          if (!line.startsWith('data: ')) continue;
+          let evt: any;
+          try {
+            evt = JSON.parse(line.slice(6));
+          } catch { continue; }
+
+          switch (evt.type) {
+            case 'debate_start':
+              setDebateStage(`Debate started (${evt.data.models.length} models, max ${evt.data.max_rounds} rounds)`);
+              break;
+            case 'round_start':
+              currentRound = evt.data.round;
+              currentRoundResponses = [];
+              setDebateStage(`Round ${currentRound}/${maxRounds}: Collecting responses...`);
+              break;
+            case 'round_model_complete':
+              currentRoundResponses = [...currentRoundResponses, { model: evt.data.model, response: evt.data.response }];
+              setDebateStage(`Round ${currentRound}/${maxRounds}: ${currentRoundResponses.length}/${debateModels.length} models done`);
+              break;
+            case 'round_complete': {
+              const roundData: DebateRound = { round: evt.data.round, responses: evt.data.responses };
+              debateRounds.push(roundData);
+              updateNodeData(runningNodeId, {
+                debateData: {
+                  rounds: [...debateRounds],
+                  finalVerdict: null,
+                  config: { judgeMode, format: debateFormat, maxRounds },
+                },
+              });
+              break;
+            }
+            case 'judge_decision': {
+              const lastRound = debateRounds[debateRounds.length - 1];
+              if (lastRound) {
+                lastRound.judgeDecision = { continue: evt.data.continue, reasoning: evt.data.reasoning };
+                updateNodeData(runningNodeId, {
+                  debateData: {
+                    rounds: [...debateRounds],
+                    finalVerdict: null,
+                    config: { judgeMode, format: debateFormat, maxRounds },
+                  },
+                });
+              }
+              if (!evt.data.continue) {
+                setDebateStage('Judge stopped debate. Generating final verdict...');
+              } else {
+                setDebateStage(`Judge: Continue to round ${currentRound + 1}...`);
+              }
+              break;
+            }
+            case 'convergence_check': {
+              const lastRound2 = debateRounds[debateRounds.length - 1];
+              if (lastRound2) {
+                lastRound2.converged = evt.data.converged;
+                updateNodeData(runningNodeId, {
+                  debateData: {
+                    rounds: [...debateRounds],
+                    finalVerdict: null,
+                    config: { judgeMode, format: debateFormat, maxRounds },
+                  },
+                });
+              }
+              if (evt.data.converged) {
+                setDebateStage('Consensus reached!');
+              }
+              break;
+            }
+            case 'final_start':
+              finalModel = evt.data.model;
+              setDebateStage('Judge synthesizing final verdict...');
+              setDebateStreamBuffer('');
+              break;
+            case 'final_chunk':
+              finalFull += evt.data.chunk;
+              setDebateStreamBuffer(finalFull);
+              setStreamBuffer(finalFull);
+              break;
+            case 'final_complete': {
+              finalModel = evt.data.model;
+              finalFull = evt.data.response;
+              const responseReceivedAt = Date.now();
+              const debateData: DebateData = {
+                rounds: debateRounds,
+                finalVerdict: { model: finalModel, response: finalFull },
+                config: { judgeMode, format: debateFormat, maxRounds },
+              };
+              const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt };
+              const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: finalFull };
+              updateNodeData(runningNodeId, {
+                status: 'success',
+                response: finalFull,
+                responseReceivedAt,
+                debateData,
+                messages: [...context, newUserMsg, newAssistantMsg] as any,
+              });
+              setDebateStage('');
+              generateTitle(runningNodeId, runningPrompt, finalFull);
+              break;
+            }
+            case 'debate_complete': {
+              // If no final verdict (display_only or self_convergence without explicit final_complete)
+              const currentNode = nodes.find(n => n.id === runningNodeId);
+              if (currentNode?.data.status === 'loading') {
+                const responseReceivedAt = Date.now();
+                const lastRoundResp = debateRounds.length > 0 ? debateRounds[debateRounds.length - 1].responses : [];
+                const bestResponse = lastRoundResp.length > 0
+                  ? lastRoundResp.reduce((a, b) => a.response.length > b.response.length ? a : b).response
+                  : '';
+                const debateData: DebateData = {
+                  rounds: debateRounds,
+                  finalVerdict: finalFull ? { model: finalModel, response: finalFull } : null,
+                  config: { judgeMode, format: debateFormat, maxRounds },
+                };
+                const displayResponse = finalFull || bestResponse;
+                const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt };
+                const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: displayResponse };
+                updateNodeData(runningNodeId, {
+                  status: 'success',
+                  response: displayResponse,
+                  responseReceivedAt,
+                  debateData,
+                  messages: [...context, newUserMsg, newAssistantMsg] as any,
+                });
+                setDebateStage('');
+                if (displayResponse) generateTitle(runningNodeId, runningPrompt, displayResponse);
+              }
+              break;
+            }
+            case 'error':
+              updateNodeData(runningNodeId, { status: 'error' });
+              setDebateStage('');
+              break;
+          }
+        }
+      }
+    } catch (error) {
+      console.error(error);
+      updateNodeData(runningNodeId, { status: 'error' });
+      setDebateStage('');
+    } finally {
+      setStreamingNodeId(prev => prev === runningNodeId ? null : prev);
+    }
+  };
+
   const handleChange = (field: keyof NodeData, value: any) => {
     updateNodeData(selectedNode.id, { [field]: value });
   };
@@ -1844,20 +2108,218 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
             <div>
               <div className="flex items-center justify-between mb-1">
                 <label className="block text-sm font-medium text-gray-700">Model</label>
-                <button
-                  onClick={() => handleChange('councilMode', !selectedNode.data.councilMode)}
-                  className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${
-                    selectedNode.data.councilMode
-                      ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700'
-                      : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
-                  }`}
-                >
-                  <Users size={11} />
-                  Council {selectedNode.data.councilMode ? 'ON' : 'OFF'}
-                </button>
+                <div className="flex gap-1">
+                  <button
+                    onClick={() => {
+                      const next = !selectedNode.data.councilMode;
+                      handleChange('councilMode', next);
+                      if (next) handleChange('debateMode', false);
+                    }}
+                    className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${
+                      selectedNode.data.councilMode
+                        ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700'
+                        : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+                    }`}
+                  >
+                    <Users size={11} />
+                    Council {selectedNode.data.councilMode ? 'ON' : 'OFF'}
+                  </button>
+                  <button
+                    onClick={() => {
+                      const next = !selectedNode.data.debateMode;
+                      handleChange('debateMode', next);
+                      if (next) handleChange('councilMode', false);
+                    }}
+                    className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${
+                      selectedNode.data.debateMode
+                        ? 'bg-cyan-100 text-cyan-700 border border-cyan-300 dark:bg-cyan-900/50 dark:text-cyan-300 dark:border-cyan-700'
+                        : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+                    }`}
+                  >
+                    <MessageSquare size={11} />
+                    Debate {selectedNode.data.debateMode ? 'ON' : 'OFF'}
+                  </button>
+                </div>
               </div>
 
-              {!selectedNode.data.councilMode ? (
+              {selectedNode.data.debateMode ? (
+                /* Debate mode: multi-model selector + judge config */
+                <div className={`space-y-2 p-2 rounded border ${isDark ? 'bg-gray-900 border-cyan-800/50' : 'bg-cyan-50/50 border-cyan-200'}`}>
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Participants (pick 2-6)</label>
+                    <div className="grid grid-cols-1 gap-0.5 max-h-40 overflow-y-auto">
+                      {(() => {
+                        const debateModelsList = [
+                          { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' },
+                          { value: 'claude-opus-4', label: 'claude-opus-4' },
+                          { value: 'claude-opus-4-5', label: 'claude-opus-4.5' },
+                          { value: 'claude-opus-4-6', label: 'claude-opus-4.6' },
+                          { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
+                          { value: 'gemini-2.5-flash-lite', label: 'gemini-2.5-flash-lite' },
+                          { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
+                          { value: 'gpt-4.1', label: 'gpt-4.1' },
+                          { value: 'gpt-4o', label: 'gpt-4o' },
+                          { value: 'gpt-5', label: 'gpt-5' },
+                          { value: 'gpt-5-mini', label: 'gpt-5-mini' },
+                          { value: 'gpt-5-nano', label: 'gpt-5-nano' },
+                          { value: 'gpt-5.1', label: 'gpt-5.1' },
+                          { value: 'gpt-5.2', label: 'gpt-5.2' },
+                          { value: 'o3', label: 'o3', premium: true },
+                        ];
+                        const members: CouncilMemberConfig[] = selectedNode.data.debateModels || [];
+                        const isReasoningModel = (v: string) => ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.2', 'gpt-5.2-pro', 'o3'].includes(v);
+                        const updateDebateMember = (modelName: string, field: string, value: any) => {
+                          const updated = [...members];
+                          const idx = updated.findIndex(c => c.model === modelName);
+                          if (idx >= 0) {
+                            updated[idx] = { ...updated[idx], [field]: value };
+                            handleChange('debateModels', updated);
+                          }
+                        };
+                        return debateModelsList.map(m => {
+                          const selected = members.some(c => c.model === m.value);
+                          const disabled = (m as any).premium && !canUsePremiumModels;
+                          const cfg = members.find(c => c.model === m.value);
+                          return (
+                            <div key={m.value}>
+                              <label className={`flex items-center gap-1.5 text-xs py-0.5 px-1 rounded cursor-pointer ${
+                                disabled ? 'opacity-40 cursor-not-allowed' : selected
+                                  ? isDark ? 'bg-cyan-900/40 text-cyan-200' : 'bg-cyan-100 text-cyan-800'
+                                  : isDark ? 'hover:bg-gray-800 text-gray-300' : 'hover:bg-gray-100 text-gray-700'
+                              }`}>
+                                <input
+                                  type="checkbox"
+                                  checked={selected}
+                                  disabled={disabled}
+                                  onChange={() => {
+                                    const next = selected
+                                      ? members.filter(c => c.model !== m.value)
+                                      : [...members, { model: m.value }];
+                                    handleChange('debateModels', next);
+                                  }}
+                                  className="w-3 h-3 accent-cyan-500"
+                                />
+                                {m.label}
+                              </label>
+                              {selected && cfg && (
+                                <div className={`flex items-center gap-2 ml-5 mt-0.5 mb-1 px-1 py-0.5 text-xs rounded ${isDark ? 'bg-gray-800/60' : 'bg-gray-100/80'}`}>
+                                  <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+                                    T:
+                                    <input
+                                      type="number"
+                                      min={0} max={2} step={0.1}
+                                      value={cfg.temperature ?? selectedNode.data.temperature}
+                                      disabled={isReasoningModel(m.value)}
+                                      onChange={(e) => updateDebateMember(m.value, 'temperature', parseFloat(e.target.value) || 0)}
+                                      className={`w-12 border rounded px-1 py-0 text-xs ${isReasoningModel(m.value) ? 'opacity-50 cursor-not-allowed' : ''} ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                                    />
+                                  </label>
+                                  {isReasoningModel(m.value) && (
+                                    <label className={`flex items-center gap-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+                                      Effort:
+                                      <select
+                                        value={cfg.reasoningEffort ?? selectedNode.data.reasoningEffort ?? 'medium'}
+                                        onChange={(e) => updateDebateMember(m.value, 'reasoningEffort', e.target.value)}
+                                        className={`border rounded px-1 py-0 text-xs ${isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                                      >
+                                        <option value="low">Low</option>
+                                        <option value="medium">Med</option>
+                                        <option value="high">High</option>
+                                      </select>
+                                    </label>
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                          );
+                        });
+                      })()}
+                    </div>
+                  </div>
+
+                  {/* Judge Mode */}
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Mode</label>
+                    <select
+                      value={selectedNode.data.debateJudgeMode || 'external_judge'}
+                      onChange={(e) => handleChange('debateJudgeMode', e.target.value)}
+                      className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                    >
+                      <option value="external_judge">External Judge</option>
+                      <option value="self_convergence">Self-Convergence</option>
+                      <option value="display_only">Display Only</option>
+                    </select>
+                  </div>
+
+                  {/* Judge Model (only for external_judge) */}
+                  {(selectedNode.data.debateJudgeMode || 'external_judge') === 'external_judge' && (
+                    <div>
+                      <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Judge Model</label>
+                      <select
+                        value={selectedNode.data.judgeModel?.model || (selectedNode.data.debateModels || [])[0]?.model || ''}
+                        onChange={(e) => {
+                          const existing = (selectedNode.data.debateModels || []).find((c: CouncilMemberConfig) => c.model === e.target.value);
+                          handleChange('judgeModel', existing ? { ...existing } : { model: e.target.value });
+                        }}
+                        className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                      >
+                        {[
+                          { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' },
+                          { value: 'claude-opus-4-6', label: 'claude-opus-4.6' },
+                          { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
+                          { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
+                          { value: 'gpt-5', label: 'gpt-5' },
+                          { value: 'gpt-5.1', label: 'gpt-5.1' },
+                          { value: 'gpt-5.2', label: 'gpt-5.2' },
+                        ].map(m => (
+                          <option key={m.value} value={m.value}>{m.label}</option>
+                        ))}
+                      </select>
+                    </div>
+                  )}
+
+                  {/* Debate Format */}
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Debate Format</label>
+                    <select
+                      value={selectedNode.data.debateFormat || 'free_discussion'}
+                      onChange={(e) => handleChange('debateFormat', e.target.value)}
+                      className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                    >
+                      <option value="free_discussion">Free Discussion</option>
+                      <option value="structured_opposition">Structured Opposition</option>
+                      <option value="iterative_improvement">Iterative Improvement</option>
+                      <option value="custom">Custom Prompt</option>
+                    </select>
+                  </div>
+
+                  {/* Custom Prompt (only for custom format) */}
+                  {selectedNode.data.debateFormat === 'custom' && (
+                    <div>
+                      <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Custom Prompt</label>
+                      <textarea
+                        value={selectedNode.data.debateCustomPrompt || ''}
+                        onChange={(e) => handleChange('debateCustomPrompt', e.target.value)}
+                        placeholder="Use {history}, {round}, {model_name}, {question} as placeholders"
+                        rows={3}
+                        className={`w-full border rounded-md p-1.5 text-xs resize-y ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' : 'bg-white border-gray-300 placeholder-gray-400'}`}
+                      />
+                    </div>
+                  )}
+
+                  {/* Max Rounds */}
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Max Rounds</label>
+                    <input
+                      type="number"
+                      min={1} max={10}
+                      value={selectedNode.data.debateMaxRounds || 5}
+                      onChange={(e) => handleChange('debateMaxRounds', Math.max(1, Math.min(10, parseInt(e.target.value) || 5)))}
+                      className={`w-20 border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'bg-white border-gray-300'}`}
+                    />
+                  </div>
+                </div>
+              ) : !selectedNode.data.councilMode ? (
                 /* Single model selector */
                 <select
                   value={selectedNode.data.model}
@@ -2500,6 +2962,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   Inspired by <a href="https://github.com/karpathy/llm-council" target="_blank" rel="noopener noreferrer" className={`underline decoration-dotted ${isDark ? 'text-gray-500 hover:text-gray-400' : 'text-gray-500 hover:text-gray-600'}`}>karpathy/llm-council</a>
                 </div>
               </>
+            ) : selectedNode.data.debateMode ? (
+              <button
+                onClick={handleRunDebate}
+                disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.debateModels || []).length < 2}
+                className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${
+                  selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.debateModels || []).length < 2
+                    ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
+                    : 'bg-cyan-600 text-white hover:bg-cyan-700'
+                }`}
+              >
+                {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <MessageSquare size={16} />}
+                {selectedNode.data.status === 'loading' && debateStage ? debateStage : `Run Debate (${(selectedNode.data.debateModels || []).length})`}
+              </button>
             ) : (
               <button
                 onClick={handleRun}
@@ -2713,6 +3188,198 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   )}
 
                 </div>
+              ) : selectedNode.data.debateMode && selectedNode.data.debateData && (selectedNode.data.debateData.rounds.length > 0 || selectedNode.data.status === 'loading') ? (
+                <div>
+                  {/* Debate tab bar */}
+                  <div className={`flex gap-0.5 mb-2 text-xs border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+                    {(selectedNode.data.debateData.config.judgeMode !== 'display_only'
+                      ? (['final', 'timeline', 'per-model'] as const)
+                      : (['timeline', 'per-model'] as const)
+                    ).map(tab => (
+                      <button
+                        key={tab}
+                        onClick={() => setDebateTab(tab)}
+                        className={`px-3 py-1.5 rounded-t transition-colors capitalize ${
+                          debateTab === tab
+                            ? isDark ? 'bg-gray-800 text-cyan-300 border-b-2 border-cyan-400' : 'bg-white text-cyan-700 border-b-2 border-cyan-500'
+                            : isDark ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600'
+                        }`}
+                      >
+                        {tab === 'final' ? 'Final Answer' : tab === 'timeline' ? 'Timeline' : 'Per-Model'}
+                      </button>
+                    ))}
+                  </div>
+
+                  {/* Final Answer tab */}
+                  {debateTab === 'final' && (
+                    <div>
+                      {selectedNode.data.debateData.finalVerdict ? (
+                        <>
+                          <div className={`text-xs mb-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Judge: {selectedNode.data.debateData.finalVerdict.model}</div>
+                          <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${
+                            isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900'
+                          }`}>
+                            {rawTextMode ? (
+                              <pre className="whitespace-pre-wrap break-words">{selectedNode.data.debateData.finalVerdict.response}</pre>
+                            ) : (
+                              <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+                                {preprocessLaTeX(selectedNode.data.debateData.finalVerdict.response)}
+                              </ReactMarkdown>
+                            )}
+                          </div>
+                        </>
+                      ) : selectedNode.data.status === 'loading' ? (
+                        <div className={`p-3 rounded-md border min-h-[150px] text-sm ${
+                          isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'
+                        }`}>
+                          {debateStreamBuffer ? (
+                            rawTextMode ? (
+                              <pre className="whitespace-pre-wrap break-words">{debateStreamBuffer}</pre>
+                            ) : (
+                              <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+                                {preprocessLaTeX(debateStreamBuffer)}
+                              </ReactMarkdown>
+                            )
+                          ) : (
+                            <div className={`flex items-center gap-2 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>
+                              <Loader2 className="animate-spin" size={14} /> Waiting for final verdict...
+                            </div>
+                          )}
+                        </div>
+                      ) : (
+                        <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}>
+                          No final verdict
+                        </div>
+                      )}
+                    </div>
+                  )}
+
+                  {/* Debate Timeline tab */}
+                  {debateTab === 'timeline' && (
+                    <div className="space-y-2 max-h-[500px] overflow-y-auto">
+                      {selectedNode.data.debateData.rounds.map((round) => {
+                        const isExpanded = debateTimelineExpandedRounds.has(round.round);
+                        return (
+                          <div key={round.round} className={`rounded border ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+                            <button
+                              onClick={() => {
+                                const next = new Set(debateTimelineExpandedRounds);
+                                if (isExpanded) next.delete(round.round); else next.add(round.round);
+                                setDebateTimelineExpandedRounds(next);
+                              }}
+                              className={`w-full flex items-center justify-between px-3 py-2 text-xs font-semibold ${
+                                isDark ? 'bg-gray-800 text-gray-300 hover:bg-gray-750' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'
+                              }`}
+                            >
+                              <span>Round {round.round} ({round.responses.length} responses)</span>
+                              <div className="flex items-center gap-2">
+                                {round.judgeDecision && (
+                                  <span className={`px-1.5 py-0.5 rounded text-[10px] ${
+                                    round.judgeDecision.continue
+                                      ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700'
+                                      : isDark ? 'bg-red-900/50 text-red-300' : 'bg-red-100 text-red-700'
+                                  }`}>
+                                    {round.judgeDecision.continue ? 'Continue' : 'Stop'}
+                                  </span>
+                                )}
+                                {round.converged !== undefined && (
+                                  <span className={`px-1.5 py-0.5 rounded text-[10px] ${
+                                    round.converged
+                                      ? isDark ? 'bg-green-900/50 text-green-300' : 'bg-green-100 text-green-700'
+                                      : isDark ? 'bg-yellow-900/50 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
+                                  }`}>
+                                    {round.converged ? 'Converged' : 'Divergent'}
+                                  </span>
+                                )}
+                                <ChevronDown size={12} className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
+                              </div>
+                            </button>
+                            {isExpanded && (
+                              <div className={`p-2 space-y-2 ${isDark ? 'bg-gray-900' : 'bg-white'}`}>
+                                {round.responses.map((resp, ri) => (
+                                  <div key={ri} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+                                    <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>{resp.model}</div>
+                                    <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}>
+                                      {rawTextMode ? (
+                                        <pre className="whitespace-pre-wrap break-words">{resp.response}</pre>
+                                      ) : (
+                                        <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+                                          {preprocessLaTeX(resp.response)}
+                                        </ReactMarkdown>
+                                      )}
+                                    </div>
+                                  </div>
+                                ))}
+                                {round.judgeDecision && (
+                                  <div className={`rounded border p-2 text-xs ${isDark ? 'border-amber-800/50 bg-amber-900/20 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-800'}`}>
+                                    <div className="font-semibold mb-1">Judge Decision</div>
+                                    <div className="whitespace-pre-wrap">{round.judgeDecision.reasoning}</div>
+                                  </div>
+                                )}
+                              </div>
+                            )}
+                          </div>
+                        );
+                      })}
+                      {selectedNode.data.status === 'loading' && debateStage && (
+                        <div className={`flex items-center gap-2 p-2 text-xs ${isDark ? 'text-cyan-400' : 'text-cyan-600'}`}>
+                          <Loader2 className="animate-spin" size={12} /> {debateStage}
+                        </div>
+                      )}
+                    </div>
+                  )}
+
+                  {/* Per-Model View tab */}
+                  {debateTab === 'per-model' && (
+                    <div>
+                      {(() => {
+                        const allModels = new Set<string>();
+                        selectedNode.data.debateData!.rounds.forEach(r => r.responses.forEach(resp => allModels.add(resp.model)));
+                        const modelList = Array.from(allModels);
+                        const selectedModel = debatePerModelSelected || modelList[0] || '';
+                        return (
+                          <>
+                            <div className={`flex gap-0.5 mb-2 flex-wrap`}>
+                              {modelList.map(model => (
+                                <button
+                                  key={model}
+                                  onClick={() => setDebatePerModelSelected(model)}
+                                  className={`px-2 py-0.5 text-xs rounded transition-colors ${
+                                    (debatePerModelSelected || modelList[0]) === model
+                                      ? isDark ? 'bg-cyan-900/50 text-cyan-300' : 'bg-cyan-100 text-cyan-700'
+                                      : isDark ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+                                  }`}
+                                >
+                                  {model}
+                                </button>
+                              ))}
+                            </div>
+                            <div className="space-y-2 max-h-[400px] overflow-y-auto">
+                              {selectedNode.data.debateData!.rounds.map(round => {
+                                const resp = round.responses.find(r => r.model === selectedModel);
+                                if (!resp) return null;
+                                return (
+                                  <div key={round.round} className={`rounded border p-2 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+                                    <div className={`text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Round {round.round}</div>
+                                    <div className={`text-xs prose prose-sm max-w-none ${isDark ? 'prose-invert text-gray-300' : 'text-gray-700'}`}>
+                                      {rawTextMode ? (
+                                        <pre className="whitespace-pre-wrap break-words">{resp.response}</pre>
+                                      ) : (
+                                        <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+                                          {preprocessLaTeX(resp.response)}
+                                        </ReactMarkdown>
+                                      )}
+                                    </div>
+                                  </div>
+                                );
+                              })}
+                            </div>
+                          </>
+                        );
+                      })()}
+                    </div>
+                  )}
+                </div>
               ) : isEditing ? (
                 <div className="space-y-2">
                   <textarea
-- 
cgit v1.2.3

