-
-
Notifications
You must be signed in to change notification settings - Fork 22
feat(taosctl): decisions command group #1413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
5e86fc3
cc0c500
9c098a2
432e114
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: |
||
|
|
||
|
|
||
| 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="<verb>") | ||
|
|
||
| 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: |
||
| 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 [] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| body = { | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| "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 ("[", "{"): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: |
||
| try: | ||
| return json.loads(text) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| except json.JSONDecodeError: | ||
| return raw | ||
| return raw | ||
|
Comment on lines
+125
to
+141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Edge Case: _coerce_value parses JSON objects, not just arrays, for any answer
Was this helpful? React with 👍 / 👎 |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: The comment on line 17 claims
_TYPES"Mirrorstinyagentos.decisions.decision_store", but the server'sDECISION_TYPESis("single_select", "multi_select", "approve_deny", "free_text")(free_text and approve_deny are swapped). Functionally harmless becausechoices=does a set comparison, but the comment is misleading. Either reorder to match the server (so a future maintainer greps in one place) or update the comment to say "same members as the server; order differs for --help readability".