From c0ac77fcacc1d94563498f4bf2f02a87eda15b60 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:04:13 -0400 Subject: [PATCH] Improve council agent UX and persistence (#3393) --- .../intelligence/ModelCouncilTab.test.tsx | 656 +++++- .../intelligence/ModelCouncilTab.tsx | 1929 +++++++++++++++-- app/src/lib/i18n/ar.ts | 99 + app/src/lib/i18n/bn.ts | 99 + app/src/lib/i18n/de.ts | 99 + app/src/lib/i18n/en.ts | 99 + app/src/lib/i18n/es.ts | 99 + app/src/lib/i18n/fr.ts | 99 + app/src/lib/i18n/hi.ts | 99 + app/src/lib/i18n/id.ts | 99 + app/src/lib/i18n/it.ts | 99 + app/src/lib/i18n/ko.ts | 99 + app/src/lib/i18n/pl.ts | 99 + app/src/lib/i18n/pt.ts | 99 + app/src/lib/i18n/ru.ts | 99 + app/src/lib/i18n/zh-CN.ts | 98 + .../services/api/councilRegistryApi.test.ts | 89 + app/src/services/api/councilRegistryApi.ts | 87 + app/src/services/api/modelCouncilApi.test.ts | 57 + app/src/services/api/modelCouncilApi.ts | 40 +- package.json | 1 + scripts/seed-councils.mjs | 210 ++ src/core/all.rs | 4 + .../agent/harness/session/builder.rs | 68 +- src/openhuman/council_registry/mod.rs | 12 + src/openhuman/council_registry/schemas.rs | 199 ++ src/openhuman/council_registry/store.rs | 284 +++ src/openhuman/council_registry/types.rs | 126 ++ src/openhuman/mod.rs | 1 + src/openhuman/model_council/council.rs | 269 ++- src/openhuman/model_council/schemas.rs | 176 +- tests/json_rpc_e2e.rs | 349 +++ 32 files changed, 5635 insertions(+), 307 deletions(-) create mode 100644 app/src/services/api/councilRegistryApi.test.ts create mode 100644 app/src/services/api/councilRegistryApi.ts create mode 100644 scripts/seed-councils.mjs create mode 100644 src/openhuman/council_registry/mod.rs create mode 100644 src/openhuman/council_registry/schemas.rs create mode 100644 src/openhuman/council_registry/store.rs create mode 100644 src/openhuman/council_registry/types.rs diff --git a/app/src/components/intelligence/ModelCouncilTab.test.tsx b/app/src/components/intelligence/ModelCouncilTab.test.tsx index 9fe7442b4..4fcc6ef87 100644 --- a/app/src/components/intelligence/ModelCouncilTab.test.tsx +++ b/app/src/components/intelligence/ModelCouncilTab.test.tsx @@ -1,134 +1,650 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ModelCouncilResult } from '../../services/api/modelCouncilApi'; +import type { CouncilDefinition } from '../../services/api/councilRegistryApi'; +import type { CouncilMemberResult, ModelCouncilResult } from '../../services/api/modelCouncilApi'; import ModelCouncilTab from './ModelCouncilTab'; -const mockRunCouncil = vi.fn(); +const mockListCouncils = vi.fn(); +const mockUpsertCouncil = vi.fn(); +const mockDeleteCouncil = vi.fn(); +const mockAnswerMember = vi.fn(); +const mockSynthesizeCouncil = vi.fn(); +const mockLoadAISettings = vi.fn(); +const mockLoadLocalProviderSnapshot = vi.fn(); +const mockListProviderModels = vi.fn(); +const mockDispatch = vi.fn(); + +const mockState = { + agentProfiles: { + profiles: [ + { + id: 'default', + name: 'Default Agent', + description: 'Default', + agentId: 'openhuman.default', + modelOverride: 'profile-model', + builtIn: true, + }, + { + id: 'critic', + name: 'Critic', + description: 'Finds gaps', + agentId: 'critic-agent', + modelOverride: 'critic-model', + builtIn: false, + }, + ], + activeProfileId: 'default', + status: 'idle', + error: null, + }, +}; + vi.mock('../../services/api/modelCouncilApi', () => ({ - modelCouncilApi: { runCouncil: (...args: unknown[]) => mockRunCouncil(...args) }, + modelCouncilApi: { + answerMember: (...args: unknown[]) => mockAnswerMember(...args), + synthesizeCouncil: (...args: unknown[]) => mockSynthesizeCouncil(...args), + }, +})); + +vi.mock('../../services/api/councilRegistryApi', () => ({ + councilRegistryApi: { + list: (...args: unknown[]) => mockListCouncils(...args), + upsert: (...args: unknown[]) => mockUpsertCouncil(...args), + delete: (...args: unknown[]) => mockDeleteCouncil(...args), + }, +})); + +vi.mock('../../services/api/aiSettingsApi', () => ({ + loadAISettings: (...args: unknown[]) => mockLoadAISettings(...args), + loadLocalProviderSnapshot: (...args: unknown[]) => mockLoadLocalProviderSnapshot(...args), + listProviderModels: (...args: unknown[]) => mockListProviderModels(...args), +})); + +vi.mock('../../store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +vi.mock('../../features/human/Mascot', () => ({ + RiveMascot: ({ face }: { face?: string }) =>
, + getMascotPalette: () => ({ bodyFill: '#F7D145', neckShadowColor: '#B23C05' }), + hexToArgbInt: () => 0xfff7d145, })); const RESULT: ModelCouncilResult = { question: 'What is the capital of France?', members: [ - { model: 'model-a', response: 'Paris is the capital.', error: null }, - { model: 'model-b', response: null, error: 'rate limited' }, + { model: 'gpt-5.2', response: 'Paris is the capital.', error: null }, + { model: 'critic-model', response: null, error: 'rate limited' }, ], - chair_model: 'chair-model', + chair_model: 'claude-opus-4-8', synthesis: 'Both that answered agree: Paris. One seat failed.', }; -const fillValidForm = () => { +const DEFAULT_MEMBERS: CouncilMemberResult[] = [ + { model: 'reasoning-v1', response: 'Paris is the capital.', error: null }, + { model: 'reasoning-v1', response: 'France uses Paris as its capital.', error: null }, + { model: 'reasoning-v1', response: 'The answer is Paris.', error: null }, +]; + +const DEFAULT_COUNCIL: CouncilDefinition = { + id: 'default-council', + name: 'Default council', + description: 'Balanced analyst, builder, and skeptic jury.', + jury_count: 3, + debate_rounds: 3, + seats: [ + { + id: 0, + mode: 'default', + profile_id: '', + name: 'Analyst', + model: 'reasoning-v1', + brief: 'Evidence, assumptions, and risk.', + }, + { + id: 1, + mode: 'default', + profile_id: '', + name: 'Builder', + model: 'reasoning-v1', + brief: 'Practical implementation path.', + }, + { + id: 2, + mode: 'default', + profile_id: '', + name: 'Skeptic', + model: 'reasoning-v1', + brief: 'Failure modes and missing context.', + }, + ], + judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: 'reasoning-v1' }, + shared_reasoning: [ + '# Shared reasoning', + '- Claims the council agrees on:', + '- Open disagreements:', + '- Evidence or constraints to preserve:', + '- Judge synthesis notes:', + ].join('\n'), + created_at_ms: 1, + updated_at_ms: 1, +}; + +const fillQuestion = () => { fireEvent.change(screen.getByLabelText('Question'), { target: { value: 'What is the capital of France?' }, }); - fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'model-a' } }); - fireEvent.change(screen.getByLabelText('Member model 2'), { target: { value: 'model-b' } }); - fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'chair-model' } }); +}; + +const mockProgressiveSuccess = (members: CouncilMemberResult[] = DEFAULT_MEMBERS) => { + mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => { + const index = mockAnswerMember.mock.calls.length - 1; + return members[index] ?? { model, response: `answer ${index + 1}`, error: null }; + }); + mockSynthesizeCouncil.mockResolvedValue(RESULT); +}; + +const renderCouncilList = async () => { + render(); + await screen.findByRole('button', { name: 'Open council' }); +}; + +const renderOpenCouncil = async () => { + await renderCouncilList(); + fireEvent.click(screen.getByRole('button', { name: 'Open council' })); + await screen.findByLabelText('Question'); +}; + +const renderEditCouncil = async () => { + await renderOpenCouncil(); + fireEvent.click(screen.getByRole('button', { name: 'Edit current council' })); + await screen.findByLabelText('Council name'); +}; + +const saveCouncilSettings = async () => { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save council' })); + }); + await screen.findByLabelText('Question'); }; describe('ModelCouncilTab', () => { beforeEach(() => { - mockRunCouncil.mockReset(); + mockListCouncils.mockReset(); + mockUpsertCouncil.mockReset(); + mockDeleteCouncil.mockReset(); + mockAnswerMember.mockReset(); + mockSynthesizeCouncil.mockReset(); + mockLoadAISettings.mockReset(); + mockLoadLocalProviderSnapshot.mockReset(); + mockListProviderModels.mockReset(); + mockDispatch.mockReset(); + mockListCouncils.mockResolvedValue([DEFAULT_COUNCIL]); + mockUpsertCouncil.mockImplementation(async council => ({ + ...council, + id: council.id || 'saved', + })); + mockDeleteCouncil.mockResolvedValue(true); + mockLoadAISettings.mockResolvedValue({ + cloudProviders: [ + { + id: 'openai-id', + slug: 'openai', + label: 'OpenAI', + endpoint: 'https://api.openai.com/v1', + auth_style: 'bearer', + has_api_key: true, + }, + { + id: 'anthropic-id', + slug: 'anthropic', + label: 'Anthropic', + endpoint: 'https://api.anthropic.com/v1', + auth_style: 'anthropic', + has_api_key: false, + }, + ], + routing: {}, + }); + mockLoadLocalProviderSnapshot.mockResolvedValue({ + status: null, + diagnostics: null, + presets: null, + installedModels: [{ name: 'llama3.2:latest', chat_capable: true }], + }); + mockListProviderModels.mockImplementation(async (provider: string) => { + if (provider === 'openhuman') return [{ id: 'managed-reasoning' }]; + if (provider === 'openai') return [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }]; + return []; + }); }); - it('renders the compose surface with two member rows by default', () => { - render(); - expect(screen.getByText('Model Council')).toBeInTheDocument(); - expect(screen.getByLabelText('Question')).toBeInTheDocument(); - expect(screen.getByLabelText('Member model 1')).toBeInTheDocument(); - expect(screen.getByLabelText('Member model 2')).toBeInTheDocument(); - expect(screen.getByLabelText('Chair model')).toBeInTheDocument(); + it('renders the council list first, then opens the default council', async () => { + await renderCouncilList(); + + expect(screen.getByText('Councils')).toBeInTheDocument(); + expect(screen.getByText('Default council')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Open council' })); + + await screen.findByLabelText('Question'); + expect(screen.getByText('Default council')).toBeInTheDocument(); + expect(screen.queryByText('Council settings')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Debate turns')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Shared reasoning file')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Convene council' })).toBeInTheDocument(); }); - it('disables Convene until question + a member + chair are all filled', () => { - render(); + it('allows the default council to be deleted from the persisted registry', async () => { + await renderCouncilList(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Delete Default council' })); + }); + + expect(mockDeleteCouncil).toHaveBeenCalledWith('default-council'); + expect(screen.queryByText('Default council')).not.toBeInTheDocument(); + expect(screen.getByText('No councils yet. Add one to get started.')).toBeInTheDocument(); + }); + + it('uses the jury count setting to resize the roster up to five', async () => { + await renderEditCouncil(); + + expect(screen.queryByLabelText('Question')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Convene council' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '5' })); + + expect(screen.getAllByTestId('rive-mascot')).toHaveLength(5); + expect(screen.getAllByText('Juror 5')).toHaveLength(2); + expect(screen.getByLabelText('Juror 5 name')).toBeInTheDocument(); + }); + + it('disables Convene until a question is filled because seats and judge have defaults', async () => { + await renderOpenCouncil(); + const run = screen.getByRole('button', { name: 'Convene council' }); expect(run).toBeDisabled(); - fireEvent.change(screen.getByLabelText('Question'), { target: { value: 'q' } }); - expect(run).toBeDisabled(); - fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'm' } }); - expect(run).toBeDisabled(); - fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'c' } }); + fillQuestion(); expect(run).not.toBeDisabled(); }); - it('adds member rows up to the max of 5 and stops', () => { - render(); - const add = screen.getByRole('button', { name: '+ Add model' }); - fireEvent.click(add); // 3 - fireEvent.click(add); // 4 - fireEvent.click(add); // 5 - expect(screen.getByLabelText('Member model 5')).toBeInTheDocument(); - expect(add).toBeDisabled(); - expect(screen.queryByLabelText('Member model 6')).not.toBeInTheDocument(); - }); + it('shows mascot deliberation and agent thoughts while the council is running', async () => { + let resolveFirst: (value: CouncilMemberResult) => void = () => {}; + let resolveSecond: (value: CouncilMemberResult) => void = () => {}; + let resolveThird: (value: CouncilMemberResult) => void = () => {}; + let resolveSynthesis: (value: ModelCouncilResult) => void = () => {}; + mockAnswerMember + .mockImplementation(async ({ model }: { model: string }) => ({ + model, + response: `follow-up thought ${mockAnswerMember.mock.calls.length}`, + error: null, + })) + .mockReturnValueOnce( + new Promise(resolve => { + resolveFirst = resolve; + }) + ) + .mockReturnValueOnce( + new Promise(resolve => { + resolveSecond = resolve; + }) + ) + .mockReturnValueOnce( + new Promise(resolve => { + resolveThird = resolve; + }) + ); + mockSynthesizeCouncil.mockReturnValueOnce( + new Promise(resolve => { + resolveSynthesis = resolve; + }) + ); + await renderOpenCouncil(); + fillQuestion(); - it('removes member rows but never below one', () => { - render(); - // Two rows initially; remove one → one left, remove button then disabled. - fireEvent.click(screen.getByRole('button', { name: 'Remove member model 2' })); - expect(screen.queryByLabelText('Member model 2')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Remove member model 1' })).toBeDisabled(); - }); - - it('runs the council and renders member answers side-by-side + the synthesis', async () => { - mockRunCouncil.mockResolvedValueOnce(RESULT); - render(); - fillValidForm(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); }); - expect(mockRunCouncil).toHaveBeenCalledWith({ - question: 'What is the capital of France?', - member_models: ['model-a', 'model-b'], - chair_model: 'chair-model', + + expect(screen.getByText('Council deliberation')).toBeInTheDocument(); + expect(screen.getAllByText('Thinking')).toHaveLength(3); + expect(screen.getByText('Judge')).toBeInTheDocument(); + expect( + screen.getByText(/Waiting for juror answers, then reading the shared reasoning file/) + ).toBeInTheDocument(); + expect(screen.getAllByTestId('rive-mascot')).toHaveLength(4); + expect(screen.getAllByTestId('rive-mascot')[0]).toHaveAttribute('data-face', 'thinking'); + + await act(async () => { + resolveFirst({ + model: 'reasoning-v1', + response: 'First juror live thought: Paris.', + error: null, + }); }); + + expect(screen.getByText('First juror live thought: Paris.')).toBeInTheDocument(); + expect(screen.getByText('Round 1')).toBeInTheDocument(); + expect(screen.getAllByText('Thinking')).toHaveLength(2); + expect(screen.getByText('Answered')).toBeInTheDocument(); + expect(screen.getByText('Judge')).toBeInTheDocument(); + + await act(async () => { + resolveSecond({ model: 'reasoning-v1', response: 'Second juror agrees.', error: null }); + resolveThird({ model: 'reasoning-v1', response: 'Third juror agrees.', error: null }); + }); + + await waitFor(() => { + expect(screen.getByText('Synthesizing')).toBeInTheDocument(); + }); + + await act(async () => { + resolveSynthesis(RESULT); + }); + + await waitFor(() => { + expect(screen.queryByText('Council deliberation')).not.toBeInTheDocument(); + }); + }); + + it('streams failed juror status without blocking other juror thoughts', async () => { + let resolveFirst: (value: CouncilMemberResult) => void = () => {}; + let resolveSecond: (value: CouncilMemberResult) => void = () => {}; + let resolveThird: (value: CouncilMemberResult) => void = () => {}; + mockAnswerMember + .mockImplementation(async ({ model }: { model: string }) => ({ + model, + response: `follow-up answer ${mockAnswerMember.mock.calls.length}`, + error: null, + })) + .mockReturnValueOnce( + new Promise(resolve => { + resolveFirst = resolve; + }) + ) + .mockReturnValueOnce( + new Promise(resolve => { + resolveSecond = resolve; + }) + ) + .mockReturnValueOnce( + new Promise(resolve => { + resolveThird = resolve; + }) + ); + mockSynthesizeCouncil.mockResolvedValueOnce(RESULT); + await renderOpenCouncil(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + + await act(async () => { + resolveFirst({ model: 'reasoning-v1', response: null, error: 'rate limited' }); + }); + + expect(screen.getByText('rate limited')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getAllByText('Thinking')).toHaveLength(2); + + await act(async () => { + resolveSecond({ model: 'reasoning-v1', response: 'Second juror answer.', error: null }); + resolveThird({ model: 'reasoning-v1', response: 'Third juror answer.', error: null }); + }); + + await waitFor(() => { + expect(mockSynthesizeCouncil).toHaveBeenCalledWith({ + question: expect.any(String), + members: [ + { + model: 'reasoning-v1', + response: expect.stringContaining('[failed: rate limited]'), + error: null, + }, + { + model: 'reasoning-v1', + response: expect.stringContaining('Second juror answer.'), + error: null, + }, + { + model: 'reasoning-v1', + response: expect.stringContaining('Third juror answer.'), + error: null, + }, + ], + chair_model: 'reasoning-v1', + }); + }); + }); + + it('appends juror turns to the shared scratchpad before the next debate round', async () => { + mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => ({ + model, + response: `round ${mockAnswerMember.mock.calls.length} update`, + error: null, + })); + mockSynthesizeCouncil.mockResolvedValueOnce(RESULT); + await renderOpenCouncil(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + + await waitFor(() => { + expect(mockSynthesizeCouncil).toHaveBeenCalled(); + }); + expect(mockAnswerMember.mock.calls[3][0].question).toContain('Round 1 updates'); + expect(mockAnswerMember.mock.calls[3][0].question).toContain('round 1 update'); + }); + + it('lets a juror model be selected from routing hints', async () => { + mockProgressiveSuccess(); + await renderEditCouncil(); + + fireEvent.click(screen.getByLabelText('Member model 1')); + expect(screen.getByRole('dialog', { name: 'Member model 1' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Reasoning/ })); + await saveCouncilSettings(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + + expect(mockAnswerMember.mock.calls.map(call => call[0].model).slice(0, 3)).toEqual([ + 'hint:reasoning', + 'reasoning-v1', + 'reasoning-v1', + ]); + }); + + it('enables provider and model dropdowns only after choosing Custom', async () => { + await renderEditCouncil(); + + fireEvent.click(screen.getByLabelText('Member model 1')); + const dialog = screen.getByRole('dialog', { name: 'Member model 1' }); + const providerSelect = within(dialog).getByLabelText('Model provider'); + const modelSelect = within(dialog).getByLabelText('Model id'); + + expect(providerSelect).toBeDisabled(); + expect(modelSelect).toBeDisabled(); + + fireEvent.click(within(dialog).getByRole('button', { name: /Provider \+ model/ })); + + await waitFor(() => expect(providerSelect).not.toBeDisabled()); + expect( + within(providerSelect).getByRole('option', { name: 'Managed (openhuman)' }) + ).toBeInTheDocument(); + expect( + within(providerSelect).getByRole('option', { name: 'OpenAI (openai)' }) + ).toBeInTheDocument(); + expect( + within(providerSelect).queryByRole('option', { name: 'Anthropic (anthropic)' }) + ).not.toBeInTheDocument(); + + await waitFor(() => expect(modelSelect).not.toBeDisabled()); + expect( + within(modelSelect).getByRole('option', { name: 'managed-reasoning' }) + ).toBeInTheDocument(); + + fireEvent.change(providerSelect, { target: { value: 'openai' } }); + await waitFor(() => { + expect(within(modelSelect).getByRole('option', { name: 'gpt-4o' })).toBeInTheDocument(); + }); + fireEvent.change(modelSelect, { target: { value: 'gpt-4o' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Use provider model' })); + + expect(screen.getByLabelText('Member model 1')).toHaveTextContent('openai:gpt-4o'); + }); + + it('lets a council seat use a saved profile and submits that profile model', async () => { + mockProgressiveSuccess(); + await renderEditCouncil(); + + const firstSeat = screen.getByLabelText('Juror 1 name').closest('article'); + expect(firstSeat).not.toBeNull(); + fireEvent.click(within(firstSeat as HTMLElement).getByRole('tab', { name: 'Profile' })); + fireEvent.change(screen.getByLabelText('Juror 1 profile'), { target: { value: 'critic' } }); + await saveCouncilSettings(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + + expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([ + 'critic-model', + 'reasoning-v1', + 'reasoning-v1', + 'critic-model', + 'reasoning-v1', + 'reasoning-v1', + 'critic-model', + 'reasoning-v1', + 'reasoning-v1', + ]); + expect(mockSynthesizeCouncil).toHaveBeenCalledWith({ + question: expect.stringContaining('shared_reasoning.md'), + members: expect.any(Array), + chair_model: 'reasoning-v1', + }); + expect(mockAnswerMember.mock.calls[0][0].question).toContain('User question:'); + expect(mockAnswerMember.mock.calls[0][0].question).toContain('What is the capital of France?'); + expect(mockAnswerMember.mock.calls[0][0].question).toContain('Debate round 1 of 3.'); + expect(mockAnswerMember.mock.calls[8][0].question).toContain('Debate round 3 of 3.'); + }); + + it('lets the judge agent use a saved profile unless a model override is typed', async () => { + mockProgressiveSuccess(); + await renderEditCouncil(); + + fireEvent.change(screen.getByLabelText('Judge agent'), { target: { value: 'profile' } }); + fireEvent.change(screen.getByLabelText('Judge profile'), { target: { value: 'critic' } }); + await saveCouncilSettings(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + + expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([ + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + 'reasoning-v1', + ]); + expect(mockSynthesizeCouncil).toHaveBeenCalledWith({ + question: expect.any(String), + members: expect.any(Array), + chair_model: 'critic-model', + }); + }); + + it('renders member answers side-by-side + the synthesis', async () => { + mockProgressiveSuccess(RESULT.members); + await renderOpenCouncil(); + fillQuestion(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + await waitFor(() => { expect(screen.getByText('Council results')).toBeInTheDocument(); }); - // Member A answered; Member B failed. expect(screen.getByText('Paris is the capital.')).toBeInTheDocument(); expect(screen.getByText('rate limited')).toBeInTheDocument(); expect(screen.getByText('Answered')).toBeInTheDocument(); expect(screen.getByText('Failed')).toBeInTheDocument(); - // Synthesis from the chair. expect( screen.getByText('Both that answered agree: Paris. One seat failed.') ).toBeInTheDocument(); - expect(screen.getByText('by chair-model')).toBeInTheDocument(); + expect(screen.getByText('by claude-opus-4-8')).toBeInTheDocument(); + expect(screen.getByText('Debate usage')).toBeInTheDocument(); + expect(screen.getByText('Total')).toBeInTheDocument(); }); - it('trims whitespace and drops blank member rows before calling the API', async () => { - mockRunCouncil.mockResolvedValueOnce(RESULT); - render(); - fireEvent.change(screen.getByLabelText('Question'), { target: { value: ' hi ' } }); - fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: ' model-a ' } }); - // leave member 2 blank - fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: ' chair ' } }); + it('renders council markdown instead of showing raw markdown markers', async () => { + mockProgressiveSuccess([ + { model: 'reasoning-v1', response: '**Paris** is the capital.', error: null }, + { model: 'reasoning-v1', response: '- France\n- Paris', error: null }, + { model: 'reasoning-v1', response: '`Paris` remains the answer.', error: null }, + ]); + mockSynthesizeCouncil.mockResolvedValueOnce({ + ...RESULT, + members: [ + { model: 'reasoning-v1', response: '**Paris** is the capital.', error: null }, + { model: 'reasoning-v1', response: '- France\n- Paris', error: null }, + ], + synthesis: '## Consensus\n\nThe answer is **Paris**.', + }); + await renderOpenCouncil(); + fillQuestion(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); }); - expect(mockRunCouncil).toHaveBeenCalledWith({ - question: 'hi', - member_models: ['model-a'], - chair_model: 'chair', + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Consensus' })).toBeInTheDocument(); }); + const results = screen.getByText('Council results').closest('section'); + expect(results).not.toBeNull(); + expect(screen.getAllByText('Paris').some(node => node.tagName.toLowerCase() === 'strong')).toBe( + true + ); + expect(within(results as HTMLElement).queryByText(/\*\*Paris\*\*/)).not.toBeInTheDocument(); }); it('surfaces an error alert when the council run fails', async () => { - mockRunCouncil.mockRejectedValueOnce(new Error('all member models failed to respond')); - render(); - fillValidForm(); + mockAnswerMember.mockResolvedValue({ + model: 'reasoning-v1', + response: null, + error: 'downstream', + }); + mockSynthesizeCouncil.mockRejectedValueOnce(new Error('all member models failed to respond')); + await renderOpenCouncil(); + fillQuestion(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); }); + await waitFor(() => { const alert = screen.getByRole('alert'); expect(alert.textContent).toMatch(/all member models failed to respond/); }); - // No results section on failure. expect(screen.queryByText('Council results')).not.toBeInTheDocument(); }); }); diff --git a/app/src/components/intelligence/ModelCouncilTab.tsx b/app/src/components/intelligence/ModelCouncilTab.tsx index 28ba84fdb..adcc8af96 100644 --- a/app/src/components/intelligence/ModelCouncilTab.tsx +++ b/app/src/components/intelligence/ModelCouncilTab.tsx @@ -1,203 +1,1766 @@ /** - * Model Council tab — ask one question, get independent answers from several - * models in parallel, then a chair model's synthesis of where they agree, - * disagree, and what unique insight each added. + * Model Council tab — configure a small council of agent-flavored model seats, + * ask one question, then let a judge model synthesize the responses. * - * The orchestration (parallel member calls + chair synthesis) lives in the - * Rust core behind `openhuman.model_council_run`; this tab is the compose + - * compare surface. Model ids are entered as free text because the available - * set is provider-specific (local Ollama + any configured cloud providers) - * and the council accepts arbitrary ids. + * The Rust core still owns orchestration through `openhuman.model_council_run`. + * This surface gives each seat an agent profile, Rive presence, and council + * settings, then resolves the roster to model ids for the existing RPC. */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + getMascotPalette, + hexToArgbInt, + type MascotFace, + RiveMascot, +} from '../../features/human/Mascot'; import { useT } from '../../lib/i18n/I18nContext'; -import { modelCouncilApi, type ModelCouncilResult } from '../../services/api/modelCouncilApi'; +import { BubbleMarkdown } from '../../pages/conversations/components/AgentMessageBubble'; +import { + listProviderModels, + loadAISettings, + loadLocalProviderSnapshot, + type ModelInfo, +} from '../../services/api/aiSettingsApi'; +import { type CouncilDefinition, councilRegistryApi } from '../../services/api/councilRegistryApi'; +import { + type CouncilMemberResult, + modelCouncilApi, + type ModelCouncilResult, +} from '../../services/api/modelCouncilApi'; +import { + type AgentProfilesStatus, + loadAgentProfiles, + selectAgentProfiles, +} from '../../store/agentProfileSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import type { AgentProfile } from '../../types/agentProfile'; /** Matches the server-side MAX_COUNCIL_MEMBERS cap. */ const MAX_MEMBERS = 5; +const MIN_MEMBERS = 1; +const MAX_DEBATE_ROUNDS = 4; +const MIN_DEBATE_ROUNDS = 2; +const DEFAULT_REASONING_MODEL = 'reasoning-v1'; -/** A member row carries a stable id so React keys survive mid-list removal. */ -interface MemberRow { +type SeatMode = 'default' | 'profile' | 'custom'; + +interface CouncilSeat { id: number; - value: string; + mode: SeatMode; + profileId: string; + name: string; + model: string; + brief: string; } -/** Next id = max existing + 1: unique among current rows, no ref/StrictMode hazard. */ -const nextMemberId = (rows: MemberRow[]): number => - rows.reduce((max, r) => Math.max(max, r.id), -1) + 1; +interface ResolvedSeat { + label: string; + model: string; + brief: string; +} + +interface LiveMemberThought { + status: 'pending' | 'answered' | 'failed'; + member: CouncilMemberResult | null; + turns: CouncilDebateTurn[]; +} + +interface CouncilDebateTurn { + round: number; + response: string | null; + error: string | null; +} + +interface DebateUsageEstimate { + inputTokens: number; + outputTokens: number; + totalTokens: number; +} + +interface ModelPickerState { + title: string; + value: string; + onSelect: (model: string) => void; +} + +const MODEL_HINTS = [ + { value: 'default', label: 'Default' }, + { value: 'hint:chat', label: 'Chat' }, + { value: 'hint:reasoning', label: 'Reasoning' }, + { value: 'hint:code', label: 'Code' }, + { value: 'hint:summarize', label: 'Summarize' }, +] as const; + +interface ConnectedModelProvider { + slug: string; + label: string; + models?: ModelInfo[]; +} + +function parseProviderModel(value: string): { provider: string; model: string } { + const trimmed = value.trim(); + const colon = trimmed.indexOf(':'); + if (colon <= 0) { + return { provider: '', model: trimmed }; + } + return { provider: trimmed.slice(0, colon), model: trimmed.slice(colon + 1) }; +} + +async function loadConnectedModelProviders(): Promise { + const [settings, localSnapshot] = await Promise.all([ + loadAISettings(), + loadLocalProviderSnapshot().catch(() => null), + ]); + const providers: ConnectedModelProvider[] = [{ slug: 'openhuman', label: 'Managed' }]; + const seen = new Set(providers.map(provider => provider.slug)); + + for (const provider of settings.cloudProviders) { + const slug = provider.slug.trim(); + if (!slug || seen.has(slug)) continue; + if (!provider.has_api_key && provider.auth_style !== 'none') continue; + providers.push({ slug, label: provider.label || slug }); + seen.add(slug); + } + + const localModels = + localSnapshot?.installedModels + .filter(model => model.chat_capable !== false) + .map(model => ({ id: model.name, context_window: model.context_length ?? null })) ?? []; + if (localModels.length > 0 && !seen.has('ollama')) { + providers.push({ slug: 'ollama', label: 'Ollama', models: localModels }); + } + + return providers; +} + +const Icon = ({ + name, + size = 16, +}: { + name: 'arrow-left' | 'plus' | 'settings' | 'trash'; + size?: number; +}) => { + const common = { + fill: 'none', + stroke: 'currentColor', + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + strokeWidth: 2, + }; + return ( + + ); +}; + +const ModelPickerDialog = ({ + picker, + onClose, +}: { + picker: ModelPickerState; + onClose: () => void; +}) => { + const { t } = useT(); + const initial = parseProviderModel(picker.value); + const initialHint = MODEL_HINTS.some(hint => hint.value === picker.value); + const [selectionMode, setSelectionMode] = useState<'hint' | 'custom'>( + initial.provider && !initialHint ? 'custom' : 'hint' + ); + const [providers, setProviders] = useState([]); + const [providersLoading, setProvidersLoading] = useState(false); + const [providersError, setProvidersError] = useState(null); + const [provider, setProvider] = useState(initial.provider); + const [models, setModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const [modelsError, setModelsError] = useState(null); + const [model, setModel] = useState(initial.model); + + useEffect(() => { + let active = true; + setProvidersLoading(true); + setProvidersError(null); + loadConnectedModelProviders() + .then(loaded => { + if (!active) return; + setProviders(loaded); + setProvidersLoading(false); + setProvider(current => { + if (current && loaded.some(item => item.slug === current)) return current; + return loaded[0]?.slug ?? ''; + }); + }) + .catch(err => { + if (!active) return; + setProvidersError(err instanceof Error ? err.message : String(err)); + setProvidersLoading(false); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (selectionMode !== 'custom' || !provider) { + setModels([]); + setModelsError(null); + return; + } + + const connectedProvider = providers.find(item => item.slug === provider); + if (!connectedProvider) { + setModels([]); + setModelsError(null); + return; + } + + if (connectedProvider.models) { + setModels(connectedProvider.models); + setModelsError(null); + setModelsLoading(false); + setModel(current => current || connectedProvider.models?.[0]?.id || ''); + return; + } + + let active = true; + setModelsLoading(true); + setModels([]); + setModelsError(null); + listProviderModels(provider) + .then(loaded => { + if (!active) return; + setModels(loaded); + setModelsLoading(false); + setModel(current => { + if (current && loaded.some(item => item.id === current)) return current; + return loaded[0]?.id ?? ''; + }); + }) + .catch(err => { + if (!active) return; + setModelsError(err instanceof Error ? err.message : String(err)); + setModelsLoading(false); + }); + return () => { + active = false; + }; + }, [provider, providers, selectionMode]); + + const saveProviderModel = () => { + const trimmedModel = model.trim(); + if (!trimmedModel) return; + const trimmedProvider = provider.trim(); + picker.onSelect(trimmedProvider ? `${trimmedProvider}:${trimmedModel}` : trimmedModel); + onClose(); + }; + + return ( +
+
+
+
+

+ {picker.title} +

+

+ {t('modelCouncil.modelPickerHelp')} +

+
+ +
+ +
+

+ {t('modelCouncil.modelPickerHints')} +

+
+ {MODEL_HINTS.map(hint => ( + + ))} +
+
+ +
+ + +
+ + +
+ {(providersLoading || modelsLoading) && ( +

+ {t('skills.resource.preview.loading')} +

+ )} + {(providersError || modelsError) && ( +

+ {providersError || modelsError} +

+ )} + +
+
+
+ ); +}; + +const DEFAULT_MODEL = DEFAULT_REASONING_MODEL; +const DEFAULT_JUDGE_MODEL = DEFAULT_REASONING_MODEL; +const SHARED_REASONING_FILE = 'shared_reasoning.md'; +const DEFAULT_SHARED_REASONING = [ + '# Shared reasoning', + '- Claims the council agrees on:', + '- Open disagreements:', + '- Evidence or constraints to preserve:', + '- Judge synthesis notes:', +].join('\n'); +const DEFAULT_SEATS: CouncilSeat[] = [ + { + id: 0, + mode: 'default', + profileId: '', + name: 'Analyst', + model: DEFAULT_MODEL, + brief: 'Evidence, assumptions, and risk.', + }, + { + id: 1, + mode: 'default', + profileId: '', + name: 'Builder', + model: DEFAULT_MODEL, + brief: 'Practical implementation path.', + }, + { + id: 2, + mode: 'default', + profileId: '', + name: 'Skeptic', + model: DEFAULT_MODEL, + brief: 'Failure modes and missing context.', + }, +]; + +const SEAT_COLORS = ['yellow', 'burgundy', 'navy', 'black', 'yellow'] as const; +const SEAT_FACES: MascotFace[] = ['thinking', 'writing', 'reading', 'curious', 'proud']; +const ACTIVE_SEAT_FACES: MascotFace[] = ['thinking', 'writing', 'thinking', 'reading', 'curious']; + +const nextSeatId = (seats: CouncilSeat[]): number => + seats.reduce((max, seat) => Math.max(max, seat.id), -1) + 1; + +function profileLabel(profile: AgentProfile): string { + return profile.modelOverride ? `${profile.name} · ${profile.modelOverride}` : profile.name; +} + +function profileModel(profile: AgentProfile | undefined): string { + return profile?.modelOverride?.trim() || profile?.agentId?.trim() || profile?.id?.trim() || ''; +} + +function resolveSeat(seat: CouncilSeat, profiles: AgentProfile[], index: number): ResolvedSeat { + const profile = profiles.find(p => p.id === seat.profileId); + const fallbackName = + seat.mode === 'profile' && profile ? profile.name : seat.name.trim() || `Juror ${index + 1}`; + const fallbackModel = seat.mode === 'profile' ? profileModel(profile) : DEFAULT_MODEL; + + return { + label: fallbackName, + model: seat.model.trim() || fallbackModel, + brief: seat.brief.trim(), + }; +} + +function mascotColors(index: number) { + const palette = getMascotPalette(SEAT_COLORS[index % SEAT_COLORS.length]); + return { + primaryColor: hexToArgbInt(palette.bodyFill), + secondaryColor: hexToArgbInt(palette.neckShadowColor), + }; +} + +function deliberationThought( + seat: ResolvedSeat, + index: number, + t: (key: string) => string +): string { + const brief = seat.brief.trim(); + if (brief) { + return t('modelCouncil.thinkingWithBrief').replace('{brief}', brief); + } + + const keys = [ + 'modelCouncil.thought.evidence', + 'modelCouncil.thought.plan', + 'modelCouncil.thought.risk', + 'modelCouncil.thought.tradeoffs', + 'modelCouncil.thought.synthesis', + ]; + return t(keys[index % keys.length]); +} + +function buildCouncilQuestion( + question: string, + sharedReasoning: string, + seats: ResolvedSeat[], + judgeName: string +): string { + const trimmedQuestion = question.trim(); + const trimmedSharedReasoning = sharedReasoning.trim(); + const roster = seats + .map((seat, index) => { + const brief = seat.brief ? ` — ${seat.brief}` : ''; + return `${index + 1}. ${seat.label} (${seat.model})${brief}`; + }) + .join('\n'); + const commonPrefix = [ + `Council workspace: ${SHARED_REASONING_FILE}`, + 'Use this shared reasoning file as the common deliberation scratchpad.', + '', + 'Council roster:', + roster, + '', + `Judge agent: ${judgeName}`, + ]; + + if (!trimmedSharedReasoning) { + return [...commonPrefix, '', 'User question:', trimmedQuestion].join('\n'); + } + + return [ + ...commonPrefix, + '', + `${SHARED_REASONING_FILE}:`, + trimmedSharedReasoning, + '', + 'User question:', + trimmedQuestion, + ].join('\n'); +} + +function buildDebateTurnQuestion( + baseQuestion: string, + seat: ResolvedSeat, + round: number, + totalRounds: number, + transcript: CouncilDebateTurn[][], + t: (key: string) => string +): string { + const previousTurns = transcript + .map((turns, seatIndex) => { + if (turns.length === 0) return ''; + const body = turns + .map(turn => { + const text = turn.response || `[${turn.error || 'no response'}]`; + return `Round ${turn.round}: ${text}`; + }) + .join('\n'); + return `Juror ${seatIndex + 1} previous turns:\n${body}`; + }) + .filter(Boolean) + .join('\n\n'); + + const phase = + round === totalRounds + ? t('modelCouncil.debateFinalInstruction') + : t('modelCouncil.debateRoundInstruction'); + + return [ + baseQuestion, + '', + `Debate round ${round} of ${totalRounds}.`, + `You are ${seat.label}. Perspective: ${seat.brief || 'independent council juror'}.`, + phase, + previousTurns ? ['', 'Debate so far:', previousTurns].join('\n') : '', + '', + 'Write this turn as a concise council thought plus your current conclusion.', + ] + .filter(Boolean) + .join('\n'); +} + +function appendScratchpadRound( + scratchpad: string, + round: number, + seats: ResolvedSeat[], + roundResults: Array<{ index: number; turn: CouncilDebateTurn }>, + t: (key: string) => string +): string { + const existing = scratchpad.trim() || '# Shared reasoning'; + const lines = [ + '', + '', + `## ${t('modelCouncil.scratchpadRoundHeading').replace('{round}', String(round))}`, + ]; + for (const { index, turn } of [...roundResults].sort((a, b) => a.index - b.index)) { + const seat = seats[index]; + lines.push('', `### ${seat?.label || `Juror ${index + 1}`}`); + if (turn.response) { + lines.push(turn.response.trim()); + } else { + lines.push(`_${t('modelCouncil.scratchpadNoResponse')}: ${turn.error || 'unknown'}_`); + } + } + return `${existing}${lines.join('\n')}`; +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil(text.length / 4)); +} + +function formatTokenCount(value: number): string { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value); +} + +function buildMemberSynthesisInput( + seat: ResolvedSeat, + model: string, + turns: CouncilDebateTurn[] +): CouncilMemberResult { + const answeredTurns = turns.filter(turn => turn.response); + if (answeredTurns.length === 0) { + return { + model, + response: null, + error: turns.find(turn => turn.error)?.error || 'no debate turns completed', + }; + } + + return { + model, + response: [ + `${seat.label} debate record:`, + ...turns.map(turn => { + const text = turn.response || `[failed: ${turn.error || 'unknown'}]`; + return `Round ${turn.round}: ${text}`; + }), + ].join('\n\n'), + error: null, + }; +} + +function councilSeatsFromDefinition(council: CouncilDefinition): CouncilSeat[] { + return council.seats.map(seat => ({ + id: seat.id, + mode: seat.mode, + profileId: seat.profile_id, + name: seat.name, + model: seat.model, + brief: seat.brief, + })); +} + +function createDraftCouncil(): CouncilDefinition { + const now = Date.now(); + return { + id: '', + name: 'New council', + description: '', + jury_count: 3, + debate_rounds: 3, + seats: DEFAULT_SEATS.map(seat => ({ + id: seat.id, + mode: seat.mode, + profile_id: seat.profileId, + name: seat.name, + model: seat.model, + brief: seat.brief, + })), + judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: DEFAULT_JUDGE_MODEL }, + shared_reasoning: DEFAULT_SHARED_REASONING, + created_at_ms: now, + updated_at_ms: now, + }; +} const ModelCouncilTab = () => { const { t } = useT(); + const dispatch = useAppDispatch(); + const profiles = useAppSelector(selectAgentProfiles); + const profileStatus = useAppSelector(state => state.agentProfiles.status as AgentProfilesStatus); + const [question, setQuestion] = useState(''); - const [members, setMembers] = useState([ - { id: 0, value: '' }, - { id: 1, value: '' }, - ]); - const [chair, setChair] = useState(''); + const [view, setView] = useState<'list' | 'run' | 'edit'>('list'); + const [councils, setCouncils] = useState([]); + const [selectedCouncil, setSelectedCouncil] = useState(null); + const [councilName, setCouncilName] = useState('Default council'); + const [councilDescription, setCouncilDescription] = useState(''); + const [registryLoading, setRegistryLoading] = useState(true); + const [registrySaving, setRegistrySaving] = useState(false); + const [registryError, setRegistryError] = useState(null); + const [sharedReasoning, setSharedReasoning] = useState(DEFAULT_SHARED_REASONING); + const [liveScratchpad, setLiveScratchpad] = useState(null); + const [juryCount, setJuryCount] = useState(3); + const [debateRounds, setDebateRounds] = useState(3); + const [seats, setSeats] = useState(DEFAULT_SEATS); + const [judgeMode, setJudgeMode] = useState('default'); + const [judgeProfileId, setJudgeProfileId] = useState(''); + const [judgeName, setJudgeName] = useState('Chief Judge'); + const [judgeModel, setJudgeModel] = useState(DEFAULT_JUDGE_MODEL); const [running, setRunning] = useState(false); + const [liveMembers, setLiveMembers] = useState([]); + const [judgeSynthesizing, setJudgeSynthesizing] = useState(false); + const [usageEstimate, setUsageEstimate] = useState(null); + const [modelPicker, setModelPicker] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const filledMembers = useMemo( - () => members.map(m => m.value.trim()).filter(v => v.length > 0), - [members] + useEffect(() => { + if (profileStatus === 'idle' && profiles.length === 0) { + void dispatch(loadAgentProfiles()); + } + }, [dispatch, profileStatus, profiles.length]); + + const applyCouncilDefinition = useCallback((council: CouncilDefinition) => { + setSelectedCouncil(council); + setCouncilName(council.name || 'Untitled council'); + setCouncilDescription(council.description || ''); + setJuryCount(Math.min(MAX_MEMBERS, Math.max(MIN_MEMBERS, council.jury_count || 3))); + setDebateRounds( + Math.min(MAX_DEBATE_ROUNDS, Math.max(MIN_DEBATE_ROUNDS, council.debate_rounds || 3)) + ); + setSeats(councilSeatsFromDefinition(council).slice(0, council.jury_count || 3)); + setJudgeMode(council.judge.mode); + setJudgeProfileId(council.judge.profile_id || ''); + setJudgeName(council.judge.name || 'Chief Judge'); + setJudgeModel(council.judge.model ?? DEFAULT_JUDGE_MODEL); + setSharedReasoning(council.shared_reasoning || DEFAULT_SHARED_REASONING); + setQuestion(''); + setLiveMembers([]); + setLiveScratchpad(null); + setJudgeSynthesizing(false); + setUsageEstimate(null); + setResult(null); + setError(null); + }, []); + + const loadCouncils = useCallback(async () => { + setRegistryLoading(true); + setRegistryError(null); + try { + const loaded = await councilRegistryApi.list(); + setCouncils(loaded); + } catch (err) { + setRegistryError(err instanceof Error ? err.message : String(err)); + } finally { + setRegistryLoading(false); + } + }, []); + + useEffect(() => { + void loadCouncils(); + }, [loadCouncils]); + + useEffect(() => { + setSeats(prev => { + if (prev.length === juryCount) return prev; + if (prev.length > juryCount) return prev.slice(0, juryCount); + + const next = [...prev]; + while (next.length < juryCount) { + const index = next.length; + next.push({ + id: nextSeatId(next), + mode: 'default', + profileId: '', + name: `${t('modelCouncil.jurorFallback')} ${index + 1}`, + model: DEFAULT_MODEL, + brief: '', + }); + } + return next; + }); + }, [juryCount, t]); + + const judgeProfile = useMemo( + () => profiles.find(profile => profile.id === judgeProfileId), + [profiles, judgeProfileId] ); + const resolvedSeats = useMemo( + () => seats.map((seat, index) => resolveSeat(seat, profiles, index)), + [profiles, seats] + ); + + const resolvedJudgeModel = + judgeModel.trim() || + (judgeMode === 'profile' ? profileModel(judgeProfile) : '') || + DEFAULT_JUDGE_MODEL; + const resolvedJudgeName = + judgeMode === 'profile' && judgeProfile ? judgeProfile.name : judgeName.trim() || 'Chief Judge'; + const canRun = - !running && question.trim().length > 0 && filledMembers.length > 0 && chair.trim().length > 0; + !running && + question.trim().length > 0 && + resolvedSeats.some(seat => seat.model.trim().length > 0) && + resolvedJudgeModel.trim().length > 0; - const updateMember = useCallback((id: number, value: string) => { - setMembers(prev => prev.map(m => (m.id === id ? { ...m, value } : m))); + const updateSeat = useCallback((id: number, patch: Partial) => { + setSeats(prev => prev.map(seat => (seat.id === id ? { ...seat, ...patch } : seat))); }, []); - const addMember = useCallback(() => { - setMembers(prev => - prev.length >= MAX_MEMBERS ? prev : [...prev, { id: nextMemberId(prev), value: '' }] - ); - }, []); + const buildCouncilDefinition = useCallback( + (base: CouncilDefinition | null): CouncilDefinition => { + const now = Date.now(); + return { + id: base?.id || '', + name: councilName.trim() || 'Untitled council', + description: councilDescription.trim(), + jury_count: juryCount, + debate_rounds: debateRounds, + seats: seats + .slice(0, juryCount) + .map(seat => ({ + id: seat.id, + mode: seat.mode, + profile_id: seat.profileId, + name: seat.name, + model: seat.model, + brief: seat.brief, + })), + judge: { mode: judgeMode, profile_id: judgeProfileId, name: judgeName, model: judgeModel }, + shared_reasoning: sharedReasoning, + created_at_ms: base?.created_at_ms || now, + updated_at_ms: now, + }; + }, + [ + councilDescription, + councilName, + debateRounds, + judgeMode, + judgeModel, + judgeName, + judgeProfileId, + juryCount, + seats, + sharedReasoning, + ] + ); - const removeMember = useCallback((id: number) => { - setMembers(prev => (prev.length <= 1 ? prev : prev.filter(m => m.id !== id))); - }, []); + const saveCouncil = useCallback(async () => { + setRegistrySaving(true); + setRegistryError(null); + try { + const saved = await councilRegistryApi.upsert(buildCouncilDefinition(selectedCouncil)); + setCouncils(prev => { + const without = prev.filter(council => council.id !== saved.id); + return [saved, ...without].sort((a, b) => a.name.localeCompare(b.name)); + }); + applyCouncilDefinition(saved); + setView('run'); + } catch (err) { + setRegistryError(err instanceof Error ? err.message : String(err)); + } finally { + setRegistrySaving(false); + } + }, [applyCouncilDefinition, buildCouncilDefinition, selectedCouncil]); + + const handleSelectCouncil = useCallback( + (council: CouncilDefinition) => { + applyCouncilDefinition(council); + setView('run'); + }, + [applyCouncilDefinition] + ); + + const handleCreateCouncil = useCallback(() => { + applyCouncilDefinition(createDraftCouncil()); + setView('edit'); + }, [applyCouncilDefinition]); + + const selectedCouncilId = selectedCouncil?.id; + const handleDeleteCouncil = useCallback( + async (council: CouncilDefinition) => { + setRegistryError(null); + try { + await councilRegistryApi.delete(council.id); + setCouncils(prev => prev.filter(item => item.id !== council.id)); + if (selectedCouncilId === council.id) { + setSelectedCouncil(null); + setView('list'); + } + } catch (err) { + setRegistryError(err instanceof Error ? err.message : String(err)); + } + }, + [selectedCouncilId] + ); + + const setSeatMode = useCallback( + (seat: CouncilSeat, mode: SeatMode) => { + updateSeat(seat.id, { + mode, + profileId: mode === 'profile' ? seat.profileId || profiles[0]?.id || '' : '', + name: mode === 'custom' ? seat.name : seat.name || '', + model: mode === 'profile' ? '' : seat.model || DEFAULT_MODEL, + }); + }, + [profiles, updateSeat] + ); const handleRun = useCallback(async () => { if (running) return; - const trimmedMembers = members.map(m => m.value.trim()).filter(v => v.length > 0); - if (question.trim().length === 0 || trimmedMembers.length === 0 || chair.trim().length === 0) { + const memberModels = resolvedSeats.map(seat => seat.model.trim()).filter(Boolean); + const chairModel = resolvedJudgeModel.trim(); + if (question.trim().length === 0 || memberModels.length === 0 || chairModel.length === 0) { return; } setRunning(true); + setJudgeSynthesizing(false); + setLiveMembers(memberModels.map(() => ({ status: 'pending', member: null, turns: [] }))); + setLiveScratchpad(sharedReasoning); + setUsageEstimate(null); setError(null); setResult(null); try { - const res = await modelCouncilApi.runCouncil({ - question: question.trim(), - member_models: trimmedMembers, - chair_model: chair.trim(), + const transcript: CouncilDebateTurn[][] = memberModels.map(() => []); + let currentScratchpad = sharedReasoning; + let estimatedInputTokens = 0; + let estimatedOutputTokens = 0; + + for (let round = 1; round <= debateRounds; round += 1) { + setLiveMembers(prev => prev.map(entry => ({ ...entry, status: 'pending' }))); + + const roundResults = await Promise.all( + memberModels.map(async (model, index) => { + const councilQuestion = buildCouncilQuestion( + question, + currentScratchpad, + resolvedSeats, + resolvedJudgeName + ); + const turnQuestion = buildDebateTurnQuestion( + councilQuestion, + resolvedSeats[index], + round, + debateRounds, + transcript, + t + ); + estimatedInputTokens += estimateTokens(turnQuestion); + try { + const member = await modelCouncilApi.answerMember({ question: turnQuestion, model }); + const turn: CouncilDebateTurn = { + round, + response: member.response, + error: member.error, + }; + estimatedOutputTokens += estimateTokens(member.response || member.error || ''); + setLiveMembers(prev => + prev.map((entry, entryIndex) => + entryIndex === index + ? { + status: member.error ? 'failed' : 'answered', + member, + turns: [...entry.turns, turn], + } + : entry + ) + ); + return { index, turn }; + } catch (memberError) { + const errorText = + memberError instanceof Error ? memberError.message : String(memberError); + const failedMember: CouncilMemberResult = { model, response: null, error: errorText }; + const turn: CouncilDebateTurn = { round, response: null, error: errorText }; + estimatedOutputTokens += estimateTokens(errorText); + setLiveMembers(prev => + prev.map((entry, entryIndex) => + entryIndex === index + ? { status: 'failed', member: failedMember, turns: [...entry.turns, turn] } + : entry + ) + ); + return { index, turn }; + } + }) + ); + + for (const { index, turn } of roundResults) { + transcript[index].push(turn); + } + currentScratchpad = appendScratchpadRound( + currentScratchpad, + round, + resolvedSeats, + roundResults, + t + ); + setLiveScratchpad(currentScratchpad); + setSharedReasoning(currentScratchpad); + } + + const councilQuestion = buildCouncilQuestion( + question, + currentScratchpad, + resolvedSeats, + resolvedJudgeName + ); + const memberResults = memberModels.map((model, index) => + buildMemberSynthesisInput(resolvedSeats[index], model, transcript[index]) + ); + const synthesisInputTokens = estimateTokens( + `${councilQuestion}\n${JSON.stringify(memberResults)}` + ); + estimatedInputTokens += synthesisInputTokens; + setJudgeSynthesizing(true); + const res = await modelCouncilApi.synthesizeCouncil({ + question: councilQuestion, + members: memberResults, + chair_model: chairModel, + }); + estimatedOutputTokens += estimateTokens(res.synthesis); + const totalTokens = estimatedInputTokens + estimatedOutputTokens; + setUsageEstimate({ + inputTokens: estimatedInputTokens, + outputTokens: estimatedOutputTokens, + totalTokens, }); setResult(res); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { + setJudgeSynthesizing(false); setRunning(false); + setLiveScratchpad(null); } - }, [running, members, question, chair]); + }, [ + debateRounds, + resolvedJudgeModel, + resolvedJudgeName, + resolvedSeats, + question, + running, + sharedReasoning, + t, + ]); - return ( -
-
-

{t('modelCouncil.title')}

-

{t('modelCouncil.intro')}

-
- - {/* Question */} -
- -