diff --git a/tests/test_taosctl_decisions.py b/tests/test_taosctl_decisions.py new file mode 100644 index 00000000..c244b239 --- /dev/null +++ b/tests/test_taosctl_decisions.py @@ -0,0 +1,173 @@ +"""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_answer_object_like_value_stays_string(monkeypatch): + # 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_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", "[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): + 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_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"}]' + 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/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 new file mode 100644 index 00000000..b3bcddd2 --- /dev/null +++ b/tinyagentos/cli/taosctl/commands/decisions.py @@ -0,0 +1,141 @@ +"""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 tinyagentos.cli.taosctl.argtypes import json_array, positive_int + +NOUN = "decisions" + +# 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", "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", type=json_array, + 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 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, + "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; every other answer is the raw + 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] == "[": + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return raw + if isinstance(parsed, list): + return parsed + return raw