Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ All notable changes to vouch are documented here. Format follows
temporary copied home containing known Claude/Codex credential files, so agent
writes stay in the throwaway dual-solve branches and host credential files are
not modified.
- dual-solve JSON, review-ui job, and choose responses now include
`changed_files` for each candidate and the kept branch, so desktop and browser
clients can show the resulting files without parsing unified diffs.
- dual-solve JSON and review-ui job responses now include each engine's returned
output log plus a deterministic recommendation hint based on success and diff
scope, so clients can compare Claude and Codex results before choosing.
- `vouch review-ui --allow-dual-solve` — a browser SPA that runs `dual-solve`
on a github issue link, streams progress over the review-ui's websocket, shows
both engines' diffs side by side, and lets you pick the winner. Off by default;
Expand Down
14 changes: 13 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2978,21 +2978,33 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str,
_emit_json({
"issue": {"number": issue.number, "title": issue.title,
"url": issue.url},
"recommendation": ds_mod.recommendation(candidates),
"candidates": [
{"engine": c.engine, "branch": c.branch, "ok": c.ok,
"error": c.error, "changed_files": ds_mod.changed_files(c.diff),
"diff": c.diff} for c in candidates
"log": c.log, "diff": c.diff} for c in candidates
],
})
return

for c in candidates:
click.echo(f"\n=== {c.engine} ({c.branch}) ===", err=True)
if c.log.strip():
click.echo("--- engine log ---", err=True)
click.echo(c.log)
if c.ok:
if c.log.strip():
click.echo("--- diff ---", err=True)
click.echo(c.diff)
else:
click.echo(f"(failed: {c.error})", err=True)

rec = ds_mod.recommendation(candidates)
if rec.get("reason"):
label = f"recommendation: {rec['engine']}" if rec.get("engine") \
else "recommendation: no automatic pick"
click.echo(f"{label} -- {rec['reason']}", err=True)

ok = [c for c in candidates if c.ok]
if not ok:
raise click.ClickException("both engines failed; nothing to choose")
Expand Down
51 changes: 50 additions & 1 deletion src/vouch/dual_solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"parse_issue_ref",
"parse_summary",
"prepare",
"recommendation",
"record_to_kb",
"repo_root",
"run_candidate",
Expand Down Expand Up @@ -73,6 +74,7 @@ class Candidate:
worktree: Path
diff: str = ""
sha: str = ""
log: str = ""
ok: bool = False
error: str | None = None

Expand All @@ -97,6 +99,53 @@ def changed_files(diff: str) -> list[str]:
return files


def _diff_stats(candidate: Candidate) -> tuple[int, int]:
files = changed_files(candidate.diff)
return len(files), len(candidate.diff.splitlines())


def recommendation(candidates: list[Candidate]) -> dict[str, str | None]:
"""Return a deterministic reviewer hint for the two dual-solve candidates.

This is deliberately a scope heuristic, not an automated quality judgment:
successful candidates beat failed ones; then the smaller changed-file count
and smaller diff win. Ties stay unresolved for the human reviewer.
"""
ok = [c for c in candidates if c.ok]
if not ok:
return {
"engine": None,
"reason": "neither engine produced a usable diff.",
}
if len(ok) == 1:
return {
"engine": ok[0].engine,
"reason": f"only {ok[0].engine} produced a usable diff.",
}

ranked = sorted(ok, key=_diff_stats)
best = ranked[0]
other = ranked[1]
best_files, best_lines = _diff_stats(best)
other_files, other_lines = _diff_stats(other)
if (best_files, best_lines) == (other_files, other_lines):
return {
"engine": None,
"reason": (
"both engines produced equally scoped diffs; "
"review the logs and tests before choosing."
),
}
return {
"engine": best.engine,
"reason": (
f"{best.engine} has the smaller scoped diff "
f"({best_files} files, {best_lines} lines vs "
f"{other_files} files, {other_lines} lines)."
),
}


def parse_issue_ref(ref: str) -> tuple[str | None, str]:
"""Normalize an issue reference for ``gh issue view``.

Expand Down Expand Up @@ -209,7 +258,7 @@ def run_candidate(engine: Engine, issue: Issue, prompt: str, root: Path,
return cand

try:
engine.fix(cwd=str(worktree), prompt=prompt)
cand.log = engine.fix(cwd=str(worktree), prompt=prompt)
except Exception as exc:
cand.error = f"engine failed: {exc}"
return cand
Expand Down
4 changes: 3 additions & 1 deletion src/vouch/web/dual_solve_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ def _serialize(job: DualSolveJob) -> dict[str, Any]:
"candidates": [
{"engine": c.engine, "branch": c.branch, "ok": c.ok,
"error": c.error, "changed_files": ds.changed_files(c.diff),
"diff": c.diff}
"log": c.log, "diff": c.diff}
for c in job.candidates
],
"recommendation": ds.recommendation(job.candidates),
"proposed_ids": list(job.proposed_ids),
"kept_branch": job.kept_branch,
"changed_files": ds.changed_files(kept.diff) if kept is not None else [],
Expand Down Expand Up @@ -200,4 +201,5 @@ async def dual_solve_choose(req: _ChooseReq) -> dict[str, Any]:
"kept_branch": job.kept_branch,
"proposed_ids": ids,
"changed_files": ds.changed_files(chosen.diff) if chosen is not None else [],
"recommendation": ds.recommendation(job.candidates),
}
3 changes: 3 additions & 0 deletions src/vouch/web/static/dual_solve.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
.ds-run input { flex: 1; }
.ds-progress { background:#111; color:#ddd; padding:.5rem; white-space:pre-wrap; }
.ds-error { color:#b00; }
.ds-recommendation { border:1px solid #ddd; padding:.5rem; background:#f8f8f8; }
.ds-panes { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
.ds-pane { border:1px solid #ccc; padding:.5rem; overflow:auto; }
.ds-changed-files { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; margin:.25rem 0 .5rem; }
.ds-log { margin:.25rem 0 .75rem; }
.ds-log pre { max-height:220px; overflow:auto; white-space:pre-wrap; background:#111; color:#ddd; padding:.5rem; }
.ds-file-head { font-weight:600; margin-top:.5rem; }
.ds-pane pre { margin:0; font-size:12px; overflow-x:auto; }
.ln-add { background:#e6ffed; display:block; }
Expand Down
13 changes: 12 additions & 1 deletion src/vouch/web/static/dual_solve.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default {
const job = reactive({
id: null, status: "idle", progress: [], candidates: [],
issue: null, error: null, kept_branch: null, proposed_ids: [],
changed_files: [],
changed_files: [], recommendation: null,
});

function applyState(s) {
Expand Down Expand Up @@ -64,6 +64,7 @@ export default {
async function run() {
job.progress = []; job.error = null; job.candidates = [];
job.kept_branch = null; job.proposed_ids = []; job.changed_files = [];
job.recommendation = null;
const r = await fetch("/dual-solve/run", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down Expand Up @@ -105,6 +106,12 @@ export default {
<p v-if="job.issue" class="ds-issue">#{{job.issue.number}} {{job.issue.title}}</p>
<pre v-if="job.progress.length" class="ds-progress">{{ job.progress.join('\\n') }}</pre>
<p v-if="job.error" class="ds-error">{{ job.error }}</p>
<p v-if="job.recommendation && job.recommendation.reason" class="ds-recommendation">
<strong>recommendation:</strong>
<span v-if="job.recommendation.engine">{{job.recommendation.engine}}</span>
<span v-else>no automatic pick</span>
<span> -- {{job.recommendation.reason}}</span>
</p>

<div v-if="job.status==='ready'||job.status==='done'" class="ds-panes">
<div v-for="c in job.candidates" :key="c.engine" class="ds-pane">
Expand All @@ -113,6 +120,10 @@ export default {
<ul v-if="c.changed_files && c.changed_files.length" class="ds-changed-files">
<li v-for="f in c.changed_files" :key="f">{{f}}</li>
</ul>
<details v-if="c.log" class="ds-log">
<summary>{{c.engine}} log</summary>
<pre>{{c.log}}</pre>
</details>
<div v-for="f in c.files" :key="f.path" class="ds-file">
<div class="ds-file-head">{{f.path}}</div>
<pre><code><span v-for="(l,i) in f.lines" :key="i" :class="'ln-'+l.cls">{{l.text}}\\n</span></code></pre>
Expand Down
45 changes: 42 additions & 3 deletions tests/test_dual_solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest
Expand Down Expand Up @@ -56,6 +57,32 @@ def test_changed_files_extracts_paths_from_git_diff():
assert ds.changed_files(diff) == ["README.md", "src/new.py"]


def test_recommendation_prefers_smaller_successful_diff():
claude = ds.Candidate(
"claude", "b-claude", Path("/w/claude"),
diff="diff --git a/a.txt b/a.txt\n+one\n", ok=True,
)
codex = ds.Candidate(
"codex", "b-codex", Path("/w/codex"),
diff="diff --git a/a.txt b/a.txt\n+one\n+two\n", ok=True,
)

rec = ds.recommendation([codex, claude])

assert rec["engine"] == "claude"
assert "smaller scoped diff" in (rec["reason"] or "")


def test_recommendation_avoids_tiebreaking_equal_scope():
claude = ds.Candidate("claude", "b1", Path("/a"), diff="d", ok=True)
codex = ds.Candidate("codex", "b2", Path("/b"), diff="x", ok=True)

rec = ds.recommendation([claude, codex])

assert rec["engine"] is None
assert "equally scoped" in (rec["reason"] or "")


def test_require_engines_raises_when_missing(monkeypatch):
monkeypatch.setattr(ds.shutil, "which", lambda b: None)
with pytest.raises(RuntimeError, match="not on PATH"):
Expand Down Expand Up @@ -140,6 +167,7 @@ def test_run_candidate_success_commits_and_captures_sha(tmp_path):
assert cand.engine == "claude"
assert cand.branch == "vouch-dual/3-fix-bug-claude"
assert cand.diff == "patch text" and cand.sha == "abc123"
assert cand.log == "done"
assert any(c[:5] == ["git", "-C", str(root), "worktree", "add"] for c in fr.calls)
assert any(c[:4] == ["git", "-C", str(wt), "commit"] for c in fr.calls)
assert any(c and c[0] == "claude" for c in fr.calls)
Expand Down Expand Up @@ -390,8 +418,16 @@ def test_cli_dual_solve_json_is_noninteractive(monkeypatch, tmp_path):
from vouch.cli import cli

issue = ds.Issue("t", "b", number=1)
cands = [ds.Candidate("claude", "b1", tmp_path / "a", diff="DA", ok=True),
ds.Candidate("codex", "b2", tmp_path / "b", diff="DB", ok=True)]
cands = [
ds.Candidate(
"claude", "b1", tmp_path / "a",
diff="diff --git a/a b/a\n+1\n", log="claude log", ok=True,
),
ds.Candidate(
"codex", "b2", tmp_path / "b",
diff="diff --git a/b b/b\n+1\n+2\n", log="codex log", ok=True,
),
]
monkeypatch.setattr("vouch.dual_solve._require_engines", lambda: None)
monkeypatch.setattr("vouch.dual_solve.repo_root", lambda r, c: tmp_path)
monkeypatch.setattr("vouch.dual_solve.prepare",
Expand All @@ -403,7 +439,10 @@ def test_cli_dual_solve_json_is_noninteractive(monkeypatch, tmp_path):

r = CliRunner().invoke(cli, ["dual-solve", "o/n#1", "--json"])
assert r.exit_code == 0, r.output
assert '"engine"' in r.output and "DA" in r.output and "DB" in r.output
body = json.loads(r.output)
assert body["recommendation"]["engine"] == "claude"
assert body["candidates"][0]["log"] == "claude log"
assert body["candidates"][1]["log"] == "codex log"
# --json must not prompt and must not finalize/record.
assert finalize_called["n"] == 0

Expand Down
10 changes: 8 additions & 2 deletions tests/test_web_dual_solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ def __init__(self, *, repo_root, runner, image):
def _fake_prepare(monkeypatch, *, calls):
issue = ds.Issue("Fix bug", "body", number=4, url="u")
cA = ds.Candidate("claude", "vouch-dual/4-fix-bug-claude", Path("/w/claude"),
diff="diff --git a/x b/x\n+1\n", sha="s1", ok=True)
diff="diff --git a/x b/x\n+1\n", sha="s1",
log="claude fixed it", ok=True)
cX = ds.Candidate("codex", "vouch-dual/4-fix-bug-codex", Path("/w/codex"),
diff="diff --git a/y b/y\n+2\n", sha="s2", ok=True)
diff="diff --git a/y b/y\n+2\n+3\n", sha="s2",
log="codex fixed it too", ok=True)

def fake(store, issue_ref, root, runner, *, claude_effort="high",
codex_effort="high", autonomy="edit", dry_run=False,
Expand Down Expand Up @@ -117,6 +119,9 @@ def test_run_starts_job_and_reaches_ready(git_kb, monkeypatch):
assert [x["engine"] for x in state["candidates"]] == ["claude", "codex"]
assert state["candidates"][0]["changed_files"] == ["x"]
assert state["candidates"][1]["changed_files"] == ["y"]
assert state["candidates"][0]["log"] == "claude fixed it"
assert state["candidates"][1]["log"] == "codex fixed it too"
assert state["recommendation"]["engine"] == "claude"
# autonomy is forced to edit regardless of input
assert calls[0]["autonomy"] == "edit"

Expand Down Expand Up @@ -200,6 +205,7 @@ def test_choose_winner_finalizes_and_returns_ids(git_kb, monkeypatch):
assert r.json()["proposed_ids"] == ["prop-1", "prop-2"]
assert r.json()["kept_branch"] == "vouch-dual/4-fix-bug-codex"
assert r.json()["changed_files"] == ["y"]
assert r.json()["recommendation"]["engine"] == "claude"
assert captured["winner"] == "codex"
assert captured["record"] is True and captured["reason"] == "cleaner"

Expand Down
Loading