diff --git a/src/conclave/cache.py b/src/conclave/cache.py index 71be1b9..873ca8f 100644 --- a/src/conclave/cache.py +++ b/src/conclave/cache.py @@ -85,6 +85,7 @@ def make_key( rounds: int | None = None, proposer: str | None = None, converge_threshold: float | None = None, + choices: list[str] | None = None, ) -> str: """Build the stable cache key (sha256 hex) for a council run. @@ -133,6 +134,8 @@ def make_key( payload["converge_threshold"] = converge_threshold if mode == "adversarial": payload["proposer"] = proposer + if mode == "vote": + payload["choices"] = choices or [] canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() diff --git a/src/conclave/cli.py b/src/conclave/cli.py index bbdc835..565fe83 100644 --- a/src/conclave/cli.py +++ b/src/conclave/cli.py @@ -216,6 +216,59 @@ def _render_debate(result: CouncilResult) -> None: _print_synthesis(result, title="FINAL SYNTHESIS") +def _render_vote(result: CouncilResult) -> None: + """Render a vote run: tally table then winner/split line.""" + _print_skipped(result) + vote = result.vote + if vote is None: + err_console.print("[yellow]No vote result produced.[/yellow]") + return + + labels = [chr(65 + i) for i in range(len(vote.choices))] + label_to_choice = dict(zip(labels, vote.choices, strict=False)) + + table = Table(title="Vote Tally", show_header=True) + table.add_column("Choice", style="bold") + table.add_column("Label", justify="center") + table.add_column("Votes", justify="right") + table.add_column("Voters") + + for lbl in labels: + choice_text = label_to_choice.get(lbl, lbl) + cnt = vote.tally.get(lbl, 0) + voters = [name for name, v in vote.votes.items() if v == lbl] + voters_str = ", ".join(voters) if voters else "—" + style = "green" if lbl == vote.winner else "" + table.add_row(choice_text, lbl, str(cnt), voters_str, style=style) + + console.print(table) + + unparsed = [name for name, v in vote.votes.items() if v is None] + if unparsed: + err_console.print(f"[yellow]Unrecognised/failed responses: {', '.join(unparsed)}[/yellow]") + + if vote.winner is not None: + winner_text = label_to_choice.get(vote.winner, vote.winner) + console.print( + Panel( + f"[bold]{vote.winner}. {winner_text}[/bold]", + title="[bold green]WINNER[/bold green]", + border_style="green", + ) + ) + elif vote.split: + tied = [f"{lbl}. {label_to_choice.get(lbl, lbl)}" for lbl in sorted(vote.tally)] + console.print( + Panel( + f"[yellow]Tie: {' vs '.join(tied)}[/yellow]", + title="[bold yellow]SPLIT[/bold yellow]", + border_style="yellow", + ) + ) + else: + err_console.print("[yellow]No votes were cast.[/yellow]") + + def _render_adversarial(result: CouncilResult) -> None: """Render an adversarial run: proposal, critiques, then the verdict.""" _print_skipped(result) @@ -303,10 +356,11 @@ def on_event(event: StreamEvent) -> None: "raw": _render_human, "debate": _render_debate, "adversarial": _render_adversarial, + "vote": _render_vote, } -_VALID_MODES = {"synthesize", "raw", "debate", "adversarial"} +_VALID_MODES = {"synthesize", "raw", "debate", "adversarial", "vote"} # Threshold used when --converge is passed without an explicit --converge-threshold. # High by design: an early stop should require answers that are nearly stable @@ -390,6 +444,14 @@ def ask( "-p", help="Proposer model name (adversarial mode; defaults to first member).", ), + choices: str | None = typer.Option( + None, + "--choices", + help=( + "Comma-separated list of options for vote mode " + "(e.g. 'Option A,Option B,Option C'). Required for --mode vote." + ), + ), as_json: bool = typer.Option( False, "--json", help="Emit the full result as JSON instead of panels." ), @@ -436,6 +498,12 @@ def ask( ) raise typer.Exit(code=2) + if mode_lower == "vote" and not choices: + err_console.print( + "[red]--mode vote requires --choices (e.g. --choices 'Yes,No,Abstain').[/red]" + ) + raise typer.Exit(code=2) + cfg = load_config() members = cfg.resolve_council(council) if not members: @@ -463,6 +531,9 @@ def ask( result = c.debate_sync(prompt, rounds=rounds, converge_threshold=threshold) elif mode_lower == "adversarial": result = c.adversarial_sync(prompt, proposer=proposer) + elif mode_lower == "vote": + choice_list = [ch.strip() for ch in (choices or "").split(",") if ch.strip()] + result = c.vote_sync(prompt, choices=choice_list) else: result = c.ask_sync(prompt, synthesize=(mode_lower == "synthesize")) diff --git a/src/conclave/council.py b/src/conclave/council.py index 86d303e..c8dbbb5 100644 --- a/src/conclave/council.py +++ b/src/conclave/council.py @@ -202,6 +202,7 @@ def _cache_key( rounds: int | None = None, proposer: str | None = None, converge_threshold: float | None = None, + choices: list[str] | None = None, ) -> str: """Build the cache key for a run from the resolved, secret-free identity. @@ -223,6 +224,7 @@ def _cache_key( rounds=rounds, proposer=proposer, converge_threshold=converge_threshold, + choices=choices, ) async def _cached_run( @@ -234,6 +236,7 @@ async def _cached_run( rounds: int | None = None, proposer: str | None = None, converge_threshold: float | None = None, + choices: list[str] | None = None, ) -> CouncilResult: """Serve ``run`` from the result cache when caching is enabled. @@ -251,6 +254,7 @@ async def _cached_run( rounds=rounds, proposer=proposer, converge_threshold=converge_threshold, + choices=choices, ) hit = cache_mod.load(key) if hit is not None: @@ -881,3 +885,27 @@ def adversarial_sync(self, prompt: str, proposer: str | None = None) -> CouncilR return self._run_sync( lambda: self.adversarial(prompt, proposer=proposer), "adversarial_sync" ) + + async def vote(self, prompt: str, choices: list[str]) -> CouncilResult: + """Run a constrained-choice vote. See :func:`conclave.modes.run_vote`. + + Each member receives the prompt and a labelled option set (A, B, C, ...) + and must respond with a single letter. Results are tallied and a winner + (plurality) or split is reported on ``result.vote``. + + Args: + prompt: The question to vote on. + choices: Two or more option strings. At least 2 required. + """ + from .modes import run_vote + + return await self._cached_run( + prompt, + "vote", + lambda: run_vote(self, prompt, choices=choices), + choices=choices, + ) + + def vote_sync(self, prompt: str, choices: list[str]) -> CouncilResult: + """Synchronous wrapper around :meth:`vote`.""" + return self._run_sync(lambda: self.vote(prompt, choices=choices), "vote_sync") diff --git a/src/conclave/models.py b/src/conclave/models.py index e1d4fbe..4856341 100644 --- a/src/conclave/models.py +++ b/src/conclave/models.py @@ -173,13 +173,35 @@ def successful_critiques(self) -> list[ModelAnswer]: return [c for c in self.critiques if c.ok] +class VoteResult(BaseModel): + """The tally from a constrained-choice vote run. + + Attributes: + choices: The fixed option labels offered to members. + votes: Map of member friendly name → the label they chose (or ``None`` + when a member's answer could not be parsed to a valid choice). + tally: Map of label → vote count across all members that returned a + parseable vote. Members that errored or returned an unrecognised + response are excluded from the tally. + winner: The label with the most votes, or ``None`` on a tie. + split: ``True`` when no single choice won an outright plurality (tie). + """ + + choices: list[str] = Field(default_factory=list) + votes: dict[str, str | None] = Field(default_factory=dict) + tally: dict[str, int] = Field(default_factory=dict) + winner: str | None = None + split: bool = False + + class CouncilResult(BaseModel): """The full outcome of a council run. Attributes: prompt: The original user prompt. mode: The run mode that produced this result - (``"synthesize"`` | ``"raw"`` | ``"debate"`` | ``"adversarial"``). + (``"synthesize"`` | ``"raw"`` | ``"debate"`` | ``"adversarial"`` + | ``"vote"``). answers: One ``ModelAnswer`` per attempted council member. For ``debate`` this mirrors the final round so existing consumers that read ``answers``/``synthesis`` keep working unchanged. @@ -261,6 +283,7 @@ class CouncilResult(BaseModel): skipped: list[str] = Field(default_factory=list) rounds: list[DebateRound] = Field(default_factory=list) adversarial: AdversarialResult | None = None + vote: VoteResult | None = None cached: bool = False converged: bool = False convergence_score: float | None = None diff --git a/src/conclave/modes.py b/src/conclave/modes.py index 4ca5e90..4e1927a 100644 --- a/src/conclave/modes.py +++ b/src/conclave/modes.py @@ -22,12 +22,13 @@ from __future__ import annotations +import re from difflib import SequenceMatcher from typing import TYPE_CHECKING from . import prompts from .logging import get_logger -from .models import AdversarialResult, CouncilResult, DebateRound, ModelAnswer +from .models import AdversarialResult, CouncilResult, DebateRound, ModelAnswer, VoteResult from .registry import key_present if TYPE_CHECKING: # avoid a circular import at runtime; only needed for typing @@ -36,6 +37,125 @@ logger = get_logger("modes") +async def run_vote( + council: Council, + prompt: str, + choices: list[str], +) -> CouncilResult: + """Run a constrained-choice vote and return a :class:`CouncilResult`. + + Each council member is shown the prompt and a fixed option set labelled A, + B, C, ... and asked to respond with a single letter. Responses are tallied; + the plurality winner (if any) is stored in ``result.vote.winner``. A tie + sets ``result.vote.split = True`` and ``result.vote.winner = None``. + + Args: + council: The :class:`Council` providing fan-out and config. + prompt: The user question to vote on. + choices: Two or more option strings (e.g. ``["Option 1", "Option 2"]``). + Each choice is assigned a consecutive uppercase letter starting at A. + + Returns: + A :class:`CouncilResult` with ``mode="vote"`` and ``vote`` populated. + ``synthesis`` carries a human-readable summary of the tally. + Zero available members yields an empty result, not an error. + """ + if len(choices) < 2: + raise ValueError("vote mode requires at least 2 choices") + + members, skipped = council._available_members() + result = CouncilResult(prompt=prompt, mode="vote", skipped=skipped) + + if not members: + logger.warning("no council members have keys available; nothing to vote") + result.vote = VoteResult(choices=choices) + return result + + labels = [chr(65 + i) for i in range(len(choices))] + label_to_choice = dict(zip(labels, choices, strict=False)) + + messages_for = _vote_messages_for(prompt, choices) + result.answers = await council.fan_out(members, messages_for) + + # Parse each member's response into a label. + member_votes: dict[str, str | None] = {} + for ans in result.answers: + if not ans.ok or not ans.answer: + member_votes[ans.name] = None + continue + raw = ans.answer.strip().upper() + # Accept the first standalone label letter (word-boundary match). + # A letter buried inside a word (e.g. "cANnot") is not accepted. + chosen: str | None = None + for m in re.finditer(r"\b([A-Z])\b", raw): + if m.group(1) in label_to_choice: + chosen = m.group(1) + break + member_votes[ans.name] = chosen + + # Tally votes. + tally: dict[str, int] = {lbl: 0 for lbl in labels} + for chosen in member_votes.values(): + if chosen is not None: + tally[chosen] = tally.get(chosen, 0) + 1 + + # Remove labels with zero votes for a cleaner tally. + tally = {lbl: cnt for lbl, cnt in tally.items() if cnt > 0} + + # Determine winner (plurality = most votes; None on tie). + winner: str | None = None + split = False + if tally: + max_votes = max(tally.values()) + leaders = [lbl for lbl, cnt in tally.items() if cnt == max_votes] + if len(leaders) == 1: + winner = leaders[0] + else: + split = True + + vote_result = VoteResult( + choices=choices, + votes=member_votes, + tally=tally, + winner=winner, + split=split, + ) + result.vote = vote_result + + # Build a human-readable synthesis summarising the outcome. + result.synthesis = _vote_summary(vote_result, label_to_choice, len(result.answers)) + return result + + +def _vote_messages_for(prompt: str, choices: list[str]): + """Build the per-member message factory for a vote round.""" + messages = [ + {"role": "system", "content": prompts.VOTE_SYSTEM}, + {"role": "user", "content": prompts.vote_user(prompt, choices)}, + ] + return lambda _name, _model_id: messages + + +def _vote_summary(vote_result: VoteResult, label_to_choice: dict[str, str], n_members: int) -> str: + """Build a brief text summary of the vote outcome.""" + lines = [f"Vote result ({n_members} member(s) polled):"] + for lbl, cnt in sorted(vote_result.tally.items(), key=lambda x: -x[1]): + choice_text = label_to_choice.get(lbl, lbl) + lines.append(f" {lbl}. {choice_text}: {cnt} vote(s)") + unparsed = sum(1 for v in vote_result.votes.values() if v is None) + if unparsed: + lines.append(f" (unrecognised/failed responses: {unparsed})") + if vote_result.winner is not None: + winner_text = label_to_choice.get(vote_result.winner, vote_result.winner) + lines.append(f"\nWinner: {vote_result.winner}. {winner_text}") + elif vote_result.split: + tied = [f"{lbl}. {label_to_choice.get(lbl, lbl)}" for lbl in sorted(vote_result.tally)] + lines.append(f"\nTie: {' vs '.join(tied)}") + else: + lines.append("\nNo votes cast.") + return "\n".join(lines) + + async def run_debate( council: Council, prompt: str, diff --git a/src/conclave/prompts.py b/src/conclave/prompts.py index dc3ea07..792c1c3 100644 --- a/src/conclave/prompts.py +++ b/src/conclave/prompts.py @@ -19,7 +19,7 @@ # detect that the wording the synthesis was produced under has shifted, rather # than silently absorbing a prompt change as a quality regression. The value is # opaque (a date-stamped tag); only equality/inequality is meaningful. -SYNTHESIS_PROMPT_VERSION = "2026-06-14" +SYNTHESIS_PROMPT_VERSION = "2026-06-29" # Stable position-based labels used to anonymize peers in debate rounds 2..N. LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -60,6 +60,25 @@ ) +VOTE_SYSTEM = ( + "You are a voting member of a multi-model council. You will be given a " + "question and a fixed set of labelled choices. You MUST respond with ONLY " + "the single uppercase letter label of your chosen option (e.g. 'A' or 'B'). " + "Do not write anything else — no explanation, no punctuation, no extra text. " + "Just the single letter." +) + + +def vote_user(prompt: str, choices: list[str]) -> str: + """User-role content for a constrained vote.""" + choice_block = "\n".join(f"{chr(65 + i)}. {c}" for i, c in enumerate(choices)) + return ( + f"Question:\n{prompt}\n\n" + f"Choices:\n{choice_block}\n\n" + "Respond with only the single letter of your chosen option." + ) + + def anonymized_peer_block( self_name: str, self_letter: str, diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py index b1c384c..a44579a 100644 --- a/tests/test_synthesizer.py +++ b/tests/test_synthesizer.py @@ -362,7 +362,7 @@ def test_prompt_version_is_pinned(): bumping ``SYNTHESIS_PROMPT_VERSION`` leaves this assertion (and the prompt-text pins) inconsistent, so the change cannot land silently. """ - assert SYNTHESIS_PROMPT_VERSION == "2026-06-14" + assert SYNTHESIS_PROMPT_VERSION == "2026-06-29" def test_synthesis_prompt_text_is_pinned(): diff --git a/tests/test_vote_mode.py b/tests/test_vote_mode.py new file mode 100644 index 0000000..94440e3 --- /dev/null +++ b/tests/test_vote_mode.py @@ -0,0 +1,372 @@ +"""Tests for the vote deliberation mode (issue #3 / CAC-09). + +All tests run offline via the ``patch_call_model`` fixture. The handler +returns a single letter ('A', 'B', etc.) for member calls so the tally +logic can be exercised without a live provider. +""" + +from __future__ import annotations + +import pytest + +from conclave import Council +from conclave.config import ConclaveConfig +from conclave.models import VoteResult +from conclave.modes import _vote_summary, run_vote +from conclave.prompts import VOTE_SYSTEM, vote_user +from tests.conftest import make_response + + +def _two_member_config() -> ConclaveConfig: + return ConclaveConfig( + models={ + "grok": "xai/grok-4.3", + "claude": "anthropic/claude-sonnet-4-6", + }, + councils={"default": ["grok", "claude"]}, + synthesizer="claude", + ) + + +def _three_member_config() -> ConclaveConfig: + return ConclaveConfig( + models={ + "grok": "xai/grok-4.3", + "gemini": "gemini/gemini-2.5-pro", + "claude": "anthropic/claude-sonnet-4-6", + }, + councils={"default": ["grok", "gemini", "claude"]}, + synthesizer="claude", + ) + + +def _set_keys(monkeypatch): + for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"): + monkeypatch.setenv(var, "dummy-key") + + +# --------------------------------------------------------------------------- +# Prompt template tests (offline, no Council needed) +# --------------------------------------------------------------------------- + + +class TestVoteSystem: + def test_vote_system_is_string(self): + assert isinstance(VOTE_SYSTEM, str) + + def test_vote_system_mentions_single_letter(self): + assert "single" in VOTE_SYSTEM.lower() or "letter" in VOTE_SYSTEM.lower() + + +class TestVoteUser: + def test_contains_choices(self): + content = vote_user("Who should win?", ["Alice", "Bob"]) + assert "A. Alice" in content + assert "B. Bob" in content + + def test_contains_prompt(self): + content = vote_user("My question", ["X", "Y"]) + assert "My question" in content + + def test_three_choices_labeled_abc(self): + content = vote_user("Pick one", ["X", "Y", "Z"]) + assert "A. X" in content + assert "B. Y" in content + assert "C. Z" in content + + +# --------------------------------------------------------------------------- +# VoteResult model tests +# --------------------------------------------------------------------------- + + +class TestVoteResult: + def test_defaults(self): + vr = VoteResult(choices=["Yes", "No"]) + assert vr.winner is None + assert vr.split is False + assert vr.tally == {} + assert vr.votes == {} + + def test_serialises_cleanly(self): + vr = VoteResult( + choices=["Yes", "No"], + votes={"grok": "A", "claude": "B"}, + tally={"A": 1, "B": 1}, + winner=None, + split=True, + ) + d = vr.model_dump() + assert d["split"] is True + assert d["winner"] is None + + +# --------------------------------------------------------------------------- +# run_vote unit tests (transport mocked) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vote_majority_winner(monkeypatch, patch_call_model): + """Three members: grok→A, gemini→A, claude→B → A wins.""" + _set_keys(monkeypatch) + + responses = { + "xai/grok-4.3": "A", + "gemini/gemini-2.5-pro": "A", + "anthropic/claude-sonnet-4-6": "B", + } + + def handler(model_id, messages): + return make_response(responses[model_id]) + + patch_call_model(handler) + cfg = _three_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Best option?", ["Alpha", "Beta"]) + + assert result.mode == "vote" + assert result.vote is not None + assert result.vote.winner == "A" + assert result.vote.split is False + assert result.vote.tally.get("A", 0) == 2 + assert result.vote.tally.get("B", 0) == 1 + + +@pytest.mark.asyncio +async def test_vote_split(monkeypatch, patch_call_model): + """Two members: grok→A, claude→B → tie (split).""" + _set_keys(monkeypatch) + + responses = {"xai/grok-4.3": "A", "anthropic/claude-sonnet-4-6": "B"} + + def handler(model_id, messages): + return make_response(responses[model_id]) + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "A or B?", ["Alpha", "Beta"]) + + assert result.vote is not None + assert result.vote.split is True + assert result.vote.winner is None + + +@pytest.mark.asyncio +async def test_vote_synthesis_is_produced(monkeypatch, patch_call_model): + """Synthesis (plain-text summary) is populated after a vote run.""" + _set_keys(monkeypatch) + + def handler(model_id, messages): + return make_response("A") + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Best option?", ["Yes", "No"]) + + assert result.synthesis is not None + assert "Vote result" in result.synthesis + + +@pytest.mark.asyncio +async def test_vote_unrecognised_response_excluded_from_tally(monkeypatch, patch_call_model): + """A member that returns junk text does not contribute to the tally.""" + _set_keys(monkeypatch) + + responses = {"xai/grok-4.3": "I cannot decide", "anthropic/claude-sonnet-4-6": "A"} + + def handler(model_id, messages): + return make_response(responses[model_id]) + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Pick one", ["Yes", "No"]) + + assert result.vote is not None + assert result.vote.winner == "A" + assert result.vote.tally.get("A", 0) == 1 + assert result.vote.votes["grok"] is None + + +@pytest.mark.asyncio +async def test_vote_letter_embedded_in_prose_is_parsed(monkeypatch, patch_call_model): + """A letter buried in extra text is still parsed out.""" + _set_keys(monkeypatch) + + responses = {"xai/grok-4.3": " B ", "anthropic/claude-sonnet-4-6": "My answer is B."} + + def handler(model_id, messages): + return make_response(responses[model_id]) + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Pick", ["Alpha", "Beta"]) + + assert result.vote is not None + assert result.vote.winner == "B" + + +@pytest.mark.asyncio +async def test_vote_no_members_returns_empty(monkeypatch): + """No available members → result with empty vote, no crash.""" + # Remove all keys so no member can run. + for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"): + monkeypatch.delenv(var, raising=False) + + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Pick", ["Yes", "No"]) + + assert result.vote is not None + assert result.vote.winner is None + assert result.vote.tally == {} + + +@pytest.mark.asyncio +async def test_vote_requires_at_least_two_choices(monkeypatch, patch_call_model): + """Fewer than 2 choices raises ValueError.""" + _set_keys(monkeypatch) + + def handler(model_id, messages): + return make_response("A") + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + with pytest.raises(ValueError, match="at least 2 choices"): + await run_vote(council, "Solo?", ["OnlyOne"]) + + +@pytest.mark.asyncio +async def test_vote_choices_stored_on_result(monkeypatch, patch_call_model): + """The choices list is preserved verbatim on VoteResult.""" + _set_keys(monkeypatch) + + def handler(model_id, messages): + return make_response("A") + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + choices = ["Option Alpha", "Option Beta", "Option Gamma"] + result = await run_vote(council, "Which?", choices) + + assert result.vote is not None + assert result.vote.choices == choices + + +@pytest.mark.asyncio +async def test_vote_failed_member_excluded_from_tally(monkeypatch, patch_call_model): + """A member call that errors does not crash the vote; it is excluded.""" + _set_keys(monkeypatch) + + def handler(model_id, messages): + if "grok" in model_id: + raise RuntimeError("provider error") + return make_response("B") + + patch_call_model(handler) + cfg = _two_member_config() + council = Council(models=cfg.resolve_council("default"), config=cfg) + + result = await run_vote(council, "Which?", ["Alpha", "Beta"]) + + assert result.vote is not None + assert result.vote.winner == "B" + assert result.vote.votes.get("grok") is None + + +# --------------------------------------------------------------------------- +# _vote_summary helper tests +# --------------------------------------------------------------------------- + + +class TestVoteSummary: + def test_winner_line_present(self): + vr = VoteResult( + choices=["Yes", "No"], + votes={"m1": "A", "m2": "A"}, + tally={"A": 2}, + winner="A", + split=False, + ) + summary = _vote_summary(vr, {"A": "Yes", "B": "No"}, 2) + assert "Winner" in summary + assert "Yes" in summary + + def test_split_line_present(self): + vr = VoteResult( + choices=["Yes", "No"], + votes={"m1": "A", "m2": "B"}, + tally={"A": 1, "B": 1}, + winner=None, + split=True, + ) + summary = _vote_summary(vr, {"A": "Yes", "B": "No"}, 2) + assert "Tie" in summary or "split" in summary.lower() + + def test_no_votes_cast_line_present(self): + vr = VoteResult(choices=["Yes", "No"], votes={}, tally={}, winner=None, split=False) + summary = _vote_summary(vr, {"A": "Yes", "B": "No"}, 0) + assert "No votes" in summary + + +# --------------------------------------------------------------------------- +# CLI integration tests (vote mode) +# --------------------------------------------------------------------------- + + +class TestCLIVoteMode: + """CLI-level integration using CliRunner + the council mock.""" + + def test_vote_mode_requires_choices(self, monkeypatch): + from typer.testing import CliRunner + + from conclave.cli import app + + _set_keys(monkeypatch) + runner = CliRunner() + result = runner.invoke(app, ["ask", "Best?", "--mode", "vote"]) + assert result.exit_code == 2 + assert "--choices" in result.output or "--choices" in (result.stderr or "") + + def test_vote_mode_renders_winner(self, monkeypatch, patch_call_model): + from typer.testing import CliRunner + + from conclave.cli import app + + _set_keys(monkeypatch) + + def handler(model_id, messages): + return make_response("A") + + patch_call_model(handler) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "ask", + "Best?", + "--mode", + "vote", + "--choices", + "Alpha,Beta", + "--council", + "grok,claude", + ], + ) + assert result.exit_code == 0 + assert "Alpha" in result.output or "WINNER" in result.output