Skip to content

Commit 3dfd830

Browse files
committed
fix(cli): mode-appropriate retry advice, and validate input files before any call (#1114)
Both from PR review, both real. 1. The retry-cap handler gave --brief-file runs an --answers-file message: it told them a "canned answer list cannot recover" and to "supply answers that address every part of the question". Brief mode has no list and takes a brief, so that advice is unactionable — and it contradicts this PR's own claim that --brief-file survives a rejection. Worse, can_desynchronise was added precisely to tell the two modes apart and then never used. It is now the branch: --answers-file is pointed at --brief-file, and --brief-file is told the brief probably does not cover the question, quoting it. 2. The input files were loaded after session.start_discovery(), which generates the opening question — a paid call taking minutes (#902). So a typo in a path cost a request before failing. This contradicted the comment three lines above the conflict check ("a flag conflict must not cost an API call"), which I had applied to the flags and not their contents. Both loads moved up; two tests assert create_provider is never called on a bad path. The brief-mode message test exercises the handler directly. Driving it through the CLI hung inside the discovery engine's own retry loop, which would have meant mocking the whole engine to assert a message string. Full suite: 6351 passed, 49 skipped.
1 parent 32d11a2 commit 3dfd830

2 files changed

Lines changed: 94 additions & 6 deletions

File tree

codeframe/cli/app.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,24 @@ def _handle_answer_attempts_exhausted(session, answers: "_AnswerSource", questio
196196
f"\n[yellow]That question has been answered {MAX_ANSWER_ATTEMPTS} times "
197197
f"without being accepted.[/yellow]"
198198
)
199-
if not answers.interactive:
199+
if answers.can_desynchronise:
200200
console.print(
201201
"[red]Error:[/red] giving up in non-interactive mode. The validator "
202202
"rejects partial answers, and AI-generated questions often have two "
203-
"parts — a canned answer list cannot recover once it desynchronises. "
204-
"Run interactively, or supply answers that address every part of the "
205-
"question."
203+
"parts — a canned --answers-file cannot recover once it "
204+
"desynchronises. Use --brief-file, which answers the question "
205+
"actually asked, or run interactively."
206+
)
207+
raise typer.Exit(1)
208+
if not answers.interactive:
209+
# --brief-file: there is no list to desynchronise, so repeated rejection
210+
# means the brief does not cover what is being asked.
211+
console.print(
212+
"[red]Error:[/red] giving up in non-interactive mode. The stand-in "
213+
"answered from the brief five times without being accepted, which "
214+
"usually means the brief does not cover what this question asks:\n\n"
215+
f" {escape(question.get('text', ''))}\n\n"
216+
"Extend the brief to cover it, or run interactively."
206217
)
207218
raise typer.Exit(1)
208219

@@ -1741,6 +1752,12 @@ def prd_generate(
17411752
)
17421753
raise typer.Exit(2)
17431754

1755+
# Same rule for the file contents. start_discovery() generates the opening
1756+
# question, which is a paid call taking minutes (#902) — a missing or
1757+
# malformed input file must not get that far.
1758+
canned = _load_answers_file(answers_file) if answers_file else None
1759+
brief = _load_brief_file(brief_file) if brief_file else None
1760+
17441761
console.print(f"[dim]Using template: {escape(template_obj.name)}[/dim]")
17451762

17461763
try:
@@ -1793,8 +1810,6 @@ def prd_generate(
17931810
console.print("[dim]The AI will ask questions to understand your project.[/dim]")
17941811
console.print("[dim]Type /help for available commands[/dim]\n")
17951812

1796-
canned = _load_answers_file(answers_file) if answers_file else None
1797-
brief = _load_brief_file(brief_file) if brief_file else None
17981813
brief_provider = None
17991814
if brief is not None:
18001815
# The stand-in answerer uses the same resolved provider chain as

tests/cli/test_prd_generate_non_interactive_1114.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AnswersExhausted,
2626
_AnswerSource,
2727
_load_answers_file,
28+
_handle_answer_attempts_exhausted,
2829
_load_brief_file,
2930
app,
3031
)
@@ -342,3 +343,75 @@ def test_a_non_utf8_brief_file(self, tmp_path):
342343
with pytest.raises(typer.BadParameter) as exc:
343344
_load_brief_file(path)
344345
assert "UTF-8" in str(exc.value)
346+
347+
348+
class TestReviewFindings:
349+
"""Two findings from PR review, both real."""
350+
351+
def test_a_bad_input_file_costs_no_llm_call(self, workspace_dir, tmp_path):
352+
"""start_discovery() generates the opening question — a paid, slow call.
353+
354+
Validation of the input file was happening after it, so a typo in a path
355+
cost a request and minutes of wall time before failing.
356+
"""
357+
with patch("codeframe.core.llm_resolution.create_provider") as create_provider:
358+
result = runner.invoke(
359+
app,
360+
[
361+
"prd", "generate", "-w", str(workspace_dir),
362+
"--answers-file", str(tmp_path / "does-not-exist.json"),
363+
],
364+
env={"ANTHROPIC_API_KEY": "test-key"},
365+
)
366+
367+
assert result.exit_code != 0
368+
create_provider.assert_not_called()
369+
370+
def test_a_bad_brief_file_costs_no_llm_call(self, workspace_dir, tmp_path):
371+
with patch("codeframe.core.llm_resolution.create_provider") as create_provider:
372+
result = runner.invoke(
373+
app,
374+
[
375+
"prd", "generate", "-w", str(workspace_dir),
376+
"--brief-file", str(tmp_path / "nope.md"),
377+
],
378+
env={"ANTHROPIC_API_KEY": "test-key"},
379+
)
380+
381+
assert result.exit_code != 0
382+
create_provider.assert_not_called()
383+
384+
def test_brief_mode_gets_brief_advice_not_answers_advice(self, capsys):
385+
"""The retry-cap message must match the mode.
386+
387+
--brief-file has no canned list, so telling the user their "answer list
388+
desynchronised" and to "supply answers" is unactionable. Exercised on the
389+
handler directly: driving it through the CLI would mean mocking the whole
390+
discovery engine, which is not what this finding is about.
391+
"""
392+
import typer
393+
394+
source = _AnswerSource(None, "A brief.", MagicMock())
395+
question = {"text": "What problem, and what inspired it?"}
396+
397+
with pytest.raises(typer.Exit):
398+
_handle_answer_attempts_exhausted(MagicMock(), source, question)
399+
400+
out = capsys.readouterr().out
401+
assert "brief" in out.lower()
402+
assert "--answers-file" not in out
403+
assert "canned" not in out.lower()
404+
# It should quote the question the brief failed to cover.
405+
assert "What problem" in out
406+
407+
def test_answers_file_mode_still_gets_answers_advice(self, capsys):
408+
import typer
409+
410+
source = _AnswerSource(["a", "b"])
411+
412+
with pytest.raises(typer.Exit):
413+
_handle_answer_attempts_exhausted(MagicMock(), source, {"text": "q"})
414+
415+
out = capsys.readouterr().out
416+
assert "--answers-file" in out
417+
assert "--brief-file" in out, "it should point at the mode that recovers"

0 commit comments

Comments
 (0)