diff --git a/__init__.py b/__init__.py index 75a07c1..9994213 100644 --- a/__init__.py +++ b/__init__.py @@ -294,6 +294,43 @@ def board_update_feature( except BoardError as exc: return f"Error: {exc}" + @tool + def board_get_feature(feature_id: str) -> str: + """Read a single feature's FULL detail as JSON — `title`, `spec`, + `acceptance_criteria`, `design`, `state`, `labels`, `pr_url`, `difficulty`, + `files_to_modify`, `foundation`, `priority`, `source_issue`, plus two + dependency views: `depends_on` (EVERY blocking edge — the historical ledger, + including already-merged blockers) and `open_depends_on` (only the edges + whose blocker is still OPEN — the live, actionable "what's blocking me now" + signal). The READ half of a read-modify-write: fetch the current criteria/ + spec, revise them, then `board_update_feature` — no operator round-trip. Returns + `Error: unknown feature …` for an id that isn't on the board.""" + try: + f = get_store(**store_kw).get_feature(feature_id) + except BoardError as exc: + return f"Error: {exc}" + if f is None: + return f"Error: unknown feature {feature_id!r}" + return json.dumps( + { + "id": f["id"], + "title": f["title"], + "spec": f["spec"], + "acceptance_criteria": f["acceptance_criteria"], + "design": f["design"], + "state": f["board_state"], + "labels": f.get("labels", []), + "pr_url": f["pr_url"], + "difficulty": f["difficulty"], + "files_to_modify": f.get("files_to_modify", []), + "foundation": f.get("foundation", False), + "priority": f.get("priority", 2), + "source_issue": f.get("source_issue", ""), + "depends_on": f.get("depends_on", []), + "open_depends_on": f.get("open_depends_on", []), + } + ) + @tool def board_mark_ready(feature_id: str) -> str: """Promote a feature backlog → ready. Fails if it lacks a spec + @@ -347,4 +384,12 @@ def board_retro() -> str: indent=2, ) - return [board_create_epic, board_create_feature, board_update_feature, board_mark_ready, board_list, board_retro] + return [ + board_create_epic, + board_create_feature, + board_update_feature, + board_get_feature, + board_mark_ready, + board_list, + board_retro, + ] diff --git a/api.py b/api.py index 5001f4e..7d9feeb 100644 --- a/api.py +++ b/api.py @@ -103,6 +103,46 @@ def _handle(): return _guard(_handle) + @router.post("/features/{fid}/review") + async def _review(fid: str, body: dict = Body(...)): + """Adverse code-review bounce for the feature's open PR — the review sibling + of ``/ci`` fail. Records the ``findings`` as a DISTINCT review-bounce comment + on the bead (≠ ci-fail), feeds them into the next dispatch prompt (the same + ``_ci_feedback`` lever the in-loop review gate uses), and **requeues onto the + SAME open PR** (``pr_url`` preserved via external_ref). Works from + ``in_review``. + + Body: ``{findings: str, escalate: bool=false}``. ``escalate=true`` climbs the + model ladder (like ``/ci``); the default keeps the same tier. With escalation + enabled and the ladder already at the top, an escalated bounce → Blocked + (never a silent re-loop).""" + findings = str(body.get("findings", "")) + escalate = bool(body.get("escalate", False)) + + def _handle(): + s = store() + # Distinct review-bounce comment on the bead (enforces in_review), then hand + # the findings to the loop so its next dispatch prompt LEADS with them — the + # external sibling of the in-loop review gate's _ci_feedback write. + s.record_review_bounce(fid, findings) + from .loop import queue_review_feedback + + queue_review_feedback(fid, findings) + if escalate and escalate_on: + nxt = s.escalate(fid, f"review-fail: {findings}" if findings else "review-fail") + if nxt is None: + return { + "requeued": False, + "escalated": True, + "exhausted": True, + "feature": s.block_from_review(fid, f"review-fail: {findings}"), + } + return {"requeued": True, "escalated": True, "next_tier": nxt, "feature": s.requeue(fid)} + # escalate=false (or no ladder configured): requeue at the SAME tier. + return {"requeued": True, "escalated": False, "feature": s.requeue(fid)} + + return _guard(_handle) + # ── the ONE Done edge: merge webhook ────────────────────────────────────── @router.post("/webhook/pr") async def _webhook_pr(request: Request): diff --git a/loop.py b/loop.py index 1c10d9c..0cb440b 100644 --- a/loop.py +++ b/loop.py @@ -55,6 +55,31 @@ log = logging.getLogger("protoagent.plugins.project_board") +# ── external re-dispatch feedback bridge (the /review route → the loop) ────────── +# An adverse-review bounce POSTed to /features/{fid}/review is handled in the API +# router — a DIFFERENT object from the running loop (register() mounts both, so they +# share a process but not an instance). This module-level dict is the seam between +# them: the router stashes the findings here and the loop drains them into its +# per-run ``_ci_feedback`` the next time it builds a dispatch prompt — the same +# lever the in-loop review gate writes directly. Keyed by feature id; last write wins. +_PENDING_FEEDBACK: dict[str, str] = {} + + +def queue_review_feedback(fid: str, findings: str) -> None: + """Stash an adverse-review bounce's ``findings`` so the loop leads ``fid``'s next + dispatch prompt with them — the cross-instance sibling of the in-loop review + gate's ``_ci_feedback`` write (``POST /features/{fid}/review`` calls this). Blank + findings are a no-op (nothing to carry back).""" + text = str(findings or "").strip() + if not text: + return + _PENDING_FEEDBACK[fid] = ( + "An adverse code review REQUESTED CHANGES on your PR. Fix every finding " + "below in the existing branch (the PR updates on push) — do not rewrite " + "unrelated code.\n\n" + text + ) + + # ── auto gate resolution ──────────────────────────────────────────────────────── # The pre-PR gate is repo-specific, and hard-coding one repo's check steps into the # orchestrator (or the operator's dispatch) rots two ways: the repo's CI changes and @@ -1927,6 +1952,12 @@ def _build_prompt(self, feature: dict, lessons: str = "") -> str: # checks itself — edit-only). Also widen scope: the fix may touch tests/files # the original `files_to_modify` didn't list (the #1053 lesson). fid = feature.get("id", "") + # Drain any externally-queued review-bounce feedback (the /review route stashed + # it via queue_review_feedback) into THIS run's _ci_feedback, so an operator/CI + # review bounce rides the exact same prompt path as an in-loop CI/review bounce. + pending = _PENDING_FEEDBACK.pop(fid, None) + if pending: + self._ci_feedback[fid] = pending ci = self._ci_feedback.get(fid) prior = self._ci_prior_diff.get(fid) prior_block = ( diff --git a/store.py b/store.py index 0f62ef9..95c7e0e 100644 --- a/store.py +++ b/store.py @@ -790,6 +790,18 @@ def bounce_ci_fail(self, fid: str, reason: str = "") -> dict: self._comment(fid, f"CI failed: {reason}") return self.get_feature(fid) + def record_review_bounce(self, fid: str, findings: str = "") -> dict: + """Record an adverse code-review bounce as a DISTINCT comment on the bead — + the review sibling of ``bounce_ci_fail``'s ``CI failed:`` note, kept separate + from the requeue so the review history survives on the bead even though the + same open PR is reused. Expects ``in_review`` — the state an adverse review + lands from; the caller then ``requeue``s onto the same PR (pr_url preserved).""" + f = self._require(fid) + if f["board_state"] != "in_review": + raise BoardError(f"review bounce expects in_review, got {f['board_state']!r}") + self._comment(fid, f"review requested changes: {findings}" if findings else "review requested changes") + return f + def requeue(self, fid: str) -> dict: """Put a feature back to `ready` for re-dispatch (keeps its open PR via external_ref). The puller re-claims it and the loop re-dispatches — at the @@ -1133,10 +1145,16 @@ def _project(self, bead: dict) -> dict: # the puller won't claim it. Only `br show` carries dependencies (`br list` # doesn't); list_features patches this by cross-referencing the puller. state = self.board_state(bead) - dag_blocked = state == "ready" and any( - d.get("dependency_type") == "blocks" and d.get("status") != "closed" - for d in (bead.get("dependencies") or []) - ) + blocks_edges = [d for d in (bead.get("dependencies") or []) if d.get("dependency_type") == "blocks"] + dag_blocked = state == "ready" and any(d.get("status") != "closed" for d in blocks_edges) + # The `blocks` dependency ledger vs. its live subset. `br show` carries + # dependencies (`br list` omits them, so BOTH are [] in a list projection — + # read a single feature via get_feature for the real edges). `depends_on` is + # EVERY blocking edge — the historical ledger, including already-merged + # (closed) blockers; `open_depends_on` keeps only the edges whose blocker is + # still open — the live "what is actually blocking me right now" signal. + depends_on = [d["id"] for d in blocks_edges if d.get("id")] + open_depends_on = [d["id"] for d in blocks_edges if d.get("id") and d.get("status") != "closed"] return { "id": bead.get("id"), "title": bead.get("title", ""), @@ -1156,6 +1174,8 @@ def _project(self, bead: dict) -> dict: "cancelled": LABEL_CANCELLED in labels, "foundation": LABEL_FOUNDATION in labels, "difficulty": diff, + "depends_on": depends_on, + "open_depends_on": open_depends_on, "attempts": attempts, "gens_spent": gens_spent, "verified_sha": verified_sha, diff --git a/tests/test_api.py b/tests/test_api.py index 3aad2ff..b25fef6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -89,6 +89,9 @@ def delete_feature(self, fid, reason=""): def bounce_ci_fail(self, fid, reason): return self._rec("bounce_ci_fail", fid, reason) + def record_review_bounce(self, fid, findings=""): + return self._rec("record_review_bounce", fid, findings) + def escalate(self, fid, reason): self.calls.append(("escalate", (fid, reason), {})) return self._escalate_to @@ -390,6 +393,89 @@ def test_ci_fail_with_a_single_coder_bounces_to_in_progress(monkeypatch): assert any(call[0] == "bounce_ci_fail" for call in store.calls) +# ── /features/{fid}/review — the adverse-review bounce (bd-171) ───────────────── +# The review sibling of /ci fail: record a distinct review-bounce comment, feed the +# findings into the loop's re-dispatch prompt (the _ci_feedback bridge), and requeue +# onto the SAME open PR. escalate=false keeps the tier; escalate=true climbs. + + +def test_review_bounce_requeues_and_records_a_distinct_comment(monkeypatch): + from project_board import loop as loop_mod + + loop_mod._PENDING_FEEDBACK.clear() + store = FakeStore() + c = _client(monkeypatch, store, cfg={}) # no ladder → default keeps the same tier + r = c.post( + "/plugins/project_board/features/bd-1/review", + json={"findings": "auth check missing a null guard"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["requeued"] is True and body["escalated"] is False + names = [call[0] for call in store.calls] + assert "record_review_bounce" in names # a distinct review-bounce comment on the bead + assert "requeue" in names # same open PR reused (store.requeue keeps external_ref) + assert "escalate" not in names # default keeps the same tier + + +def test_review_findings_reach_the_loop_feedback_bridge(monkeypatch): + """AC: the findings text crosses into the loop's re-dispatch path (the same + _ci_feedback lever the in-loop review gate writes), via queue_review_feedback.""" + from project_board import loop as loop_mod + + loop_mod._PENDING_FEEDBACK.clear() + c = _client(monkeypatch, FakeStore()) + c.post("/plugins/project_board/features/bd-1/review", json={"findings": "missing a null guard"}) + assert "missing a null guard" in loop_mod._PENDING_FEEDBACK.get("bd-1", "") + + +def test_review_escalate_true_climbs_the_ladder(monkeypatch): + store = FakeStore(escalate_to="smart") + c = _client(monkeypatch, store, cfg=ESCALATION_CFG) + r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": True}) + body = r.json() + assert body["escalated"] is True and body["next_tier"] == "smart" and body["requeued"] is True + assert any(call[0] == "escalate" for call in store.calls) + assert any(call[0] == "requeue" for call in store.calls) + + +def test_review_escalate_false_keeps_the_same_tier(monkeypatch): + store = FakeStore(escalate_to="smart") + c = _client(monkeypatch, store, cfg=ESCALATION_CFG) # a ladder exists… + r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": False}) + body = r.json() + assert body["escalated"] is False and body["requeued"] is True + assert not any(call[0] == "escalate" for call in store.calls) # …but the default doesn't climb it + assert any(call[0] == "requeue" for call in store.calls) + + +def test_review_escalate_exhausted_blocks(monkeypatch): + store = FakeStore(escalate_to=None) # ladder already at the top + c = _client(monkeypatch, store, cfg=ESCALATION_CFG) + r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x", "escalate": True}) + body = r.json() + assert body["exhausted"] is True and body["requeued"] is False + assert any(call[0] == "block_from_review" for call in store.calls) + + +def test_review_is_public_not_operator_gated(monkeypatch): + c = _client(monkeypatch, FakeStore()) + # served on the public prefix (a review-infra edge, like /ci + /webhook)… + assert c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x"}).status_code == 200 + # …and NOT on the gated /api prefix. + assert c.post("/api/plugins/project_board/features/bd-1/review", json={"findings": "x"}).status_code == 404 + + +def test_review_from_a_non_in_review_state_surfaces_as_400(monkeypatch): + class WrongState(FakeStore): + def record_review_bounce(self, fid, findings=""): + raise BoardError("review bounce expects in_review, got 'in_progress'") + + c = _client(monkeypatch, WrongState()) + r = c.post("/plugins/project_board/features/bd-1/review", json={"findings": "x"}) + assert r.status_code == 400 and "in_review" in r.json()["detail"] + + # ── /features/{fid}/test-rung — operator-only diagnostic (ADR 0064) ───────────── # No @tool wrapper anywhere in coder_seam.py/api.py exposes this to the board's # own lead agent — these tests only exercise the HTTP route directly, mirroring diff --git a/tests/test_loop.py b/tests/test_loop.py index 23965e7..f4adabe 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -126,6 +126,30 @@ def test_build_prompt_asks_for_a_clean_pr_summary_not_raw_reasoning(): assert "do not narrate your process" in prompt.lower() +def test_queue_review_feedback_reaches_the_next_prompt(): + """AC (bd-171): a /review bounce stashed via queue_review_feedback rides the SAME + prompt path as an in-loop CI/review bounce — _build_prompt drains it into + _ci_feedback, leads the prompt with it, and clears the one-shot pending entry.""" + from project_board.loop import _PENDING_FEEDBACK, queue_review_feedback + + _PENDING_FEEDBACK.clear() + loop = BoardLoop({}) + queue_review_feedback("bd-1", "the auth check is missing a null guard") # FEATURE id is bd-1 + prompt = loop._build_prompt(FEATURE) + assert "REJECTED" in prompt # the previous-attempt-rejected block fires + assert "null guard" in prompt # the findings text reached the dispatch prompt + assert "bd-1" not in _PENDING_FEEDBACK # drained one-shot + assert loop._ci_feedback.get("bd-1") # promoted into the per-run feedback lever + + +def test_queue_review_feedback_ignores_blank_findings(): + from project_board.loop import _PENDING_FEEDBACK, queue_review_feedback + + _PENDING_FEEDBACK.clear() + queue_review_feedback("bd-9", " ") + assert "bd-9" not in _PENDING_FEEDBACK # nothing to carry back + + def test_is_test_path_classification(): """The deterministic gate's path classifier — what counts as a test vs code.""" from project_board.loop import _is_code_path, _is_test_path diff --git a/tests/test_store.py b/tests/test_store.py index 2fe9004..b7ac86d 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -984,6 +984,70 @@ def test_resolve_plan_dep_multi_dash_index_is_named_not_a_crash(bad): def test_resolve_plan_dep_out_of_range_index_raises_named(): with pytest.raises(BoardError, match="out of range"): BeadsBoard._resolve_plan_dep("7", {0: "bd-1"}, {}) + + +# ── _project: depends_on ledger vs. the live open subset (bd-171) ──────────────── + + +def test_project_exposes_depends_on_ledger_and_open_subset(make_board): + """`_project` surfaces BOTH dependency views: `depends_on` is every `blocks` + edge (the historical ledger, incl. already-merged/closed blockers) while + `open_depends_on` keeps only the edges whose blocker is still open — the live, + actionable signal. Non-`blocks` edges are ignored by both.""" + b = make_board(Br()) + bead = { + "id": "bd-5", + "status": "open", + "labels": [], + "dependencies": [ + {"id": "bd-a", "dependency_type": "blocks", "status": "closed"}, # blocker merged + {"id": "bd-b", "dependency_type": "blocks", "status": "open"}, # still blocking + {"id": "bd-c", "dependency_type": "related", "status": "open"}, # not a blocks edge + ], + } + f = b._project(bead) + assert f["depends_on"] == ["bd-a", "bd-b"] # the full ledger + assert f["open_depends_on"] == ["bd-b"] # only the still-open blocker + # a feature with no deps (`br list` omits them too) → both empty, never missing + empty = b._project({"id": "x", "status": "open", "labels": []}) + assert empty["depends_on"] == [] and empty["open_depends_on"] == [] + + +# ── the adverse-review bounce (bd-171): a distinct comment + requeue-on-same-PR ── + + +def test_record_review_bounce_comments_from_in_review_distinct_from_ci(make_board, monkeypatch): + br = Br() + b = make_board(br) + comments = [] + monkeypatch.setattr(b, "_comment", lambda fid, text: comments.append((fid, text))) + monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "in_review"}) + b.record_review_bounce("bd-9", "auth check missing a null guard") + assert comments == [("bd-9", "review requested changes: auth check missing a null guard")] + assert "CI failed" not in comments[0][1] # distinct from the ci-fail note + + +def test_record_review_bounce_rejects_a_non_in_review_state(make_board, monkeypatch): + b = make_board(Br()) + monkeypatch.setattr(b, "get_feature", lambda fid: {"id": fid, "board_state": "in_progress"}) + with pytest.raises(BoardError, match="expects in_review"): + b.record_review_bounce("bd-9", "x") + + +def test_requeue_preserves_the_open_pr_and_clears_the_assignee(make_board, monkeypatch): + """A requeue (the /ci + /review re-dispatch path) keeps the open PR — it never + touches external_ref — and clears the assignee so the re-pull can `--claim`.""" + br = Br() + b = make_board(br) + monkeypatch.setattr( + b, "get_feature", lambda fid: {"id": fid, "board_state": "ready", "pr_url": "https://example/pr/1"} + ) + f = b.requeue("bd-1") + call = next(c for c in br.calls if c and c[0] == "update") + assert "--external-ref" not in call # the open PR is left intact + assert "--assignee" in call # cleared (paired with "") so `--claim` won't reject + assert "--add-label" in call and "ready" in call and "in-review" in call # ready↑, in-review↓ + assert f["pr_url"] == "https://example/pr/1" # preserved onto the requeued feature with pytest.raises(BoardError, match="out of range"): BeadsBoard._resolve_plan_dep(-5, {0: "bd-1"}, {}) diff --git a/tests/test_tools.py b/tests/test_tools.py index 6d78ef6..bc952d3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -161,3 +161,159 @@ def test_create_tool_lands_the_normalized_source_label_end_to_end(make_board, mo assert out["id"] == "bd-1" update = next(c for c in calls if c and c[0] == "update") assert "--add-label" in update and "source:o/r#12" in update + + +# ── board_get_feature: the read half of a read-modify-write (bd-171) ───────────── + + +class _RoundTripStore: + """``update_feature`` writes fields onto an in-memory feature; ``get_feature`` + reads them back — so a board_update_feature → board_get_feature round-trip proves + the read tool surfaces exactly what the write tool stored.""" + + def __init__(self): + self.f = { + "id": "bd-1", + "title": "T", + "spec": "", + "acceptance_criteria": "", + "design": "", + "board_state": "backlog", + "labels": [], + "pr_url": "", + "difficulty": "", + "files_to_modify": [], + "foundation": False, + "priority": 2, + "source_issue": "", + "depends_on": [], + "open_depends_on": [], + } + + def update_feature( + self, + fid, + *, + spec=None, + acceptance_criteria=None, + design=None, + files_to_modify=None, + difficulty=None, + depends_on=None, + foundation=None, + source_issue=None, + ): + if spec is not None: + self.f["spec"] = spec + if acceptance_criteria is not None: + self.f["acceptance_criteria"] = acceptance_criteria + if design is not None: + self.f["design"] = design + if files_to_modify is not None: + self.f["files_to_modify"] = files_to_modify + if difficulty is not None: + self.f["difficulty"] = difficulty + return {"id": fid, "title": self.f["title"], "board_state": self.f["board_state"]} + + def get_feature(self, fid): + return dict(self.f) if fid == self.f["id"] else None + + +def test_get_feature_round_trips_values_written_by_update(monkeypatch): + fake = _RoundTripStore() + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: fake) + update = _get_tool("board_update_feature") + get = _get_tool("board_get_feature") + + update.invoke( + { + "feature_id": "bd-1", + "spec": "the new spec", + "acceptance_criteria": "WHEN x THE SYSTEM SHALL y", + "files_to_modify": "a.py, b.py", + "difficulty": "medium", + } + ) + out = json.loads(get.invoke({"feature_id": "bd-1"})) + + assert out["spec"] == "the new spec" + assert out["acceptance_criteria"] == "WHEN x THE SYSTEM SHALL y" + assert out["files_to_modify"] == ["a.py", "b.py"] # tool split → stored → read back + assert out["difficulty"] == "medium" + assert out["state"] == "backlog" # board_state surfaced as `state` + + +def test_get_feature_surfaces_both_dependency_views(monkeypatch): + """AC (bd-171 review-fix): a feature with one closed + one open blocker returns + BOTH in `depends_on` (the ledger) but only the open one in `open_depends_on`.""" + + class _S: + def get_feature(self, fid): + if fid != "bd-1": + return None + return { + "id": "bd-1", + "title": "T", + "spec": "s", + "acceptance_criteria": "ac", + "design": "", + "board_state": "ready", + "labels": ["ready"], + "pr_url": "", + "difficulty": "", + "files_to_modify": [], + "foundation": False, + "priority": 2, + "source_issue": "", + "depends_on": ["bd-a", "bd-b"], # every blocking edge (the ledger) + "open_depends_on": ["bd-b"], # only the still-open blocker + } + + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: _S()) + out = json.loads(_get_tool("board_get_feature").invoke({"feature_id": "bd-1"})) + assert out["depends_on"] == ["bd-a", "bd-b"] + assert out["open_depends_on"] == ["bd-b"] + + +def test_get_feature_end_to_end_through_the_projection(make_board, monkeypatch): + """board_get_feature → store.get_feature → _project: the closed/open blocker split + is computed by the REAL projection off a `br show` bead, not hand-fed by a fake.""" + bead = { + "id": "bd-1", + "title": "T", + "status": "open", + "labels": ["ready", "diff:medium"], + "description": "the spec", + "acceptance_criteria": "WHEN x THE SYSTEM SHALL y", + "external_ref": "https://example/pr/1", + "dependencies": [ + {"id": "bd-a", "dependency_type": "blocks", "status": "closed"}, + {"id": "bd-b", "dependency_type": "blocks", "status": "open"}, + ], + } + + def run_impl(*args, want_json=False): + if args and args[0] == "show": + return [bead] + return [] if want_json else "" + + b = make_board(run_impl) + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: b) + out = json.loads(_get_tool("board_get_feature").invoke({"feature_id": "bd-1"})) + + assert out["spec"] == "the spec" + assert out["state"] == "ready" + assert out["pr_url"] == "https://example/pr/1" + assert out["difficulty"] == "medium" + assert out["depends_on"] == ["bd-a", "bd-b"] # ledger + assert out["open_depends_on"] == ["bd-b"] # live subset + + +def test_get_feature_unknown_id_surfaces_a_named_error(monkeypatch): + class _S: + def get_feature(self, fid): + return None + + monkeypatch.setattr("project_board.store.get_store", lambda **_kw: _S()) + out = _get_tool("board_get_feature").invoke({"feature_id": "nope"}) + assert out.startswith("Error:") and "unknown feature" in out