Files
turnstone/tests/test_coordinator_tools.py
T
Patrick Buckley 8650370790 Feat/coordinator phase2 audit (#369)
* feat(coordinator): audit middleware on routing proxy — phase 2

Adds per-tool-call audit attribution to the multi-node routing proxy
handlers so coordinator → server hops land observable rows in
``audit_events``.  Phase 1 preserved the ``src="coordinator"`` claim
through ``_proxy_auth_headers``'s upstream re-mint; this commit
closes the recording side.  Was the last real security gap from
phase 1 — an enterprise deployment with ``admin.coordinator``
granted got only the three console-side
``coordinator.{create,close,cancel}`` rows; per-tool-call
attribution was missing.

## Action-naming scheme

  route.workstream.create   POST /v1/api/route/workstreams/new
  route.workstream.send     POST /v1/api/route/send
  route.workstream.close    POST /v1/api/route/workstreams/close
  route.workstream.delete   POST /v1/api/route/workstreams/delete
  route.approve             POST /v1/api/route/approve
  route.cancel              POST /v1/api/route/cancel
  route.command             POST /v1/api/route/command
  route.plan                POST /v1/api/route/plan

Action-name conventions documented in ``turnstone/core/audit.py``
module docstring alongside the existing namespaces — the docstring
is now ``<resource>.<verb>`` shaped (non-exhaustive) rather than
trying to enumerate every prefix.

## Recording rules

- ``record_audit()`` fires only on a 2xx upstream response.  4xx/5xx
  are observable via ``_record_route``'s metrics path; doubling the
  audit-events table size for failure rows would dilute signal
  without giving operators much extra value.
- ``detail`` JSON carries ``{src, node_id, coord_ws_id?}`` — ``src``
  lands verbatim from ``auth.token_source`` so non-coordinator
  origins (``"jwt"``, ``"console-proxy"``) also get attribution;
  ``coord_ws_id`` only appears when the inbound JWT carried it.
- Wrapped in ``try/except`` + ``log.debug("route.audit_failed", ...)``
  defence-in-depth.  ``record_audit`` itself is fire-and-forget;
  the outer try guards against a programmer error in the call site.

## Routing-proxy specifics

- ``route_create``: emits at the post-multipart/JSON convergence
  ``if resp.status_code == 200`` block.  Both branches set
  ``audit_ws_id`` correctly — multipart from the query-string ws_id,
  JSON from ``body["ws_id"]`` (post-503-retry) or ``body["resume_ws"]``.
- ``route_proxy``: emits the URL-method-mapped action.  ``ref`` is
  reassigned to ``new_ref`` after a successful 404→cache-refresh
  retry so audit attribution uses the retried node, not the failed
  first node.
- ``route_workstream_delete``: emits on 2xx using the ws_id from
  the request body.
- ``route_attachment_proxy``: out of scope (upstream attachment
  endpoints emit their own ``workstream.attachment.*`` rows;
  auditing here would double-count).

## Tests

16 new tests in ``tests/test_route_proxy_audit.py`` covering:
- Coordinator-origin emission with full detail payload.
- 502 / 400 / 503-retry-final-node-id paths.
- Parametrised method→action mapping for the 6 ``route_proxy`` URLs.
- Plain-JWT origin (no ``coord_ws_id`` in detail).
- Delete handler 2xx + 502.
- Audit-storage exception swallowed (proxied response unchanged).
- ``auth_storage`` absent → no-op (existing route-handler tests
  unaffected).

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4087 passed,
3 deselected (live-backend), 0 failed.

* feat(coordinator): discovery tools and /open parity — list_nodes, list_skills, POST /coordinator/{ws_id}/open

Adds the read-side surface coordinators need to make informed
orchestration decisions plus an explicit rehydration endpoint
matching the server's ``POST /v1/api/workstreams/{ws_id}/open``.

## list_nodes (auto-approved)

``list_nodes(filters={key: value, ...})`` reads ``node_metadata`` via
``storage.filter_nodes_by_metadata`` + ``get_all_node_metadata`` —
one query each, no N+1.  Each row carries its full metadata dict so
the coordinator has both auto keys (``arch`` / ``cpu_count`` /
``fqdn`` / ``hostname`` / ``os`` / ``os_release`` / ``python``;
always present) and operator-supplied user keys (``capability`` /
``region`` / ``tenant`` / ``role``) without a second round-trip.
Tool description enumerates the auto keys explicitly so the model
knows what's always available vs deployment-specific.

Storage stores metadata values as JSON-encoded strings (the write
path in ``server.py`` / ``admin.py`` / ``console/server.py`` all go
through ``json.dumps``).  The client re-encodes filter values
before the stored-text comparison and decodes stored values before
returning them to the model — so ``{"capability": "gpu"}`` is the
natural form the model uses, not ``{"capability": "\"gpu\""}``.
Ints round-trip as ints.

Returns ``{nodes, truncated}``; ``truncated=True`` when the page
was full.

## list_skills (auto-approved)

``list_skills(category?, tag?, scan_status?, enabled_only?, limit?)``
surfaces the skill registry so coordinators can discover worker
profiles.  New storage protocol method ``list_skills_filtered(...)``
on both SQLite and PostgreSQL backends pushes filters into SQL.
``tag`` filter matches against the JSON-array ``tags`` column with
quote-bracketed substring (``%"tag"%``) — quote-safe against
``foo`` vs ``foobar`` collisions on both backends.

Returns ``{skills, truncated}`` with ``name`` / ``category`` /
``tags`` (decoded to list) / ``version`` / ``description`` /
``model`` / ``enabled`` / ``scan_status`` / ``activation`` — the
discovery projection, not the full row.

## POST /v1/api/coordinator/{ws_id}/open

Explicit rehydration endpoint.  Lazy ``GET`` rehydration works for
the UI; this gives SDK callers and operators a way to warm a
coordinator without browsing to it.  Same ownership / 404-on-
mismatch / correlation-id-masked error semantics as
``coordinator_detail``.  Returns ``{ws_id, name, already_loaded?}``.
Registered in ``turnstone/api/console_spec.py`` with a dedicated
``CoordinatorOpenResponse`` Pydantic model so the OpenAPI schema
matches the wire shape.

## Tests

- ``tests/test_storage_skills_filtered.py`` — 8 cases validated on
  BOTH SQLite and PostgreSQL backends (``pytest --storage-backend
  postgresql``).  Covers no-filter ordering, category exact-match,
  tag quote-safety (``"foo"`` matches ``["foo","bar"]`` but not
  ``["foobar"]``), scan_status, enabled_only, limit, AND semantics,
  empty result.
- ``tests/test_coordinator_client.py`` — 11 new cases covering
  node/skill shape decoding, JSON-encoded filter round-trip (the
  ``"gpu"`` vs ``'"gpu"'`` case), int filter encoding, truncation,
  no-match empty, no N+1 (``get_prompt_template`` /
  ``get_node_metadata`` call counts asserted zero).
- ``tests/test_coordinator_tools.py`` — 11 new cases for
  ``_prepare``/``_exec`` dispatch, filter type-drop, limit clamping
  (``limit=0`` falls back to 100, negatives clamp to 1),
  truncation-signal summary.
- ``tests/test_coordinator_endpoints.py`` — 8 new cases for
  ``/open``: ``already_loaded`` on in-memory hit, 404 on ownership
  mismatch, lazy rehydrate on miss, admin bypass, unknown ws_id,
  503 on ``coord_mgr`` unavailable, 500 with correlation-id mask on
  factory failure, 503 passthrough on ``ValueError``.
- ``tests/test_workstream_kind.py`` / ``test_tools_schema.py``
  updated to include ``list_nodes`` and ``list_skills`` in the
  disjoint-namespace regression guard and the tool-count check.

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4122 passed, 3
deselected (live-backend), 0 failed.  Postgres backend storage
tests green (``pytest --storage-backend postgresql
tests/test_storage_skills_filtered.py`` 8 passed).

* feat(coordinator): task_list tool — persistent planning state

Adds a coordinator-only ``task_list`` tool persisted on the
coordinator's own ``workstream_config`` row.  Gives coordinators a
scratch surface for work decomposition that survives restarts so the
UI can render planned-vs-done state once the tree view lands.

## Tool surface

``task_list(action, ...)`` with five actions:

- ``list``     auto-approved read.  Returns ``{tasks, truncated}``;
               truncated=True when the list exceeded the 200-row
               page cap.
- ``add``      needs approval.  ``title`` required; optional
               ``status`` and ``child_ws_id``.  Title clamped at
               200 chars.  Capacity cap at 500 tasks — hitting the
               cap is an explicit signal to prune done/blocked rows.
- ``update``   needs approval.  Mutate by ``task_id``; fields
               ``title`` / ``status`` / ``child_ws_id`` optional.
- ``remove``   needs approval.  Drop by ``task_id``.
- ``reorder``  needs approval.  Pass ``task_ids``; validated as an
               exact permutation of the current set (rejects
               partial, extra, or substituted ids — prevents silent
               task loss).

Status enum: ``pending`` / ``in_progress`` / ``done`` / ``blocked``.
``child_ws_id`` links a task to the child workstream spawned for it
(no enforcement; the coordinator owns the relationship).

## Persistence

Stored as a single JSON-envelope value on ``workstream_config`` —
``{"version": 1, "tasks": [...]}``.  No new table; the kanban v2
work will supersede this row via a format migration keyed on
``version``.  ``_save_task_list`` writes only the ``tasks`` key so
concurrent writers to other ``workstream_config`` keys (e.g. the
admin Settings UI updating ``reasoning_effort``) aren't clobbered
by a read-modify-write on the full row.

## Corrupt-envelope safety

A hand-edited or legacy config row that doesn't parse as the
expected shape logs a warning and returns an empty envelope from
``task_list_get``.  Mutators refuse to overwrite corrupt data —
they detect the sentinel and return a clear error so the operator
can inspect or clear the row rather than losing work silently.

## Concurrency

Per-(ws) ``threading.Lock`` cached on the client.  The worker
thread is single-threaded for tool execs so this is mostly
defence-in-depth against future maintenance-script call sites.
Cache never grows beyond one entry per coordinator session because
the scope guard short-circuits foreign ``ws_id`` before the lock
is acquired.

## Malformed-JSON recovery

``_prepare_tool`` fallback-1 regex-extract allowlist extended with
``action`` / ``status`` / ``task_id`` / ``title`` (alphabetized) so
slightly-malformed ``task_list`` calls get the same
self-correction behaviour as the other coordinator tools.

## Tests

- ``tests/test_coordinator_client.py`` — 15 new cases covering:
  fresh-envelope shape, add/get roundtrip, empty-title + invalid-
  status rejection, 200-char title clamp, update by id + missing
  id, remove semantics, reorder permutation validation (partial +
  extra + wrong id + valid), cross-ws scope violation, corrupt-
  JSON read recovery, corrupt-envelope write refusal (all four
  mutators), 500-task capacity cap, workstream_config key
  preservation across ``_save_task_list``.
- ``tests/test_coordinator_tools.py`` — 12 new cases covering the
  dispatch layer: list auto-approved, each mutating action needs
  approval, unknown-action / missing-required-arg errors, list
  returns tasks, page-cap at 200 with truncated signal, add
  dispatches to client, reorder surfaces permutation error,
  remove-not-found.
- ``tests/test_tools_schema.py`` / ``tests/test_workstream_kind.py``
  extend the tool-count + disjoint-namespace + primary-key
  regression guards with ``task_list``.

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4148 passed,
3 deselected (live-backend), 0 failed.
2026-04-16 23:44:28 -07:00

772 lines
28 KiB
Python

"""Tests for the coordinator prepare/exec dispatch on ChatSession.
We construct a ChatSession with ``kind="coordinator"`` and a mocked
``CoordinatorClient``, then drive ``_prepare_tool`` directly with tool
call dicts matching the shape the provider layer produces. This is a
unit-level test of the dispatch plumbing — end-to-end flows land in
Phase D's test_coordinator_end_to_end.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from turnstone.core.session import ChatSession
from turnstone.prompts import ClientType
class _StubUI:
"""Minimal SessionUI that records signals without doing anything with them."""
def __init__(self) -> None:
self._user_id = "user-1"
self.infos: list[str] = []
self.errors: list[str] = []
self.tool_results: list[tuple[str, str, str, bool]] = []
def on_info(self, msg: str) -> None:
self.infos.append(msg)
def on_error(self, msg: str) -> None:
self.errors.append(msg)
def on_tool_result(self, call_id: str, name: str, output: str, is_error: bool = False) -> None:
self.tool_results.append((call_id, name, output, is_error))
# Other SessionUI methods — only stubs, not exercised here.
def on_turn_start(self) -> None:
pass
def on_turn_end(self) -> None:
pass
def on_stream_start(self) -> None:
pass
def on_stream_end(self) -> None:
pass
def on_message_delta(self, delta: str) -> None:
pass
def on_reasoning_delta(self, delta: str) -> None:
pass
def on_tool_call(self, call_id: str, name: str, header: str, preview: str) -> None:
pass
def on_completion(self, content: str) -> None:
pass
def on_attention(self, header: str, preview: str = "") -> None:
pass
def wait_for_approval(
self,
call_id: str,
name: str,
header: str,
preview: str,
*,
label: str = "",
) -> tuple[bool, str | None]:
return True, None
@pytest.fixture
def coord_session(monkeypatch):
"""Build a coordinator ChatSession with a mocked CoordinatorClient.
Patches heavyweight init steps (_load_skills, _init_system_messages,
_save_config) to keep the test fast + isolated from the storage
registry.
"""
monkeypatch.setattr(ChatSession, "_load_skills", lambda self: None)
monkeypatch.setattr(ChatSession, "_init_system_messages", lambda self: None)
monkeypatch.setattr(ChatSession, "_save_config", lambda self: None)
ui = _StubUI()
coord_client = MagicMock()
sess = ChatSession(
client=MagicMock(),
model="gpt-test",
ui=ui, # type: ignore[arg-type]
instructions=None,
temperature=0.0,
max_tokens=1024,
tool_timeout=30,
context_window=16384,
ws_id="coord-1",
user_id="user-1",
client_type=ClientType.WEB,
kind="coordinator",
coord_client=coord_client,
)
return sess, coord_client, ui
# ---------------------------------------------------------------------------
# Tool set shape
# ---------------------------------------------------------------------------
def test_coordinator_session_uses_coordinator_tools(coord_session):
sess, _coord, _ui = coord_session
names = {t["function"]["name"] for t in sess._tools}
assert names == {
"spawn_workstream",
"inspect_workstream",
"send_to_workstream",
"close_workstream",
"delete_workstream",
"list_workstreams",
"list_nodes",
"list_skills",
"task_list",
}
# Sub-agent tool sets are zeroed on coordinator sessions.
assert sess._task_tools == []
assert sess._agent_tools == []
# ---------------------------------------------------------------------------
# Helper: build a ChatCompletion-style tool_call dict
# ---------------------------------------------------------------------------
def _tc(name: str, args: dict[str, Any], call_id: str = "call-1") -> dict[str, Any]:
return {
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": json.dumps(args)},
}
# ---------------------------------------------------------------------------
# spawn_workstream
# ---------------------------------------------------------------------------
def test_spawn_prepare_allows_empty_initial_message(coord_session):
"""Empty initial_message creates an idle child — matches tool JSON advertisement."""
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": ""}))
assert "error" not in item
assert item["needs_approval"] is True
assert "idle workstream" in item["header"]
assert item["initial_message"] == ""
def test_spawn_prepare_needs_approval(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc("spawn_workstream", {"initial_message": "do a thing", "skill": "s"})
)
assert item["needs_approval"] is True
assert item["execute"].__func__ is ChatSession._exec_spawn_workstream
assert item["skill"] == "s"
def test_spawn_exec_calls_client_and_returns_summary(coord_session):
sess, coord, _ui = coord_session
coord.spawn.return_value = {
"ws_id": "child-7",
"name": "c",
"node_id": "node-1",
"status": 200,
}
item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"}))
call_id, output = sess._exec_spawn_workstream(item)
coord.spawn.assert_called_once()
_, kwargs = coord.spawn.call_args
assert kwargs["parent_ws_id"] == "coord-1"
assert kwargs["user_id"] == "user-1"
assert kwargs["initial_message"] == "hi"
assert call_id == "call-1"
assert "child-7" in output
def test_spawn_exec_surfaces_client_error(coord_session):
sess, coord, ui = coord_session
coord.spawn.return_value = {"error": "upstream unreachable", "status": 502}
item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"}))
_call_id, output = sess._exec_spawn_workstream(item)
assert "upstream unreachable" in output
# UI got an error result
assert ui.tool_results[-1][3] is True # is_error
# ---------------------------------------------------------------------------
# inspect_workstream
# ---------------------------------------------------------------------------
def test_inspect_prepare_is_auto_approved(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "child-x", "message_limit": 5}))
assert item["needs_approval"] is False
assert item["execute"].__func__ is ChatSession._exec_inspect_workstream
assert item["message_limit"] == 5
def test_inspect_prepare_requires_ws_id(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("inspect_workstream", {}))
assert "error" in item
def test_inspect_prepare_clamps_message_limit(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "x", "message_limit": 10000}))
assert item["message_limit"] == 200 # clamped
def test_inspect_exec_dispatches_to_client(coord_session):
sess, coord, _ui = coord_session
coord.inspect.return_value = {
"ws_id": "child-x",
"state": "idle",
"messages": [],
"verdicts": [],
}
item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "child-x"}))
_call_id, output = sess._exec_inspect_workstream(item)
coord.inspect.assert_called_once_with("child-x", message_limit=20)
assert "child-x" in output
# ---------------------------------------------------------------------------
# send_to_workstream
# ---------------------------------------------------------------------------
def test_send_prepare_needs_approval(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": "hello"}))
assert item["needs_approval"] is True
def test_send_prepare_rejects_empty_message(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": ""}))
assert "error" in item
def test_send_exec_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.send.return_value = {"status": 200}
item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": "hi"}))
_call_id, output = sess._exec_send_to_workstream(item)
coord.send.assert_called_once_with("x", "hi")
assert "x" in output
# ---------------------------------------------------------------------------
# close_workstream
# ---------------------------------------------------------------------------
def test_close_prepare_needs_approval(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x"}))
assert item["needs_approval"] is True
def test_close_exec_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.close_workstream.return_value = {"status": 200}
item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x"}))
_call_id, output = sess._exec_close_workstream(item)
# Default (no reason) — kwargs carry empty reason through the call.
coord.close_workstream.assert_called_once_with("x", reason="")
parsed = json.loads(output)
assert parsed["closed"] is True
assert "reason" not in parsed # omitted when empty
def test_close_exec_forwards_reason(coord_session):
"""reason is wired through both CoordinatorClient.close_workstream
and the tool-result payload so the coordinator's message stream
records why the close happened."""
sess, coord, _ui = coord_session
coord.close_workstream.return_value = {"status": 200}
item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x", "reason": "task done"}))
_call_id, output = sess._exec_close_workstream(item)
coord.close_workstream.assert_called_once_with("x", reason="task done")
parsed = json.loads(output)
assert parsed["reason"] == "task done"
# ---------------------------------------------------------------------------
# delete_workstream
# ---------------------------------------------------------------------------
def test_delete_prepare_needs_approval(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("delete_workstream", {"ws_id": "x"}))
assert item["needs_approval"] is True
assert "irreversible" in item["header"].lower()
def test_delete_exec_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.delete.return_value = {"status": 200}
item = sess._prepare_tool(_tc("delete_workstream", {"ws_id": "x"}))
_call_id, output = sess._exec_delete_workstream(item)
coord.delete.assert_called_once_with("x")
parsed = json.loads(output)
assert parsed["deleted"] is True
# ---------------------------------------------------------------------------
# list_workstreams
# ---------------------------------------------------------------------------
def test_list_prepare_is_auto_approved(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("list_workstreams", {}))
assert item["needs_approval"] is False
def test_list_prepare_defaults_parent_to_self_ws(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("list_workstreams", {}))
assert item["parent_ws_id"] == "coord-1"
def test_list_prepare_accepts_explicit_parent(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc("list_workstreams", {"parent_ws_id": "other-coord", "state": "idle"})
)
assert item["parent_ws_id"] == "other-coord"
assert item["state"] == "idle"
def test_list_exec_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.list_children.return_value = {
"children": [
{"ws_id": "a", "state": "idle"},
{"ws_id": "b", "state": "running"},
],
"truncated": False,
}
item = sess._prepare_tool(_tc("list_workstreams", {}))
_call_id, output = sess._exec_list_workstreams(item)
coord.list_children.assert_called_once()
parsed = json.loads(output)
assert parsed["parent_ws_id"] == "coord-1"
assert len(parsed["children"]) == 2
assert parsed["truncated"] is False
def test_list_exec_surfaces_truncated_sentinel(coord_session):
sess, coord, _ui = coord_session
coord.list_children.return_value = {
"children": [{"ws_id": "a", "state": "idle"}],
"truncated": True,
}
item = sess._prepare_tool(_tc("list_workstreams", {}))
_call_id, output = sess._exec_list_workstreams(item)
parsed = json.loads(output)
assert parsed["truncated"] is True
# ---------------------------------------------------------------------------
# Defensive guard: missing coord_client
# ---------------------------------------------------------------------------
def test_prepare_fails_cleanly_when_coord_client_missing(monkeypatch):
"""If somehow a coordinator-kind session is built without a coord_client,
prepare methods return an error item rather than NPE."""
monkeypatch.setattr(ChatSession, "_load_skills", lambda self: None)
monkeypatch.setattr(ChatSession, "_init_system_messages", lambda self: None)
monkeypatch.setattr(ChatSession, "_save_config", lambda self: None)
ui = _StubUI()
sess = ChatSession(
client=MagicMock(),
model="m",
ui=ui, # type: ignore[arg-type]
instructions=None,
temperature=0.0,
max_tokens=1024,
tool_timeout=30,
context_window=16384,
ws_id="coord-1",
kind="coordinator",
coord_client=None,
)
for tool, args in (
("spawn_workstream", {"initial_message": "hi"}),
("inspect_workstream", {"ws_id": "x"}),
("send_to_workstream", {"ws_id": "x", "message": "m"}),
("close_workstream", {"ws_id": "x"}),
("delete_workstream", {"ws_id": "x"}),
("list_workstreams", {}),
("list_nodes", {}),
("list_skills", {}),
("task_list", {"action": "list"}),
):
item = sess._prepare_tool(_tc(tool, args))
assert "error" in item, f"{tool} did not error on missing coord_client"
# ---------------------------------------------------------------------------
# list_nodes
# ---------------------------------------------------------------------------
def test_list_nodes_prepare_is_auto_approved(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("list_nodes", {}))
assert item["needs_approval"] is False
assert item["filters"] == {}
assert item["limit"] == 100
def test_list_nodes_prepare_accepts_filters(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc("list_nodes", {"filters": {"arch": "x86_64", "capability": "gpu"}})
)
assert item["filters"] == {"arch": "x86_64", "capability": "gpu"}
def test_list_nodes_prepare_drops_invalid_filter_types(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc(
"list_nodes",
{"filters": {"arch": "x86_64", "bad": {"nested": "dict"}, "": "empty-key"}},
)
)
# Nested dict values + empty keys are filtered out; string + primitive kept.
assert item["filters"] == {"arch": "x86_64"}
def test_list_nodes_prepare_clamps_limit(coord_session):
sess, _coord, _ui = coord_session
over = sess._prepare_tool(_tc("list_nodes", {"limit": 9999}))
assert over["limit"] == 500
# limit == 0 falls back to the default (100), not 1 — consistent with
# the other coordinator list tools' ``int(args.get("limit") or 100)``.
zero = sess._prepare_tool(_tc("list_nodes", {"limit": 0}))
assert zero["limit"] == 100
neg = sess._prepare_tool(_tc("list_nodes", {"limit": -5}))
assert neg["limit"] == 1 # negative values clamp to 1
def test_list_nodes_exec_dispatches_to_client(coord_session):
sess, coord, ui = coord_session
coord.list_nodes.return_value = {
"nodes": [{"node_id": "n1", "metadata": {"arch": {"value": "x86_64", "source": "auto"}}}],
"truncated": False,
}
item = sess._prepare_tool(_tc("list_nodes", {"filters": {"arch": "x86_64"}}))
call_id, output = sess._exec_list_nodes(item)
assert call_id == "call-1"
parsed = json.loads(output)
assert parsed["nodes"][0]["node_id"] == "n1"
assert parsed["truncated"] is False
coord.list_nodes.assert_called_once_with(filters={"arch": "x86_64"}, limit=100)
def test_list_nodes_exec_surfaces_truncated_sentinel(coord_session):
sess, coord, ui = coord_session
coord.list_nodes.return_value = {"nodes": [], "truncated": True}
item = sess._prepare_tool(_tc("list_nodes", {}))
_, _ = sess._exec_list_nodes(item)
# Summary reported to UI carries the "truncated" hint.
assert any("truncated" in r[2] for r in ui.tool_results)
# ---------------------------------------------------------------------------
# list_skills
# ---------------------------------------------------------------------------
def test_list_skills_prepare_is_auto_approved(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("list_skills", {}))
assert item["needs_approval"] is False
assert item["category"] is None
assert item["tag"] is None
assert item["scan_status"] is None
assert item["enabled_only"] is False
assert item["limit"] == 100
def test_list_skills_prepare_accepts_filters(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc(
"list_skills",
{"category": "ops", "tag": "gpu", "scan_status": "clean", "enabled_only": True},
)
)
assert item["category"] == "ops"
assert item["tag"] == "gpu"
assert item["scan_status"] == "clean"
assert item["enabled_only"] is True
def test_list_skills_prepare_tolerates_non_string_filters(coord_session):
"""A malformed model call with non-string filter values must NOT
raise AttributeError during ``.strip()`` — the prepare path should
coerce non-strings to ``None`` and proceed."""
sess, _coord, _ui = coord_session
item = sess._prepare_tool(
_tc(
"list_skills",
{"category": 42, "tag": ["not", "a", "string"], "scan_status": {"bad": 1}},
)
)
assert "error" not in item
assert item["category"] is None
assert item["tag"] is None
assert item["scan_status"] is None
def test_list_skills_prepare_parses_enabled_only_string_forms(coord_session):
"""``bool("false")`` is True (non-empty string). The prepare path
must interpret common string forms the way the model would expect."""
sess, _coord, _ui = coord_session
for raw, expected in (
("true", True),
("True", True),
("1", True),
("false", False),
("False", False),
("0", False),
("", False),
(True, True),
(False, False),
):
item = sess._prepare_tool(_tc("list_skills", {"enabled_only": raw}))
assert item.get("enabled_only") is expected, (
f"enabled_only={raw!r}{item.get('enabled_only')!r}, expected {expected!r}"
)
def test_list_skills_exec_dispatches_to_client(coord_session):
sess, coord, ui = coord_session
coord.list_skills.return_value = {
"skills": [{"name": "alpha", "tags": ["gpu"]}],
"truncated": False,
}
item = sess._prepare_tool(_tc("list_skills", {"category": "ops", "tag": "gpu"}))
call_id, output = sess._exec_list_skills(item)
assert call_id == "call-1"
parsed = json.loads(output)
assert parsed["skills"][0]["name"] == "alpha"
coord.list_skills.assert_called_once_with(
category="ops",
tag="gpu",
scan_status=None,
enabled_only=False,
limit=100,
)
def test_list_skills_exec_surfaces_truncated_sentinel(coord_session):
sess, coord, ui = coord_session
coord.list_skills.return_value = {"skills": [], "truncated": True}
item = sess._prepare_tool(_tc("list_skills", {}))
_, _ = sess._exec_list_skills(item)
assert any("truncated" in r[2] for r in ui.tool_results)
# ---------------------------------------------------------------------------
# task_list
# ---------------------------------------------------------------------------
def test_task_list_list_is_auto_approved(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "list"}))
assert item["needs_approval"] is False
assert item["action"] == "list"
def test_task_list_bare_string_fallback_uses_action_primary_key(coord_session):
"""A model that emits an unquoted ``list`` as the arguments blob
lands on the ``primary_key=action`` fallback and recovers. Before
the fix primary_key was ``title`` so the fallback produced
``{"title": "list"}`` and hit the required-action rejection."""
sess, _coord, _ui = coord_session
call = {
"id": "c1",
"type": "function",
"function": {"name": "task_list", "arguments": "list"},
}
item = sess._prepare_tool(call)
assert "error" not in item
assert item["action"] == "list"
def test_task_list_mutating_actions_need_approval(coord_session):
sess, _coord, _ui = coord_session
add_item = sess._prepare_tool(_tc("task_list", {"action": "add", "title": "plan"}))
assert add_item["needs_approval"] is True
update_item = sess._prepare_tool(
_tc("task_list", {"action": "update", "task_id": "tsk_1", "status": "done"})
)
assert update_item["needs_approval"] is True
remove_item = sess._prepare_tool(_tc("task_list", {"action": "remove", "task_id": "tsk_1"}))
assert remove_item["needs_approval"] is True
reorder_item = sess._prepare_tool(
_tc("task_list", {"action": "reorder", "task_ids": ["tsk_1"]})
)
assert reorder_item["needs_approval"] is True
def test_task_list_unknown_action_errors(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "wat"}))
assert "error" in item
def test_task_list_non_string_action_errors_cleanly(coord_session):
"""A malformed ``action=42`` must NOT raise AttributeError during
``.strip().lower()`` — coerce to the empty string and fall through
to the enum-check error."""
sess, _coord, _ui = coord_session
for bad_action in (42, None, ["list"], {"a": 1}, True):
item = sess._prepare_tool(_tc("task_list", {"action": bad_action}))
assert "error" in item, f"action={bad_action!r} did not produce a clean error"
def test_task_list_add_rejects_non_string_title_and_status(coord_session):
"""Add branch: ``title=42`` / ``status=0`` must NOT raise
AttributeError during ``.strip()``; produce a clean error item."""
sess, _coord, _ui = coord_session
for bad in ({"action": "add", "title": 42}, {"action": "add", "title": "ok", "status": 0}):
item = sess._prepare_tool(_tc("task_list", bad))
assert "error" in item, f"args={bad!r} did not produce a clean error"
def test_task_list_remove_non_string_task_id_errors_cleanly(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "remove", "task_id": 42}))
assert "error" in item
def test_task_list_add_requires_title(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "add", "title": ""}))
assert "error" in item
def test_task_list_update_requires_task_id(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "update", "status": "done"}))
assert "error" in item
def test_task_list_update_rejects_non_string_field_values(coord_session):
"""Preview must not diverge from execute: reject non-string field
values at prepare time rather than silently coercing to None."""
sess, _coord, _ui = coord_session
for field in ("title", "status", "child_ws_id"):
item = sess._prepare_tool(
_tc("task_list", {"action": "update", "task_id": "t1", field: 42})
)
assert "error" in item, f"update with non-string {field} should error"
def test_task_list_update_requires_at_least_one_field(coord_session):
"""update with only task_id is a no-op — reject to save an approval prompt."""
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "update", "task_id": "t1"}))
assert "error" in item
def test_task_list_remove_requires_task_id(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "remove"}))
assert "error" in item
def test_task_list_reorder_requires_list_of_strings(coord_session):
sess, _coord, _ui = coord_session
item = sess._prepare_tool(_tc("task_list", {"action": "reorder", "task_ids": [1, 2]}))
assert "error" in item
def test_task_list_exec_list_returns_tasks(coord_session):
sess, coord, _ui = coord_session
coord.task_list_get.return_value = {
"version": 1,
"tasks": [{"id": "tsk_1", "title": "do", "status": "pending"}],
}
item = sess._prepare_tool(_tc("task_list", {"action": "list"}))
_, output = sess._exec_task_list(item)
parsed = json.loads(output)
assert parsed["tasks"][0]["id"] == "tsk_1"
assert parsed["truncated"] is False
def test_task_list_exec_list_page_caps_at_200(coord_session):
sess, coord, _ui = coord_session
coord.task_list_get.return_value = {
"version": 1,
"tasks": [{"id": f"tsk_{i}", "title": "x", "status": "pending"} for i in range(250)],
}
item = sess._prepare_tool(_tc("task_list", {"action": "list"}))
_, output = sess._exec_task_list(item)
parsed = json.loads(output)
assert len(parsed["tasks"]) == 200
assert parsed["truncated"] is True
def test_task_list_exec_add_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.task_list_add.return_value = {"id": "tsk_new", "title": "plan"}
item = sess._prepare_tool(
_tc("task_list", {"action": "add", "title": "plan", "status": "pending"})
)
_, _ = sess._exec_task_list(item)
coord.task_list_add.assert_called_once_with(
sess._ws_id, title="plan", status="pending", child_ws_id=""
)
def test_task_list_exec_reorder_surfaces_permutation_error(coord_session):
sess, coord, _ui = coord_session
coord.task_list_reorder.return_value = {"error": "task_ids must be a permutation..."}
item = sess._prepare_tool(_tc("task_list", {"action": "reorder", "task_ids": ["wrong"]}))
_, output = sess._exec_task_list(item)
parsed = json.loads(output)
assert "error" in parsed
def test_task_list_exec_remove_passes_client_dict_through(coord_session):
"""The client returns a dict; exec must pass it through without
synthesising a generic 'not found' message that would mask corrupt-
envelope errors from the LLM."""
sess, coord, _ui = coord_session
coord.task_list_remove.return_value = {
"error": "task_list envelope is corrupt on disk; refusing to overwrite."
}
item = sess._prepare_tool(_tc("task_list", {"action": "remove", "task_id": "x"}))
_, output = sess._exec_task_list(item)
parsed = json.loads(output)
assert "corrupt" in parsed["error"]
def test_task_list_exec_remove_success_dispatches(coord_session):
sess, coord, _ui = coord_session
coord.task_list_remove.return_value = {"ok": True, "task_id": "tsk_1"}
item = sess._prepare_tool(_tc("task_list", {"action": "remove", "task_id": "tsk_1"}))
_, output = sess._exec_task_list(item)
parsed = json.loads(output)
assert parsed.get("ok") is True