diff --git a/api.py b/api.py index dbc8f03..d930ee6 100644 --- a/api.py +++ b/api.py @@ -282,6 +282,9 @@ async def _test_rung(fid: str, body: dict = Body(...)): if coder is None: raise HTTPException(400, f"acp delegate {coder_name!r} not found — check `delegates:`") + fusion_max_file_chars = max( + 1, int((cfg or {}).get("coder_solve_fusion_max_file_chars", coder_seam.FUSION_MAX_FILE_CHARS_DEFAULT)) + ) fusion_delegate = None if rung == "fusion": fusion_name = str((cfg or {}).get("coder_solve_fusion_delegate") or "").strip() @@ -290,6 +293,22 @@ async def _test_rung(fid: str, body: dict = Body(...)): fusion_delegate = coder_seam.resolve_delegate(fusion_name, "openai") if fusion_delegate is None: raise HTTPException(400, f"openai delegate {fusion_name!r} not found — check `delegates:`") + # Same gate `_drive` applies before a real dispatch — fusion can't + # tool-call and returns whole-file replacements, so this diagnostic + # must refuse the same oversized files a real build would skip. + viable, reason = coder_seam.fusion_viable_for_files( + (cfg or {}).get("repo", "."), + f.get("files_to_modify") or [], + max_file_chars=fusion_max_file_chars, + max_total_chars=max( + 1, + int( + (cfg or {}).get("coder_solve_fusion_max_total_chars", coder_seam.FUSION_MAX_TOTAL_CHARS_DEFAULT) + ), + ), + ) + if not viable: + raise HTTPException(400, f"rung='fusion' not viable for this feature's files: {reason}") task = ( f"# {f.get('title', '')}\n\n" @@ -317,6 +336,7 @@ async def _test_rung(fid: str, body: dict = Body(...)): fusion_delegate=fusion_delegate, fusion_k=max(1, int((cfg or {}).get("coder_solve_fusion_k", 2))), files_to_modify=f.get("files_to_modify") or [], + fusion_max_file_chars=fusion_max_file_chars, ) except Exception as exc: # noqa: BLE001 — surface as a 400, not a raw 500 raise HTTPException(400, f"test-rung failed: {exc}") from exc diff --git a/coder_seam.py b/coder_seam.py index f4199c8..88ac953 100644 --- a/coder_seam.py +++ b/coder_seam.py @@ -48,6 +48,24 @@ resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None`` and stops at tree-search exactly as before (honest degrade, unchanged). +**Fusion + large files — honest-degrade, not silent truncation.** Whole-file +replacement only works when a real completion can (a) see the WHOLE current file +and (b) reproduce the WHOLE new one — and the tighter constraint is usually the +OUTPUT side: a delegate's own ``max_tokens`` (often ~1024 by default, ~4K chars) +can truncate the response well before a merely-medium file's size, and +``OpenAiAdapter.dispatch`` doesn't surface ``finish_reason`` to tell a caller that +happened. So this module never attempts a full-file rewrite it can't stand behind: +``fusion_viable_for_files`` gates on the feature's ACTUAL on-disk file sizes +(per-file and combined) BEFORE fusion is ever dispatched — callers (``loop.py``, +``api.py``) check it and treat "not viable" exactly like "no fusion_delegate +configured" (``fusion_delegate=None`` for that dispatch). As a defensive backstop +(in case a caller skips the gate — direct ``test_rung`` callers, say) +``generate_fusion`` ALSO refuses to write a candidate file back over a +significantly larger original — a shrunk "complete" rewrite is far more likely a +truncated one than an honest tiny file than a real edit, so that file is left +unwritten (verify() then judges the candidate on what's actually there) rather +than risking data loss. + **``test_rung`` (operator-only diagnostic).** Verifying a specific rung — fusion especially, only otherwise reached after three cheaper rungs fail — shouldn't require contriving a task hard enough to fail its way there. ``test_rung`` runs @@ -93,7 +111,55 @@ def resolve_delegate(name: str, expect_type: str): # any other empty candidate — never a silent partial/mangled write. _FUSION_FILE_RE = re.compile(r"^###\s+(\S.+?)\s*$\n```[^\n]*\n(.*?)```", re.MULTILINE | re.DOTALL) -_FUSION_READ_MAX_CHARS = 20_000 # per-file context cap — fusion sees enough to edit, not a dump +# Defaults for `fusion_viable_for_files` — deliberately conservative. The binding +# constraint is usually the OUTPUT side (a delegate's own `max_tokens`, often +# ~1024 ⇒ ~4K chars, silently truncating a reply the adapter doesn't even expose +# `finish_reason` for), not this repo's own read logic — these caps exist so +# fusion is refused for a feature's files BEFORE a doomed rewrite is attempted, +# not tuned to "how much can Python read." Configurable per-board — see loop.py's +# `coder_solve_fusion_max_file_chars` / `_max_total_chars`. +FUSION_MAX_FILE_CHARS_DEFAULT = 8_000 +FUSION_MAX_TOTAL_CHARS_DEFAULT = 16_000 + +# Defense-in-depth for `generate_fusion`'s write guard: a returned file under this +# fraction of the ORIGINAL file's size is treated as a likely-truncated rewrite, +# not a legitimately smaller edit, and is refused. Only applies above a minimum +# original size (`_SHRINK_GUARD_MIN_ORIGINAL_CHARS`) — a real small edit to a +# small file (e.g. a 40-char file trimmed to 10) shouldn't trip a "big shrink" +# heuristic meant to catch multi-KB truncation. +_SHRINK_GUARD_RATIO = 0.5 +_SHRINK_GUARD_MIN_ORIGINAL_CHARS = 500 + + +def fusion_viable_for_files( + repo: str, + files_to_modify: list[str], + *, + max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, + max_total_chars: int = FUSION_MAX_TOTAL_CHARS_DEFAULT, +) -> tuple[bool, str]: + """Gate fusion on the feature's files BEFORE ever dispatching to it — whole-file + replacement only works when a real completion can see the whole current file + and reproduce the whole new one. Checks actual on-disk size (``os.path.getsize``, + never reads the file into memory just to measure it). Returns ``(True, "")`` when + every file is small enough (or doesn't exist yet — nothing to be too large), + else ``(False, reason)``. Callers treat ``False`` exactly like "no + fusion_delegate configured": honest degrade, not a silent truncated attempt.""" + import os + + total = 0 + for rel in files_to_modify: + p = Path(repo) / rel + try: + size = os.path.getsize(p) + except OSError: + continue # doesn't exist yet — nothing to be too large + if size > max_file_chars: + return False, f"{rel} is {size} chars, over the {max_file_chars}-char per-file cap for a full rewrite" + total += size + if total > max_total_chars: + return False, f"files_to_modify total {total} chars, over the {max_total_chars}-char combined cap" + return True, "" def _parse_fusion_files(reply: str) -> dict[str, str]: @@ -103,18 +169,41 @@ def _parse_fusion_files(reply: str) -> dict[str, str]: return {path.strip(): content for path, content in _FUSION_FILE_RE.findall(reply or "")} -def _fusion_prompt(task: str, *, feedback: str | None, repo: str, files_to_modify: list[str]) -> str: +def _fusion_prompt( + task: str, + *, + feedback: str | None, + repo: str, + files_to_modify: list[str], + max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, +) -> str: """Build fusion's prompt. Fusion can't read the repo itself (no tool-calling), so this hands it the CURRENT content of every file the feature declares — read from - the base repo, best-effort (a listed-but-not-yet-created file is noted as new).""" + the base repo, best-effort (a listed-but-not-yet-created file is noted as new). + + Callers are expected to have already checked ``fusion_viable_for_files`` (the + real gate) so a genuinely oversized file should never reach here — this + truncation is a DEFENSIVE backstop only (e.g. a direct ``test_rung`` call that + skipped the gate), and unlike the gate it's never silent: a truncated file is + marked as such so fusion knows not to claim a full-file replacement for it.""" file_blocks = [] for rel in files_to_modify: p = Path(repo) / rel try: - text = p.read_text(errors="replace")[:_FUSION_READ_MAX_CHARS] - file_blocks.append(f"### {rel} (current content)\n```\n{text}\n```") + raw = p.read_text(errors="replace") except OSError: file_blocks.append(f"### {rel} (does not exist yet — you are creating it)") + continue + if len(raw) > max_file_chars: + text = raw[:max_file_chars] + file_blocks.append( + f"### {rel} (current content — TRUNCATED at {max_file_chars} chars, " + f"real file is {len(raw)} chars — do NOT return this as a complete " + "replacement; skip this file instead)\n```\n" + f"{text}\n```" + ) + else: + file_blocks.append(f"### {rel} (current content)\n```\n{raw}\n```") files_section = ( "\n\n".join(file_blocks) if file_blocks else "(no existing files listed — create what the task needs)" ) @@ -224,6 +313,7 @@ def __init__( verdict_cls, fusion_delegate=None, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _fusion_dispatch=None, ): self.repo = repo @@ -237,6 +327,7 @@ def __init__( self.verdict_cls = verdict_cls # `plugins.coder.solve.Verdict` — passed in, never imported here self.fusion_delegate = fusion_delegate # a resolved `openai`-type Delegate, or None self.files_to_modify = files_to_modify or [] + self.fusion_max_file_chars = fusion_max_file_chars # Test-injection seam (mirrors `_solve`/`_budget_cls`/`_verdict_cls` on # `dispatch()`): production never passes this — the real lazy # `ADAPTERS["openai"].dispatch` import happens in `generate_fusion` below. @@ -273,7 +364,13 @@ async def generate_fusion(self, task: str, *, feedback: str | None = None) -> st openai_dispatch = ADAPTERS["openai"].dispatch - prompt = _fusion_prompt(task, feedback=feedback, repo=self.repo, files_to_modify=self.files_to_modify) + prompt = _fusion_prompt( + task, + feedback=feedback, + repo=self.repo, + files_to_modify=self.files_to_modify, + max_file_chars=self.fusion_max_file_chars, + ) reply = await openai_dispatch(self.fusion_delegate, prompt, timeout=self.dispatch_timeout) files = _parse_fusion_files(reply) wt, _branch = await self._new_candidate_worktree() @@ -290,6 +387,36 @@ async def generate_fusion(self, task: str, *, feedback: str | None = None) -> st "[project_board] %s fusion tried to write outside its worktree: %r — skipped", self.fid, rel ) continue + # Fusion has no tool access — it can only ever act on the files we showed + # it. A path outside the feature's declared set means it hallucinated a + # file (or the parser mis-split the reply); writing it would silently + # touch unrelated code with no test coverage backing the change. + if self.files_to_modify and rel not in self.files_to_modify: + log.warning("[project_board] %s fusion tried to write an undeclared path: %r — skipped", self.fid, rel) + continue + # Fusion returns whole-file replacements with no diff to sanity-check. + # A reply that's drastically smaller than the file it claims to replace + # is far more likely a truncated completion (see FUSION_MAX_FILE_CHARS_DEFAULT + # and the delegate's own max_tokens ceiling) than an intentional big + # deletion — refuse it rather than risk silent data loss. + if dest.exists(): + try: + original_size = dest.stat().st_size + except OSError: + original_size = 0 + if ( + original_size > _SHRINK_GUARD_MIN_ORIGINAL_CHARS + and len(content) < original_size * _SHRINK_GUARD_RATIO + ): + log.warning( + "[project_board] %s fusion's rewrite of %r (%d chars) is suspiciously smaller than " + "the original (%d chars) — refusing, likely truncated", + self.fid, + rel, + len(content), + original_size, + ) + continue dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content) written += 1 @@ -373,6 +500,7 @@ async def dispatch( fusion_delegate=None, fusion_k: int = 2, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _solve=None, _budget_cls=None, _verdict_cls=None, @@ -432,6 +560,7 @@ async def dispatch( verdict_cls=Verdict, fusion_delegate=fusion_delegate, files_to_modify=files_to_modify, + fusion_max_file_chars=fusion_max_file_chars, _fusion_dispatch=_fusion_dispatch, ) try: @@ -521,6 +650,7 @@ async def test_rung( fusion_delegate=None, fusion_k: int = 2, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _solve=None, _budget_cls=None, _verdict_cls=None, @@ -557,6 +687,7 @@ async def test_rung( verdict_cls=Verdict, fusion_delegate=fusion_delegate, files_to_modify=files_to_modify, + fusion_max_file_chars=fusion_max_file_chars, _fusion_dispatch=_fusion_dispatch, ) try: diff --git a/loop.py b/loop.py index 1656e35..e1be91c 100644 --- a/loop.py +++ b/loop.py @@ -242,6 +242,17 @@ def __init__(self, cfg: dict): # Blank ⇒ no fusion rung; the ladder stops at tree-search exactly as before. self.coder_solve_fusion_delegate = str(self.cfg.get("coder_solve_fusion_delegate", "")).strip() self.coder_solve_fusion_k = max(1, int(self.cfg.get("coder_solve_fusion_k", 2))) + # Fusion can't tool-call and returns whole-file replacements with no diff — + # a file over this cap risks a silent truncated "complete" rewrite (see + # coder_seam.fusion_viable_for_files). Gated BEFORE dispatch, not after: + # an oversized feature just skips the fusion rung (fusion_delegate=None + # for that dispatch), it never gets to attempt-and-corrupt. + self.coder_solve_fusion_max_file_chars = max( + 1, int(self.cfg.get("coder_solve_fusion_max_file_chars", coder_seam.FUSION_MAX_FILE_CHARS_DEFAULT)) + ) + self.coder_solve_fusion_max_total_chars = max( + 1, int(self.cfg.get("coder_solve_fusion_max_total_chars", coder_seam.FUSION_MAX_TOTAL_CHARS_DEFAULT)) + ) # KG lessons (the flywheel READ half): before dispatching a coder, query the # knowledge graph (via graph.sdk) for distilled lessons relevant to THIS feature # and inject them into the prompt — so the coder heeds this area's known failure @@ -681,11 +692,27 @@ async def _drive(self, feature: dict): self._inflight[fid] = (repo, wt, branch) result = await worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None) elif self._use_coder_solve(feature) and not self._ci_feedback.get(fid): + files_to_modify = feature.get("files_to_modify") or [] fusion = ( self._resolve_delegate(self.coder_solve_fusion_delegate, "openai") if self.coder_solve_fusion_delegate else None ) + if fusion is not None: + # Gate BEFORE dispatch: fusion can't tool-call and returns + # whole-file replacements, so an oversized file risks a + # silent truncated rewrite (coder_seam.fusion_viable_for_files). + # Not viable ⇒ this dispatch just skips the fusion rung — the + # ladder still runs greedy/best-of-k/tree-search unchanged. + viable, reason = coder_seam.fusion_viable_for_files( + repo, + files_to_modify, + max_file_chars=self.coder_solve_fusion_max_file_chars, + max_total_chars=self.coder_solve_fusion_max_total_chars, + ) + if not viable: + log.info("[project_board] %s fusion rung skipped for this dispatch: %s", fid, reason) + fusion = None wt, branch, result = await coder_seam.dispatch( task=prompt, coder=coder, @@ -702,7 +729,8 @@ async def _drive(self, feature: dict): record_gens=lambda n: store.record_gens_spent(fid, n), fusion_delegate=fusion, fusion_k=self.coder_solve_fusion_k, - files_to_modify=feature.get("files_to_modify") or [], + files_to_modify=files_to_modify, + fusion_max_file_chars=self.coder_solve_fusion_max_file_chars, ) self._inflight[fid] = (repo, wt, branch) elif self.max_mode_n > 1 and not self._ci_feedback.get(fid): diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 59e4793..1e17280 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.29.0 +version: 0.29.1 description: >- A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready → in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an diff --git a/pyproject.toml b/pyproject.toml index c013993..20bd2c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.29.0" +version = "0.29.1" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/tests/test_api.py b/tests/test_api.py index 2d1c616..03adc54 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -386,6 +386,36 @@ def test_test_rung_fusion_400s_without_a_configured_fusion_delegate(monkeypatch) assert "coder_solve_fusion_delegate" in r.json()["detail"] +def test_test_rung_fusion_400s_when_files_are_oversized(monkeypatch, tmp_path): + """Same gate `_drive` applies before a real dispatch: fusion can't tool-call + and returns whole-file replacements, so an oversized declared file must be + refused here too, before ever reaching coder_seam.test_rung.""" + (tmp_path / "big.py").write_text("x" * 1000) + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid, files=["big.py"])) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "resolve_delegate", lambda name, t: object()) + + async def _boom(**kwargs): + raise AssertionError("coder_seam.test_rung must not be reached when fusion isn't viable") + + monkeypatch.setattr(coder_seam, "test_rung", _boom) + c = _client( + monkeypatch, + store, + cfg={ + "coder_solve_test_cmd": "pytest -q", + "coder_solve_fusion_delegate": "fusion-model", + "coder_solve_fusion_max_file_chars": 10, + "repo": str(tmp_path), + }, + ) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "fusion"}) + assert r.status_code == 400 + assert "not viable" in r.json()["detail"] + assert "big.py" in r.json()["detail"] + + def test_test_rung_happy_path_calls_coder_seam_test_rung_and_returns_its_result(monkeypatch): store = FakeStore() monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) diff --git a/tests/test_coder_seam.py b/tests/test_coder_seam.py index c9775c0..b0a703c 100644 --- a/tests/test_coder_seam.py +++ b/tests/test_coder_seam.py @@ -637,6 +637,43 @@ def test_parse_fusion_files_no_match_returns_empty(): assert coder_seam._parse_fusion_files("") == {} +def test_fusion_viable_for_files_true_when_under_both_caps(tmp_path): + (tmp_path / "a.py").write_text("x" * 100) + (tmp_path / "b.py").write_text("y" * 100) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["a.py", "b.py"], max_file_chars=1_000, max_total_chars=1_000 + ) + assert ok is True + assert reason == "" + + +def test_fusion_viable_for_files_false_over_per_file_cap(tmp_path): + (tmp_path / "huge.py").write_text("x" * 500) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["huge.py"], max_file_chars=100, max_total_chars=10_000 + ) + assert ok is False + assert "huge.py" in reason + assert "100-char per-file cap" in reason + + +def test_fusion_viable_for_files_false_over_combined_cap(tmp_path): + (tmp_path / "a.py").write_text("x" * 100) + (tmp_path / "b.py").write_text("y" * 100) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["a.py", "b.py"], max_file_chars=1_000, max_total_chars=150 + ) + assert ok is False + assert "combined cap" in reason + + +def test_fusion_viable_for_files_skips_files_that_do_not_exist_yet(tmp_path): + # A feature creating a brand-new file has nothing on disk to be too large yet. + ok, reason = coder_seam.fusion_viable_for_files(str(tmp_path), ["not_yet_created.py"]) + assert ok is True + assert reason == "" + + def test_fusion_prompt_includes_task_and_existing_file_content(tmp_path): (tmp_path / "existing.py").write_text("def old(): pass\n") prompt = coder_seam._fusion_prompt( @@ -660,6 +697,27 @@ def test_fusion_prompt_includes_feedback_when_refining(tmp_path): assert "AssertionError" in prompt +def test_fusion_prompt_truncation_is_visible_not_silent(tmp_path): + """Defensive backstop only (real callers gate via `fusion_viable_for_files` + first) — but if a caller ever skips that gate, a truncated read must tell + fusion to skip the file rather than let it return a "complete" replacement + of content it never actually saw in full.""" + (tmp_path / "big.py").write_text("x = 1\n" * 10) + prompt = coder_seam._fusion_prompt( + "fix it", feedback=None, repo=str(tmp_path), files_to_modify=["big.py"], max_file_chars=10 + ) + assert "TRUNCATED at 10 chars" in prompt + assert "do NOT return this as a complete replacement" in prompt + + +def test_fusion_prompt_no_truncation_marker_when_file_fits(tmp_path): + (tmp_path / "small.py").write_text("x = 1\n") + prompt = coder_seam._fusion_prompt( + "fix it", feedback=None, repo=str(tmp_path), files_to_modify=["small.py"], max_file_chars=10_000 + ) + assert "TRUNCATED" not in prompt + + async def test_generate_fusion_writes_parsed_files_into_a_fresh_worktree(monkeypatch, tmp_path): created, *_ = _stub_worktree(monkeypatch) @@ -731,6 +789,112 @@ async def _create_in_tmp(repo, base, cid, root): assert not Path("/etc/shadow_THIS_MUST_NOT_EXIST_pwned2").exists() +async def test_generate_fusion_restricts_writes_to_declared_files_to_modify(monkeypatch, tmp_path): + """Fusion has no tool access — it only ever sees the files we showed it. A + path outside the feature's declared `files_to_modify` means a hallucinated + file (or a parser mis-split); writing it would silently touch unrelated + code with no test coverage backing the change.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### declared.py\n```\nfine\n```\n\n### undeclared.py\n```\nsneaky\n```" + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["declared.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + assert (Path(wt) / "declared.py").read_text() == "fine\n" + assert not (Path(wt) / "undeclared.py").exists() + + +async def test_generate_fusion_shrink_guard_refuses_a_suspiciously_smaller_rewrite(monkeypatch, tmp_path): + """A whole-file "complete replacement" that comes back drastically smaller + than the file it claims to replace is far more likely a truncated + completion (delegate max_tokens ceiling) than an intentional big deletion.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### big.py\n```\nx\n```" # a few chars back for a 1000-char original + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + (d / "big.py").write_text("x = 1\n" * 200) # 1200 chars, well over the min-original floor + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["big.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + # refused — the pre-existing (larger) content must survive untouched + assert (Path(wt) / "big.py").read_text() == "x = 1\n" * 200 + + +async def test_generate_fusion_shrink_guard_allows_a_legitimately_smaller_edit(monkeypatch, tmp_path): + """The guard only kicks in above `_SHRINK_GUARD_MIN_ORIGINAL_CHARS` and below + `_SHRINK_GUARD_RATIO` — a real, modest trim must still go through.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### small.py\n```\nx = 1\n```" + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + (d / "small.py").write_text("x = 1\ny = 2\n") # tiny original, under the min-original floor + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["small.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + assert (Path(wt) / "small.py").read_text() == "x = 1\n" + + async def test_generate_fusion_empty_reply_writes_nothing_and_does_not_crash(monkeypatch, tmp_path): _stub_worktree(monkeypatch) diff --git a/tests/test_loop.py b/tests/test_loop.py index b6b03e3..bc9ad3d 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -481,6 +481,7 @@ async def _fake_dispatch( fusion_delegate=None, fusion_k=2, files_to_modify=None, + fusion_max_file_chars=None, ): seen["fid"] = fid seen["test_cmd"] = test_cmd @@ -504,6 +505,42 @@ async def _open_pr(wt, branch, *, base, title, body): assert store.creates == [] # solve()'s own per-candidate worktrees replaced the single create +async def test_drive_skips_fusion_for_a_dispatch_when_files_are_oversized(monkeypatch, tmp_path): + """Fusion can't tool-call and returns whole-file replacements — an oversized + file must gate BEFORE dispatch (fusion_delegate=None for that dispatch), not + get attempted and risk a silently truncated rewrite. The ladder still runs + (greedy/best-of-k/tree-search), it just skips the fusion rung.""" + from project_board import coder_seam + + (tmp_path / "big.py").write_text("x" * 1000) + seen = {} + + async def _fake_dispatch(*, fusion_delegate=None, **kw): + seen["fusion_delegate"] = fusion_delegate + return (f"/wt/feat-{kw['fid']}", f"feat/{kw['fid']}", "[coder.solve rung=greedy gens=1] solved") + + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "dispatch", _fake_dispatch) + + async def _open_pr(wt, branch, *, base, title, body): + return "https://example/pr/42" + + feature = {**FEATURE, "repo": str(tmp_path), "files_to_modify": ["big.py"]} + loop, store = await _drive_with( + monkeypatch, + open_pr=_open_pr, + cfg={ + "coder": "proto", + "local_gate_cmd": "pytest -q", + "coder_solve_fusion_delegate": "fusion-model", + "coder_solve_fusion_max_file_chars": 10, + }, + gate=_pass_gate, + feature=feature, + ) + assert seen["fusion_delegate"] is None # gated out before dispatch, not attempted + + async def test_drive_falls_back_to_single_shot_without_acceptance_criteria(monkeypatch): """Honest degrade: even with the coder plugin available and a test command configured, a feature with NO acceptance criteria takes today's single