diff --git a/tests/test_sidecall_verbose.py b/tests/test_sidecall_verbose.py new file mode 100644 index 0000000..252e7a2 --- /dev/null +++ b/tests/test_sidecall_verbose.py @@ -0,0 +1,75 @@ +"""Issue #131: with `"verbose": true` in the user's Claude Code settings, every side call +fails. Under `-p --output-format json`, a verbose Claude Code prints a JSON ARRAY of stream +events (`system`, `assistant`, ..., `result`) instead of the bare result object, so +`claude_call`'s `data.get("result")` raises `AttributeError: 'list' object has no attribute +'get'`. That escapes through the micro-skills proposer (`Distiller._maybe_propose` calls its +runner unguarded, unlike the loop guard's judge) and aborts the whole fold.""" +import json + +import pytest + + +def _verbose_array(result="OK", usage=None, model_usage=None): + """The shape `claude -p --output-format json` prints under the user's `verbose: true` + (Claude Code 2.1.318): one JSON array of events, the result its last element.""" + events = [{"type": "system", "subtype": "init", "session_id": "s1"}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": result}]}}] + if usage is not None: + events.append({"type": "result", "subtype": "success", "result": result, + "usage": usage, "modelUsage": model_usage or {}}) + return json.dumps(events) + + +def _fake_run(events_stdout, helps=" --no-session-persistence\n -p"): + """A `claude` whose side-call stdout is `events_stdout`, as the tests in test_live_core + fake it (the subprocess is faked, not the CLI).""" + import subprocess + from tokeneconomizer.plugin import sidecall + + def fake_run(cmd, **kw): + if cmd[1:] == ["--help"]: + return subprocess.CompletedProcess(cmd, 0, stdout=helps, stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout=events_stdout, stderr="") + return fake_run + + +@pytest.mark.real_sidecalls +def test_a_claude_side_call_reads_the_result_event_from_a_verbose_array(monkeypatch): + import subprocess + from tokeneconomizer.plugin import sidecall + monkeypatch.setattr(sidecall.subprocess, "run", + _fake_run(_verbose_array("OK", {"input_tokens": 10, "output_tokens": 1}, + {"claude-haiku-4-5": {}}))) + monkeypatch.setattr(sidecall, "_HELP", {}) + text, eff = sidecall.claude_call("Reply with exactly: OK", cli="new") + assert text == "OK" + assert eff > 0 + + +@pytest.mark.real_sidecalls +def test_a_claude_side_call_array_without_a_result_event_comes_back_empty(monkeypatch): + import subprocess + from tokeneconomizer.plugin import sidecall + monkeypatch.setattr(sidecall.subprocess, "run", + _fake_run(json.dumps([{"type": "system", "subtype": "init"}]))) + monkeypatch.setattr(sidecall, "_HELP", {}) + text, _eff = sidecall.claude_call("Reply with exactly: OK", cli="new") + assert text == "" + + +def test_a_failed_side_call_skips_the_proposal_instead_of_aborting_the_fold(): + """`_maybe_propose` calls its runner unguarded: one failed side call (a `verbose` array, + a timeout, a CLI that would not run) escaped and aborted the whole fold, so the session + stayed pending and was re-folded -- with another paid call -- on a later tick.""" + from tokeneconomizer.microskills.distiller import Distiller + + def broken(prompt, model): + raise AttributeError("'list' object has no attribute 'get'") + + d = Distiller("repo", broken, "haiku") + d.raw_window = [[("git status", "")]] + d.window = [{"iid": 1, "edited_files": [], "features": {}}] + d.now = 10 + d._maybe_propose() # must not raise + # ... and must not be retried on every tick: a failed paid call backs off one cadence + assert d.last_propose == 10 diff --git a/tokeneconomizer/microskills/distiller.py b/tokeneconomizer/microskills/distiller.py index a1532ef..817cab6 100644 --- a/tokeneconomizer/microskills/distiller.py +++ b/tokeneconomizer/microskills/distiller.py @@ -626,7 +626,15 @@ def _maybe_propose(self): {i: f.claim for i, f in self.facts.items()}, set(self.extractors), excerpts, external=external, generic=self.generic) - raw, eff = self.runner(prompt, self.model) + raw, eff = None, None + try: + raw, eff = self.runner(prompt, self.model) + except Exception: + # A failed side call (a `verbose` array, a timeout, a CLI that would not run) + # skips this proposal instead of aborting the fold, and backs off one cadence + # so a broken runner is not paid again every tick (#131). + self.last_propose = self.now + return if eff is not None: self.opt_tokens += eff exts, facts = parse_payload(raw) diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 626c207..f808b5a 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -219,6 +219,23 @@ def claude_no_mcp(cli, cwd): return ["--strict-mcp-config", "--mcp-config", path] +def _claude_json_result(stdout): + """One result object out of what `-p --output-format json` printed. With the user's + `verbose: true`, Claude Code prints a JSON ARRAY of stream events (`system`, + `assistant`, ..., `result`) instead of the bare result object, and the result is its + last element -- anything else it prints still parses as one object (#131).""" + try: + data = json.loads(stdout or "{}") + except ValueError: + return {} + if isinstance(data, list): + for ev in reversed(data): + if isinstance(ev, dict) and ev.get("type") == "result": + return ev + return {} + return data if isinstance(data, dict) else {} + + def claude_call(prompt, model="haiku", cli=None, timeout=TIMEOUT_S): cli = cli or claude_cli() with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: @@ -228,10 +245,7 @@ def claude_call(prompt, model="haiku", cli=None, timeout=TIMEOUT_S): out = subprocess.run(cmd, input=prompt, capture_output=True, encoding="utf-8", errors="replace", timeout=timeout, env=_env(), cwd=cwd) - try: - data = json.loads(out.stdout or "{}") - except ValueError: - data = {} + data = _claude_json_result(out.stdout) if not data.get("result"): _no_reply("claude", out) usage = dict(data.get("usage") or {})