From 5e86fc35500c7438465c1afc522f3a6a45b3fc3c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 23 Jun 2026 18:25:07 +0100 Subject: [PATCH 1/4] feat(taosctl): decisions command group (post, list, answer, history) The Decisions inbox backend (store + /api/decisions routes, L1 supersede, A2A answer routing) shipped in #1331/#1339/#1336, but had no taosctl surface. Decisions is agent-facing: agents post the choices they need and poll for answers between turns, so the CLI group is the natural driver. Wraps the five routes (post, list, get, history, answer) with status/project filters, positive-int limit validation, and JSON-array coercion for multi_select answers. Covered by unit tests and the static route-coverage gate. --- tests/test_taosctl_decisions.py | 130 +++++++++++++++++ tinyagentos/cli/taosctl/commands/decisions.py | 131 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 tests/test_taosctl_decisions.py create mode 100644 tinyagentos/cli/taosctl/commands/decisions.py diff --git a/tests/test_taosctl_decisions.py b/tests/test_taosctl_decisions.py new file mode 100644 index 00000000..e2476426 --- /dev/null +++ b/tests/test_taosctl_decisions.py @@ -0,0 +1,130 @@ +"""Tests for the taosctl decisions command group.""" +from __future__ import annotations + +import pytest + +from tinyagentos.cli.taosctl import client as cli_client +from tinyagentos.cli.taosctl import __main__ as cli_main + + +class _FakeClient: + def __init__(self, *a, **k): + self.calls = [] + self.base_url = "http://x" + self.token = "t" + self._raise = None + + def get(self, path, params=None): + self.calls.append(("GET", path, params)) + if self._raise: + raise self._raise + return {"items": []} + + def post(self, path, body=None, params=None, json=None): + self.calls.append(("POST", path, body)) + if self._raise: + raise self._raise + return {"id": "d1"} + + +def _run(monkeypatch, argv, fake): + monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake) + return cli_main.main(argv) + + +def test_list_default_sends_limit(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "list"], fake) + assert rc == 0 + assert ("GET", "/api/decisions", {"limit": 200}) in fake.calls + + +def test_list_filters_status_and_project(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "list", "--status", "pending", + "--project", "prj-1", "--limit", "5"], fake) + assert rc == 0 + assert ("GET", "/api/decisions", + {"limit": 5, "status": "pending", "project_id": "prj-1"}) in fake.calls + + +def test_list_rejects_nonpositive_limit(monkeypatch): + fake = _FakeClient() + with pytest.raises(SystemExit): + _run(monkeypatch, ["decisions", "list", "--limit", "0"], fake) + + +def test_get_calls_endpoint(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "get", "d9"], fake) + assert rc == 0 + assert ("GET", "/api/decisions/d9", None) in fake.calls + + +def test_history_calls_endpoint(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "history", "d9"], fake) + assert rc == 0 + assert ("GET", "/api/decisions/d9/history", None) in fake.calls + + +def test_answer_single_value_is_a_string(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", "approve"], fake) + assert rc == 0 + assert ("POST", "/api/decisions/d9/answer", {"value": "approve"}) in fake.calls + + +def test_answer_json_array_is_parsed(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", '["a","b"]', + "--answered-by", "jay"], fake) + assert rc == 0 + assert ("POST", "/api/decisions/d9/answer", + {"value": ["a", "b"], "answered_by": "jay"}) in fake.calls + + +def test_post_minimal_free_text(monkeypatch): + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "post", "--from-agent", "@taOS-dev", + "--question", "Which path?", "--type", "free_text"], fake) + assert rc == 0 + posted = [c for c in fake.calls if c[0] == "POST" and c[1] == "/api/decisions"] + assert posted, fake.calls + body = posted[0][2] + assert body["from_agent"] == "@taOS-dev" + assert body["type"] == "free_text" + assert body["options"] == [] + assert body["priority"] == "normal" + # Unset optional fields are omitted, not sent as None. + assert "project_id" not in body + + +def test_post_select_with_options_and_project(monkeypatch): + fake = _FakeClient() + opts = '[{"label":"A","value":"a","recommended":true,"rationale":"safest"}]' + rc = _run(monkeypatch, ["decisions", "post", "--from-agent", "@taOS-dev", + "--question", "Pick", "--type", "single_select", + "--options-json", opts, "--project", "prj-7", + "--priority", "blocking"], fake) + assert rc == 0 + body = [c for c in fake.calls if c[1] == "/api/decisions"][0][2] + assert body["options"][0]["value"] == "a" + assert body["project_id"] == "prj-7" + assert body["priority"] == "blocking" + + +def test_api_error_maps_to_exit_2(monkeypatch, capsys): + fake = _FakeClient() + fake._raise = cli_client.ApiError(404, "not found") + rc = _run(monkeypatch, ["decisions", "get", "nope"], fake) + assert rc == 2 + assert "not found" in capsys.readouterr().err + + +def test_transport_error_maps_to_exit_1(monkeypatch, capsys): + fake = _FakeClient() + fake._raise = cli_client.TransportError("cannot reach http://x: refused") + rc = _run(monkeypatch, ["decisions", "list"], fake) + assert rc == 1 + assert "cannot reach" in capsys.readouterr().err diff --git a/tinyagentos/cli/taosctl/commands/decisions.py b/tinyagentos/cli/taosctl/commands/decisions.py new file mode 100644 index 00000000..68327e9a --- /dev/null +++ b/tinyagentos/cli/taosctl/commands/decisions.py @@ -0,0 +1,131 @@ +"""taosctl decisions -- the human-in-the-loop decision inbox. + +Agents post the choices they need from the user (single/multi select, free text, +approve/deny), list what is pending, record an answer, and trace the L1 +supersession lineage of a decision. Thin wrapper over the /api/decisions routes; +@taOS-dev uses `list --status pending` to poll for answers between turns. +""" +from __future__ import annotations + +import json +from urllib.parse import quote + +from ..argtypes import positive_int + +NOUN = "decisions" + +# Mirrors tinyagentos.decisions.decision_store; kept inline so the CLI stays +# free of server imports (matching the other command groups). +_TYPES = ("single_select", "multi_select", "free_text", "approve_deny") +_PRIORITIES = ("normal", "blocking") +_STATUSES = ("pending", "answered", "expired", "superseded") + + +def register(subparsers) -> None: + p = subparsers.add_parser(NOUN, help="Post, list, answer, and trace decisions") + verbs = p.add_subparsers(dest="verb", required=True, metavar="") + + lp = verbs.add_parser("list", help="List decisions (filter by status/project)") + lp.add_argument("--status", choices=list(_STATUSES), help="Filter by status") + lp.add_argument("--project", dest="project_id", help="Filter by project id") + lp.add_argument("--limit", type=positive_int, default=200, help="Max rows (default 200)") + lp.set_defaults(func=_list) + + gp = verbs.add_parser("get", help="Get one decision by id") + gp.add_argument("decision_id", help="Decision id") + gp.set_defaults(func=_get) + + hp = verbs.add_parser("history", help="Show the supersession lineage (oldest first)") + hp.add_argument("decision_id", help="Decision id") + hp.set_defaults(func=_history) + + ap = verbs.add_parser("answer", help="Record an answer for a decision") + ap.add_argument("decision_id", help="Decision id") + ap.add_argument("--value", required=True, + help="Answer value. For multi_select pass a JSON array, " + 'e.g. \'["a","b"]\'') + ap.add_argument("--answered-by", dest="answered_by", + help="Who answered (defaults to the caller)") + ap.set_defaults(func=_answer) + + pp = verbs.add_parser("post", help="Post a new decision to the inbox") + pp.add_argument("--from-agent", dest="from_agent", required=True, + help="Asking agent, e.g. @taOS-dev") + pp.add_argument("--question", required=True, help="The decision question") + pp.add_argument("--type", required=True, choices=list(_TYPES), help="Decision type") + pp.add_argument("--context", default="", help="Supporting detail / the why") + pp.add_argument("--priority", choices=list(_PRIORITIES), default="normal", + help="normal | blocking") + pp.add_argument("--project", dest="project_id", help="Project id this decision is scoped to") + pp.add_argument("--deadline", type=float, help="Optional deadline (epoch seconds)") + pp.add_argument("--parent", dest="parent_decision_id", + help="Parent decision id this one supersedes (L1)") + pp.add_argument("--options-json", dest="options_json", + help="Options as a JSON array of " + "{label,value,recommended,rationale} (select types)") + pp.add_argument("--checkpoint-ref", dest="checkpoint_ref", + help="State pointer the decision is made against (e.g. a git commit)") + pp.add_argument("--timeline-id", dest="timeline_id", help="Timeline/branch this belongs to") + pp.set_defaults(func=_post) + + +def _list(args, client): + params = {"limit": args.limit} + if args.status: + params["status"] = args.status + if args.project_id: + params["project_id"] = args.project_id + return client.get("/api/decisions", params=params) + + +def _get(args, client): + return client.get(f"/api/decisions/{quote(str(args.decision_id), safe='')}") + + +def _history(args, client): + return client.get(f"/api/decisions/{quote(str(args.decision_id), safe='')}/history") + + +def _answer(args, client): + value = _coerce_value(args.value) + body = {"value": value} + if args.answered_by: + body["answered_by"] = args.answered_by + return client.post( + f"/api/decisions/{quote(str(args.decision_id), safe='')}/answer", body=body + ) + + +def _post(args, client): + options = json.loads(args.options_json) if args.options_json else [] + body = { + "from_agent": args.from_agent, + "question": args.question, + "type": args.type, + "options": options, + "context": args.context, + "priority": args.priority, + } + for key, val in ( + ("project_id", args.project_id), + ("deadline", args.deadline), + ("parent_decision_id", args.parent_decision_id), + ("checkpoint_ref", args.checkpoint_ref), + ("timeline_id", args.timeline_id), + ): + if val is not None: + body[key] = val + return client.post("/api/decisions", body=body) + + +def _coerce_value(raw: str): + """A multi_select answer is a JSON array; everything else is the raw string. + Parse only when the value looks like JSON so a plain string answer that + happens to contain a bracket is not mangled.""" + text = raw.strip() + if text[:1] in ("[", "{"): + try: + return json.loads(text) + except json.JSONDecodeError: + return raw + return raw From cc0c5009d50e511ed1978a7d9b6316829d0e9b0b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 23 Jun 2026 18:35:47 +0100 Subject: [PATCH 2/4] fix(taosctl): fold review findings on the decisions group - --options-json: validate as a JSON array at parse time via a new argtypes.json_array, so malformed or non-array input is a clean CLI error instead of an uncaught json.JSONDecodeError traceback (gitar Edge-Case; kilo + coderabbit concur). - _coerce_value: only coerce a clearly-bracketed array ([...]); a brace-prefixed or otherwise non-array answer stays a literal string, and a bracketed-but- malformed value surfaces as a clean exit-1 error rather than being silently forwarded as a string (kilo + gitar). - _STATUSES: drop 'expired' -- the store never writes it, so offering it as a filter would just return a silently-empty inbox (kilo footgun). - Correct the _TYPES mirror comment (kilo). --- tests/test_taosctl_decisions.py | 41 +++++++++++++++++++ tinyagentos/cli/taosctl/argtypes.py | 14 +++++++ tinyagentos/cli/taosctl/commands/decisions.py | 31 ++++++++------ 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/tests/test_taosctl_decisions.py b/tests/test_taosctl_decisions.py index e2476426..6e50116f 100644 --- a/tests/test_taosctl_decisions.py +++ b/tests/test_taosctl_decisions.py @@ -84,6 +84,23 @@ def test_answer_json_array_is_parsed(monkeypatch): {"value": ["a", "b"], "answered_by": "jay"}) in fake.calls +def test_answer_object_like_value_stays_string(monkeypatch): + # Only a clearly-bracketed array is coerced; a brace-prefixed answer is a + # literal string (multi_select is the only JSON-array case). + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", '{"x":1}'], fake) + assert rc == 0 + assert ("POST", "/api/decisions/d9/answer", {"value": '{"x":1}'}) in fake.calls + + +def test_answer_malformed_array_maps_to_exit_1(monkeypatch, capsys): + # A bracketed-but-invalid value is a usage error, surfaced cleanly. + fake = _FakeClient() + rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", '["a" "b"]'], fake) + assert rc == 1 + assert "JSON array" in capsys.readouterr().err + + def test_post_minimal_free_text(monkeypatch): fake = _FakeClient() rc = _run(monkeypatch, ["decisions", "post", "--from-agent", "@taOS-dev", @@ -100,6 +117,30 @@ def test_post_minimal_free_text(monkeypatch): assert "project_id" not in body +def test_post_rejects_malformed_options_json(monkeypatch): + # json_array argtype rejects bad JSON at parse time (clean error, no traceback). + fake = _FakeClient() + with pytest.raises(SystemExit): + _run(monkeypatch, ["decisions", "post", "--from-agent", "@taOS-dev", + "--question", "Pick", "--type", "single_select", + "--options-json", '[{"label":"A"'], fake) + + +def test_post_rejects_non_array_options_json(monkeypatch): + fake = _FakeClient() + with pytest.raises(SystemExit): + _run(monkeypatch, ["decisions", "post", "--from-agent", "@taOS-dev", + "--question", "Pick", "--type", "single_select", + "--options-json", '{"label":"A"}'], fake) + + +def test_list_rejects_unknown_status(monkeypatch): + # `expired` is not a status the store writes, so it is not an allowed filter. + fake = _FakeClient() + with pytest.raises(SystemExit): + _run(monkeypatch, ["decisions", "list", "--status", "expired"], fake) + + def test_post_select_with_options_and_project(monkeypatch): fake = _FakeClient() opts = '[{"label":"A","value":"a","recommended":true,"rationale":"safest"}]' diff --git a/tinyagentos/cli/taosctl/argtypes.py b/tinyagentos/cli/taosctl/argtypes.py index 7e7250ea..6b106028 100644 --- a/tinyagentos/cli/taosctl/argtypes.py +++ b/tinyagentos/cli/taosctl/argtypes.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import json def positive_int(value: str) -> int: @@ -24,3 +25,16 @@ def nonneg_int(value: str) -> int: if iv < 0: raise argparse.ArgumentTypeError("must be a non-negative integer") return iv + + +def json_array(value: str) -> list: + """A JSON array argument (e.g. a decision's options list). Rejects malformed + JSON and non-array JSON at parse time so the handler receives a guaranteed + list and the user gets a clean ``error:`` line instead of a traceback.""" + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(f"not valid JSON: {exc}") + if not isinstance(parsed, list): + raise argparse.ArgumentTypeError("expected a JSON array") + return parsed diff --git a/tinyagentos/cli/taosctl/commands/decisions.py b/tinyagentos/cli/taosctl/commands/decisions.py index 68327e9a..b548ba6f 100644 --- a/tinyagentos/cli/taosctl/commands/decisions.py +++ b/tinyagentos/cli/taosctl/commands/decisions.py @@ -10,15 +10,19 @@ import json from urllib.parse import quote -from ..argtypes import positive_int +from ..argtypes import json_array, positive_int +from ..client import TransportError NOUN = "decisions" -# Mirrors tinyagentos.decisions.decision_store; kept inline so the CLI stays -# free of server imports (matching the other command groups). +# Same members as the server (tinyagentos.decisions.decision_store); order +# differs for --help readability. Kept inline so the CLI stays free of server +# imports (matching the other command groups). `_STATUSES` lists only what the +# store actually writes -- `expired` is reserved in the spec but no sweeper sets +# it yet, so exposing it as a filter would just return a silently-empty inbox. _TYPES = ("single_select", "multi_select", "free_text", "approve_deny") _PRIORITIES = ("normal", "blocking") -_STATUSES = ("pending", "answered", "expired", "superseded") +_STATUSES = ("pending", "answered", "superseded") def register(subparsers) -> None: @@ -60,7 +64,7 @@ def register(subparsers) -> None: pp.add_argument("--deadline", type=float, help="Optional deadline (epoch seconds)") pp.add_argument("--parent", dest="parent_decision_id", help="Parent decision id this one supersedes (L1)") - pp.add_argument("--options-json", dest="options_json", + pp.add_argument("--options-json", dest="options_json", type=json_array, help="Options as a JSON array of " "{label,value,recommended,rationale} (select types)") pp.add_argument("--checkpoint-ref", dest="checkpoint_ref", @@ -97,7 +101,8 @@ def _answer(args, client): def _post(args, client): - options = json.loads(args.options_json) if args.options_json else [] + # --options-json is parsed + validated to a list at argparse time (json_array). + options = args.options_json or [] body = { "from_agent": args.from_agent, "question": args.question, @@ -119,13 +124,15 @@ def _post(args, client): def _coerce_value(raw: str): - """A multi_select answer is a JSON array; everything else is the raw string. - Parse only when the value looks like JSON so a plain string answer that - happens to contain a bracket is not mangled.""" + """A multi_select answer is a JSON array; every other answer is the raw + string. Only a clearly-bracketed array ([...]) is parsed, so a free-text + answer that merely starts with a bracket (or is a JSON object) is left + untouched. A bracketed-but-malformed value is a usage error, surfaced + cleanly (exit 1) rather than silently forwarded to the server as a string.""" text = raw.strip() - if text[:1] in ("[", "{"): + if text[:1] == "[" and text[-1:] == "]": try: return json.loads(text) - except json.JSONDecodeError: - return raw + except json.JSONDecodeError as exc: + raise TransportError(f"--value looks like a JSON array but is invalid: {exc}") return raw From 9c098a28ab8e6093e066692daba35877cdcc2108 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 23 Jun 2026 18:40:08 +0100 Subject: [PATCH 3/4] fix(taosctl): keep bracketed free-text answers as literal strings My previous _coerce_value tightening hard-errored on a legitimate free_text answer like '[hello world]' (looks bracketed, not valid JSON). The CLI cannot see the decision type, so coerce only when the value parses as a JSON list; non-JSON or non-list values pass through as the literal string and a malformed multi_select array is rejected server-side against the options. Drops the now unused TransportError import. (kilo review on #1413.) --- tests/test_taosctl_decisions.py | 16 +++++++------- tinyagentos/cli/taosctl/commands/decisions.py | 21 +++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/tests/test_taosctl_decisions.py b/tests/test_taosctl_decisions.py index 6e50116f..c244b239 100644 --- a/tests/test_taosctl_decisions.py +++ b/tests/test_taosctl_decisions.py @@ -85,20 +85,22 @@ def test_answer_json_array_is_parsed(monkeypatch): def test_answer_object_like_value_stays_string(monkeypatch): - # Only a clearly-bracketed array is coerced; a brace-prefixed answer is a - # literal string (multi_select is the only JSON-array case). + # Only a value that parses to a JSON list is coerced; a JSON object parses + # but is not a list, so it stays the literal string. fake = _FakeClient() rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", '{"x":1}'], fake) assert rc == 0 assert ("POST", "/api/decisions/d9/answer", {"value": '{"x":1}'}) in fake.calls -def test_answer_malformed_array_maps_to_exit_1(monkeypatch, capsys): - # A bracketed-but-invalid value is a usage error, surfaced cleanly. +def test_answer_bracketed_free_text_stays_string(monkeypatch): + # A free-text answer that merely looks bracketed ("[hello world]") is not + # valid JSON, so it is forwarded as the literal string, not an error. fake = _FakeClient() - rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", '["a" "b"]'], fake) - assert rc == 1 - assert "JSON array" in capsys.readouterr().err + rc = _run(monkeypatch, ["decisions", "answer", "d9", "--value", "[hello world]"], fake) + assert rc == 0 + assert ("POST", "/api/decisions/d9/answer", + {"value": "[hello world]"}) in fake.calls def test_post_minimal_free_text(monkeypatch): diff --git a/tinyagentos/cli/taosctl/commands/decisions.py b/tinyagentos/cli/taosctl/commands/decisions.py index b548ba6f..878ef7c2 100644 --- a/tinyagentos/cli/taosctl/commands/decisions.py +++ b/tinyagentos/cli/taosctl/commands/decisions.py @@ -11,7 +11,6 @@ from urllib.parse import quote from ..argtypes import json_array, positive_int -from ..client import TransportError NOUN = "decisions" @@ -125,14 +124,18 @@ def _post(args, client): def _coerce_value(raw: str): """A multi_select answer is a JSON array; every other answer is the raw - string. Only a clearly-bracketed array ([...]) is parsed, so a free-text - answer that merely starts with a bracket (or is a JSON object) is left - untouched. A bracketed-but-malformed value is a usage error, surfaced - cleanly (exit 1) rather than silently forwarded to the server as a string.""" + string. The value is coerced only when it parses as a JSON *list*: anything + that is not valid JSON (e.g. the free-text answer "[hello world]") or that + parses to a non-list (a JSON object, a number) is kept as the literal string. + The CLI cannot see the decision type, so a malformed multi_select array is + forwarded as a string and rejected server-side against the declared options + rather than guessed at here.""" text = raw.strip() - if text[:1] == "[" and text[-1:] == "]": + if text[:1] == "[": try: - return json.loads(text) - except json.JSONDecodeError as exc: - raise TransportError(f"--value looks like a JSON array but is invalid: {exc}") + parsed = json.loads(text) + except json.JSONDecodeError: + return raw + if isinstance(parsed, list): + return parsed return raw From 432e114ffbab88df93fe21aa7adef18591bb1b47 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 23 Jun 2026 18:45:57 +0100 Subject: [PATCH 4/4] style(taosctl): use absolute argtypes import in decisions group Align with the convention used by the 11 other command modules (gitar Quality nit on the sibling observatory PR; same divergence here). --- tinyagentos/cli/taosctl/commands/decisions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinyagentos/cli/taosctl/commands/decisions.py b/tinyagentos/cli/taosctl/commands/decisions.py index 878ef7c2..b3bcddd2 100644 --- a/tinyagentos/cli/taosctl/commands/decisions.py +++ b/tinyagentos/cli/taosctl/commands/decisions.py @@ -10,7 +10,7 @@ import json from urllib.parse import quote -from ..argtypes import json_array, positive_int +from tinyagentos.cli.taosctl.argtypes import json_array, positive_int NOUN = "decisions"