mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-22 11:54:47 -06:00
553d73109b
* feat(coordinator): phase 5 — harness-test polish + wait_for_workstream + judge fix
Closes the bug list surfaced by the 2026-04-17 coordinator harness test
plus the post-phase-4 wait_for_workstream ask, and folds in three
adjacent cleanups that landed in the same window. Tightens defense-in-
depth on the model-invoked mutating ops, fixes the LLM judge silent
no-op, kills the inspect-poll token burn, and rounds out a handful of
observability / docstring / spec gaps.
The session-factory pre-resolve at console/session_factory.py and
server.py was rewriting `judge.model` from an alias (e.g. `judge-mini`)
to the resolved underlying id (e.g. `gpt-5-mini`). IntentJudge then
checked `model_registry.has_alias(config.model)`, found nothing, and
fell back to the SESSION's provider/client with that bare model id —
silent `llm_fallback / "did not return a verdict"` whenever the
coordinator and judge alias resolved to different providers.
Pass the alias through unchanged; IntentJudge's existing alias-
resolution path picks up the matching client + provider. Validate
the alias exists so an obvious typo still surfaces, but don't replace
the model field.
Regression: `test_alias_uses_registry_provider_not_session_provider`
constructs an alias whose provider differs from the session's and
asserts the judge picks up the alias's provider/client/model;
`test_coordinator_tool_call_returns_llm_verdict_not_fallback` asserts
the verdict tier is `llm` (not `llm_fallback`) on the happy path.
New `cancel_workstream` tool (approval required, primary_key=ws_id) —
cancels in-flight generation, unblocks any pending approval / plan,
moves the child to idle, leaves the row in storage so a fresh
send_to_workstream lands cleanly. Re-uses the existing
`/v1/api/route/cancel` route + `route.cancel` audit namespace; no
new server endpoint.
`CoordinatorClient.cancel/close_workstream/delete/send` now enforce
a tenant guard inline (`_is_own_subtree`) — only the coordinator
itself or one of its own children is targetable. Foreign ids return
the same 404-shape inspect/wait_for_workstream use, so the model
can't distinguish foreign from missing (no existence oracle).
Defense-in-depth — the upstream node enforcement is the perimeter,
this is the second line.
`list_workstreams` advertised `state="deleted"` and an
`include_closed=true` that surfaced deleted rows. Hard-deletes
cascade the workstream + conversation rows out of storage, so
deleted is unreachable in normal operation. Doc-only fix; the
synthetic-test path that registers `state="deleted"` rows still
works (terminal-state filter still excludes them via
`_terminal_states = {"closed", "deleted"}` in list_children).
Documented that the 120s service-registry heartbeat window means a
node returned by list_nodes can drop out before a follow-up
`spawn_workstream(target_node=…)` lands — the spawn fails with "No
available node for routing" rather than falling back. Two-line
clarification on each tool. No code change (a code fallback is a
bigger discussion deferred to 1.6).
`close_workstream` accepts `reason`; the upstream server handler now
persists it to `workstream_config.close_reason` (capped at 512 BYTES,
sliced on UTF-8 not code points so a CJK / emoji-heavy payload can't
4× the documented budget). `CoordinatorClient.inspect()` reads it
and surfaces as `close_reason` in the result dict — only for
terminal-state children (closed/error/deleted) so the live-child hot
path doesn't pay a per-inspect DB round-trip.
Tests: server-side persistence covers success / no-reason /
length-cap / non-string / storage-failure / multi-byte-utf8 paths;
client-side surface covers terminal vs. live workstreams.
For idle children whose node-dashboard live counter is 0 (the live
block only surfaces in-flight token counters), fall back to
`SUM(prompt_tokens + completion_tokens)` from `usage_events` so the
inspect output reflects cumulative spend.
New `storage.sum_workstream_tokens(ws_id) -> int` on the protocol +
both backends. The fallback is folded INTO `_fetch_cluster_live` so
the merged live block (with persisted total applied) is what gets
cached — back-to-back inspects of an idle child amortize through
the existing 2s LRU cache instead of each firing a fresh aggregation.
`CoordinatorClient.list_skills()` now projects `allowed_tools` per
skill — capped at 20 with a `+N more` sentinel so a skill that
whitelists a wide MCP surface doesn't bloat the per-row payload.
Reads the existing `prompt_templates.allowed_tools` column; no
storage change. Coordinators no longer have to guess what tools a
skill brings.
`route_create` now sets `routing_strategy: "hash_ring" | "target_node"
| "resume"` on the spawn response so the coordinator's spawn
response (and the `spawn_workstream` tool output) carries why a
given node was chosen. 3 lines + 3 covering tests in
test_console_routing_proxy.py.
New coordinator tool `wait_for_workstream(ws_ids, timeout=60,
mode='any'|'all')` that absorbs the wait into a single tool call —
the model sees one call + one result regardless of how long the
children take. Kills the busy-poll inspect loop that burned 20+
turns on a 3-child fan-out.
Storage-poll loop with batched primitives —
`get_workstreams_batch` + `sum_workstream_tokens_batch` issue exactly
two storage calls per tick regardless of N. At the cap (32 ws_ids /
600s / 0.5s tick) that's ~2400 round-trips for a full wait, down
from ~38k under the naive per-id shape.
Validation single-source-of-truth: the client owns mode whitelist,
ws_ids dedup + cap, timeout coerce + clamp. The session preparer
is a thin pass-through that builds the header + dispatches; bad
input surfaces at exec time as a tool error via `result.get("error")`.
Tenant-isolation collapse: missing-row and cross-tenant cases both
return `state="denied"` so wait can't be used as an existence oracle
(matches the 404-mask contract `inspect` uses).
Prompt-side: tools_coordinator.md adds a `wait_for_workstream`
pattern + an explicit "PREFER wait_for_workstream OVER a loop of
inspect_workstream" line in the workflow-shape section.
Replaces the quote-bracketed substring LIKE/ILIKE pattern with proper
JSON-array containment. The previous shape effectively did
`LOWER(tags) LIKE '%"<lower-tag>"%'`, which broke for tag values
containing `"` (the JSON encoder escapes it to `\"` and the literal-
substring search misses), `\` (encoded as `\\`), or non-ASCII
characters that the encoder rendered as `\uXXXX`. Also exposed a
small spoofing surface — `tags=["foo\","bar"]` would have matched a
query for `bar`. Real-world tag values are alphanumeric+dash today
so it hadn't fired in production, but the fix is small.
- SQLite: `EXISTS (SELECT 1 FROM json_each(prompt_templates.tags)
WHERE lower(value) = lower(:tag))` (JSON1 extension; SQLite 3.38+).
- PostgreSQL: `EXISTS (SELECT 1 FROM jsonb_array_elements_text(
prompt_templates.tags::jsonb) AS jat(elem) WHERE lower(jat.elem) =
lower(:tag))`.
Three new tests prove the substring pattern was broken for
quoted / backslash / unicode tag values; the existing case-fold +
wildcard tests continue to pin the contract.
Phase 1 added the coordinator workstream API; phase 2 added only
`/open` to the OpenAPI catalog and missed every other coordinator
endpoint plus phase 3's `/children`, `/tasks`, and the
`/cluster/ws/{ws_id}/detail` aggregator. SDK consumers + operators
browsing `/docs` couldn't discover the surface. Doc-only addition:
12 endpoints + 9 new Pydantic models, all under the `Coordinator`
OpenAPI tag so /docs groups them together.
Sidebar re-fetches `GET /tasks` on every `task_list` `tool_result`
SSE event. A model that runs `add → list` (or any back-to-back
mutation pair) double-fetches the same envelope. Coalesced into
one fetch per 150ms window via a new `loadTasksDebounced` wrapper;
direct UI actions (refresh button, page load) keep calling
`loadTasks` directly so user clicks aren't delayed.
- `ruff check turnstone tests` — clean
- `mypy turnstone` — clean (157 source files)
- `pytest -m "not live"` — 4284 passed, 3 deselected (was 4226 on
main; +58 new tests across coordinator client, tools, judge,
storage, console routing proxy, server close-handler,
storage_skills_filtered, OpenAPI catalog, server close-reason
persistence)
- New tools added: 2 (cancel_workstream, wait_for_workstream) —
TOOLS count 28 → 30; coordinator subset 9 → 11; auto_approve adds
wait_for_workstream; primary_key adds cancel_workstream
- New OpenAPI endpoints: 12 (every phase-1/2/3 coordinator route +
the cluster-inspect aggregator)
- New storage protocol methods: 3 (sum_workstream_tokens,
sum_workstream_tokens_batch, get_workstreams_batch)
All phase 1 / 2 / 3 / 4 invariants preserved: COORDINATOR_TOOLS /
INTERACTIVE_TOOLS disjoint; coordinator sessions have no MCP surface;
list-style tools return {items, truncated}; route-proxy emits
route.<action> audit on 2xx; 404-mask on ownership failures; tenant
filters pushed into SQL; per-coordinator JWT carries scope context.
* fix(coordinator): address Copilot review on PR #378
Three valid Copilot findings on the wait_for_workstream surface:
1. ``wait_for_workstream.json`` description claimed the tool returns a
top-level mapping ``ws_id -> {state, tokens, updated}`` plus
elapsed/complete/mode at the same level, but the actual shape is
``{results: {ws_id: {...}}, elapsed, complete, mode}``. Description
now matches the implementation. Also adds ``deleted`` to the
advertised terminal-state list (it's in ``_WAIT_REAL_TERMINAL_STATES``;
the doc and runtime now agree).
2. ``CoordinatorClient.wait_for_workstream`` docstring listed
``idle / error / closed`` as the real terminal set but the constant
includes ``deleted``. Same fix — list ``deleted`` with a parenthetical
noting it's unreachable in normal operation (hard-delete cascades the
row).
3. Storage protocol docstring math: ``sum_workstream_tokens_batch``
claimed "from ~38k to ~1200" round-trips per wait at the cap, but
``wait_for_workstream`` issues TWO storage calls per tick
(``get_workstreams_batch`` + this one), so 1200 ticks × 2 = ~2400.
Updated to "~2400" with the math spelled out.
Also a clean rebase onto today's main (PR #377 — the rebalancer node_id
snapshot doc — landed since phase 5's last push). Single conflict in
``inspect_workstream.json`` resolved by keeping both notes (rebalancer
node_id binding semantics + the new ``close_reason`` surface from phase
5); ``spawn_workstream.json`` auto-merged.
The github-code-quality bot also flagged three items on
``_protocol.py`` asking to replace ``...`` with ``pass`` in Protocol
method bodies. Refuted: ``...`` is the canonical PEP 544 idiom for
Protocol method bodies and the rest of the file uses it consistently.
The bot's lint rule misfires for ``Protocol`` classes.
Verification:
- ``ruff check turnstone tests`` clean
- ``mypy turnstone`` clean (158 source files)
- ``pytest -m "not live"`` — 4308 passed, 3 deselected (no test count
change; pure doc/comment edits)
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""Tests for Phase A schema additions: ``kind`` + ``parent_ws_id`` on workstreams.
|
|
|
|
Covers:
|
|
|
|
- ``register_workstream`` persists the two new columns.
|
|
- ``get_workstream`` returns the full row including the new fields.
|
|
- ``list_workstreams`` filters on ``kind`` and ``parent_ws_id`` correctly.
|
|
- ``parent_ws_id`` empty-string normalization at the storage edge.
|
|
- Defaults remain ``"interactive"`` / ``NULL`` when not specified.
|
|
- ``Workstream`` dataclass exposes ``kind`` / ``parent_ws_id`` / ``user_id``
|
|
with safe defaults.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from turnstone.core.workstream import Workstream
|
|
|
|
# ``storage`` comes from tests/conftest.py — backend-parametrized fixture that
|
|
# respects the ``--storage-backend`` flag so the same assertions run against
|
|
# both SQLite (default) and PostgreSQL (CI), closing the q-3 drift risk that
|
|
# sqlite↔postgres register/list/normalize semantics could diverge silently.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# register_workstream / get_workstream
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_register_defaults_to_interactive_no_parent(storage):
|
|
storage.register_workstream("ws-a")
|
|
row = storage.get_workstream("ws-a")
|
|
assert row is not None
|
|
assert row["kind"] == "interactive"
|
|
assert row["parent_ws_id"] is None
|
|
|
|
|
|
def test_register_coordinator_kind_and_parent(storage):
|
|
storage.register_workstream("ws-coord", node_id="console", user_id="user-1", kind="coordinator")
|
|
storage.register_workstream(
|
|
"ws-child",
|
|
node_id="node-a",
|
|
user_id="user-1",
|
|
kind="interactive",
|
|
parent_ws_id="ws-coord",
|
|
)
|
|
|
|
coord = storage.get_workstream("ws-coord")
|
|
child = storage.get_workstream("ws-child")
|
|
|
|
assert coord is not None and child is not None
|
|
assert coord["kind"] == "coordinator"
|
|
assert coord["parent_ws_id"] is None
|
|
assert coord["user_id"] == "user-1"
|
|
|
|
assert child["kind"] == "interactive"
|
|
assert child["parent_ws_id"] == "ws-coord"
|
|
assert child["user_id"] == "user-1"
|
|
|
|
|
|
def test_register_normalizes_empty_parent_to_null(storage):
|
|
"""Empty-string parent_ws_id must be persisted as NULL so
|
|
``WHERE parent_ws_id IS NULL`` filters stay correct."""
|
|
storage.register_workstream("ws-a", parent_ws_id="")
|
|
row = storage.get_workstream("ws-a")
|
|
assert row is not None
|
|
assert row["parent_ws_id"] is None
|
|
|
|
|
|
def test_register_rejects_unknown_kind(storage):
|
|
"""Storage edge validates kind via WorkstreamKind(kind).value —
|
|
SDK / restore / direct callers can't silently corrupt the NOT NULL column
|
|
with typos or unknown values the way pre-PR #1 they could."""
|
|
import pytest as _pytest
|
|
|
|
with _pytest.raises(ValueError):
|
|
storage.register_workstream("ws-bogus", kind="interative") # typo
|
|
# Row was never inserted — no side effects on failure.
|
|
assert storage.get_workstream("ws-bogus") is None
|
|
|
|
|
|
def test_delete_workstream_nulls_child_parent_ws_id(storage):
|
|
"""Deleting a coordinator must null-out its children's parent_ws_id
|
|
so list_workstreams(parent_ws_id=<deleted>) doesn't keep returning
|
|
ghost-parented rows."""
|
|
storage.register_workstream("coord", kind="coordinator", user_id="user-1")
|
|
storage.register_workstream(
|
|
"child-a", kind="interactive", parent_ws_id="coord", user_id="user-1"
|
|
)
|
|
storage.register_workstream(
|
|
"child-b", kind="interactive", parent_ws_id="coord", user_id="user-1"
|
|
)
|
|
|
|
assert storage.delete_workstream("coord") is True
|
|
|
|
# Children still exist but with NULL parent_ws_id.
|
|
for cid in ("child-a", "child-b"):
|
|
row = storage.get_workstream(cid)
|
|
assert row is not None, f"{cid} should survive parent deletion"
|
|
assert row["parent_ws_id"] is None, f"{cid} still points at ghost coord"
|
|
# No rows match the deleted coord's parent filter.
|
|
assert storage.list_workstreams(parent_ws_id="coord") == []
|
|
|
|
|
|
def test_list_workstreams_filter_by_user_id(storage):
|
|
"""The user_id kwarg pushes tenant scoping into SQL so callers
|
|
can't forget to filter client-side."""
|
|
storage.register_workstream("ws-a", user_id="user-1")
|
|
storage.register_workstream("ws-b", user_id="user-1")
|
|
storage.register_workstream("ws-c", user_id="user-2")
|
|
storage.register_workstream("ws-ownerless") # no user_id
|
|
|
|
mine = storage.list_workstreams(user_id="user-1")
|
|
theirs = storage.list_workstreams(user_id="user-2")
|
|
ownerless = storage.list_workstreams(user_id="")
|
|
unfiltered = storage.list_workstreams()
|
|
|
|
assert {r[0] for r in mine} == {"ws-a", "ws-b"}
|
|
assert {r[0] for r in theirs} == {"ws-c"}
|
|
# Empty string is a real filter value (matches rows with stored "" owner).
|
|
# Rows with NULL owner are distinct and not matched.
|
|
assert "ws-ownerless" not in {r[0] for r in ownerless}
|
|
# No filter → all rows.
|
|
assert {r[0] for r in unfiltered} == {"ws-a", "ws-b", "ws-c", "ws-ownerless"}
|
|
|
|
|
|
def test_get_workstream_missing_returns_none(storage):
|
|
assert storage.get_workstream("nonexistent") is None
|
|
|
|
|
|
def test_get_workstream_includes_all_fields(storage):
|
|
storage.register_workstream(
|
|
"ws-full",
|
|
node_id="n1",
|
|
user_id="u1",
|
|
alias="alias-1",
|
|
title="Title 1",
|
|
name="name-1",
|
|
state="idle",
|
|
skill_id="skill-x",
|
|
skill_version=3,
|
|
kind="interactive",
|
|
parent_ws_id="parent-x",
|
|
)
|
|
row = storage.get_workstream("ws-full")
|
|
assert row is not None
|
|
for expected in (
|
|
"ws_id",
|
|
"node_id",
|
|
"user_id",
|
|
"alias",
|
|
"title",
|
|
"name",
|
|
"state",
|
|
"skill_id",
|
|
"skill_version",
|
|
"kind",
|
|
"parent_ws_id",
|
|
"created",
|
|
"updated",
|
|
):
|
|
assert expected in row
|
|
assert row["skill_version"] == 3
|
|
assert row["parent_ws_id"] == "parent-x"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_workstreams filter params
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_list_workstreams_no_filters_unchanged(storage):
|
|
storage.register_workstream("ws-a")
|
|
storage.register_workstream("ws-b")
|
|
rows = storage.list_workstreams()
|
|
assert len(rows) == 2
|
|
|
|
|
|
def test_list_workstreams_filter_by_kind(storage):
|
|
storage.register_workstream("ws-int-1")
|
|
storage.register_workstream("ws-int-2")
|
|
storage.register_workstream("ws-coord", kind="coordinator")
|
|
|
|
interactive = storage.list_workstreams(kind="interactive")
|
|
coord = storage.list_workstreams(kind="coordinator")
|
|
|
|
assert {r[0] for r in interactive} == {"ws-int-1", "ws-int-2"}
|
|
assert {r[0] for r in coord} == {"ws-coord"}
|
|
|
|
|
|
def test_list_workstreams_filter_by_parent(storage):
|
|
storage.register_workstream("ws-coord", kind="coordinator")
|
|
storage.register_workstream("child-1", parent_ws_id="ws-coord")
|
|
storage.register_workstream("child-2", parent_ws_id="ws-coord")
|
|
storage.register_workstream("other-1") # no parent
|
|
|
|
children = storage.list_workstreams(parent_ws_id="ws-coord")
|
|
assert {r[0] for r in children} == {"child-1", "child-2"}
|
|
|
|
|
|
def test_list_workstreams_combined_filters(storage):
|
|
storage.register_workstream("ws-coord", kind="coordinator")
|
|
storage.register_workstream("child-1", parent_ws_id="ws-coord")
|
|
storage.register_workstream("child-coord", parent_ws_id="ws-coord", kind="coordinator")
|
|
|
|
# Children of ws-coord that are themselves interactive.
|
|
rows = storage.list_workstreams(parent_ws_id="ws-coord", kind="interactive")
|
|
assert {r[0] for r in rows} == {"child-1"}
|
|
|
|
|
|
def test_list_workstreams_node_id_filter_still_works(storage):
|
|
"""The existing ``node_id`` filter keeps working after the signature change."""
|
|
storage.register_workstream("ws-a", node_id="node-1")
|
|
storage.register_workstream("ws-b", node_id="node-2")
|
|
rows = storage.list_workstreams(node_id="node-1")
|
|
assert {r[0] for r in rows} == {"ws-a"}
|
|
|
|
|
|
def test_list_workstreams_returns_kind_and_parent_columns(storage):
|
|
storage.register_workstream("ws-coord", kind="coordinator")
|
|
storage.register_workstream("child-1", parent_ws_id="ws-coord")
|
|
rows = storage.list_workstreams()
|
|
by_id = {r[0]: r for r in rows}
|
|
# Columns: ws_id, node_id, name, state, created, updated, kind, parent_ws_id
|
|
coord_row = by_id["ws-coord"]
|
|
child_row = by_id["child-1"]
|
|
assert coord_row[6] == "coordinator"
|
|
assert coord_row[7] is None
|
|
assert child_row[6] == "interactive"
|
|
assert child_row[7] == "ws-coord"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workstream dataclass field additions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_workstream_dataclass_defaults():
|
|
ws = Workstream()
|
|
assert ws.user_id == ""
|
|
assert ws.kind == "interactive"
|
|
assert ws.parent_ws_id is None
|
|
|
|
|
|
def test_workstream_dataclass_accepts_coordinator_kind():
|
|
ws = Workstream(kind="coordinator", user_id="user-1")
|
|
assert ws.kind == "coordinator"
|
|
assert ws.user_id == "user-1"
|
|
assert ws.parent_ws_id is None
|
|
|
|
|
|
def test_workstream_dataclass_accepts_parent():
|
|
ws = Workstream(parent_ws_id="parent-x")
|
|
assert ws.parent_ws_id == "parent-x"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool-namespace isolation between kinds
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_interactive_and_coordinator_tool_sets_are_disjoint():
|
|
"""Interactive sessions must not see coordinator tools and vice versa.
|
|
|
|
Regression guard for the latent threshold bug where coordinator tools
|
|
counted against the interactive session's tool-search threshold, and
|
|
a future reader might naively expose ``TOOLS`` (the union) to an
|
|
interactive session.
|
|
"""
|
|
from turnstone.core.tools import COORDINATOR_TOOLS, INTERACTIVE_TOOLS, TOOLS
|
|
|
|
interactive_names = {t["function"]["name"] for t in INTERACTIVE_TOOLS}
|
|
coord_names = {t["function"]["name"] for t in COORDINATOR_TOOLS}
|
|
|
|
# No overlap.
|
|
assert interactive_names.isdisjoint(coord_names), (
|
|
f"interactive ∩ coordinator tools should be empty, got {interactive_names & coord_names}"
|
|
)
|
|
# Coordinator set is non-empty (spawn/inspect/send/close/delete/list).
|
|
assert coord_names, "expected at least one coordinator tool"
|
|
# Union covers every loaded tool (no tool is in neither set).
|
|
all_names = {t["function"]["name"] for t in TOOLS}
|
|
assert interactive_names | coord_names == all_names
|
|
|
|
|
|
def test_chatsession_interactive_kind_excludes_coordinator_tools(tmp_db):
|
|
"""An interactive ``ChatSession`` does not surface coordinator tools."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from turnstone.core.session import ChatSession
|
|
|
|
class _NullUI:
|
|
def __getattr__(self, _name):
|
|
return lambda *a, **kw: None
|
|
|
|
sess = ChatSession(
|
|
client=MagicMock(),
|
|
model="test-model",
|
|
ui=_NullUI(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=30,
|
|
)
|
|
names = {t["function"]["name"] for t in sess._tools}
|
|
# None of the coordinator-only names should be in the interactive
|
|
# session's tool set.
|
|
for coord_name in (
|
|
"spawn_workstream",
|
|
"inspect_workstream",
|
|
"send_to_workstream",
|
|
"close_workstream",
|
|
"cancel_workstream",
|
|
"delete_workstream",
|
|
"list_workstreams",
|
|
"list_nodes",
|
|
"list_skills",
|
|
"task_list",
|
|
"wait_for_workstream",
|
|
):
|
|
assert coord_name not in names, f"{coord_name} leaked into interactive session tools"
|
|
|
|
|
|
def test_chatsession_coordinator_kind_excludes_interactive_tools(tmp_db):
|
|
"""A coordinator ``ChatSession`` sees only coordinator tools."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from turnstone.core.session import ChatSession
|
|
|
|
class _NullUI:
|
|
def __getattr__(self, _name):
|
|
return lambda *a, **kw: None
|
|
|
|
sess = ChatSession(
|
|
client=MagicMock(),
|
|
model="test-model",
|
|
ui=_NullUI(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=30,
|
|
kind="coordinator",
|
|
)
|
|
names = {t["function"]["name"] for t in sess._tools}
|
|
# Coordinator tools present, interactive tools absent.
|
|
assert "spawn_workstream" in names
|
|
assert "bash" not in names
|
|
assert "edit_file" not in names
|
|
assert "memory" not in names
|
|
# Sub-agent tool lists are zeroed for coordinators.
|
|
assert sess._task_tools == []
|
|
assert sess._agent_tools == []
|
|
|
|
|
|
def test_chatsession_coordinator_kind_does_not_merge_mcp_tools(tmp_db):
|
|
"""Coordinator ChatSession ignores any attached MCP client tool surface.
|
|
|
|
Coordinators are meta-orchestrators that spawn child workstreams;
|
|
MCP tools live on the children. Giving the coordinator direct MCP
|
|
access defeats the child-spawning pattern.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from turnstone.core.session import ChatSession
|
|
|
|
class _NullUI:
|
|
def __getattr__(self, _name):
|
|
return lambda *a, **kw: None
|
|
|
|
mcp_client = MagicMock()
|
|
mcp_client.get_tools.return_value = [
|
|
{"type": "function", "function": {"name": "mcp__foo__bar", "parameters": {}}}
|
|
]
|
|
sess = ChatSession(
|
|
client=MagicMock(),
|
|
model="test-model",
|
|
ui=_NullUI(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=30,
|
|
kind="coordinator",
|
|
mcp_client=mcp_client,
|
|
)
|
|
names = {t["function"]["name"] for t in sess._tools}
|
|
# No MCP tools in the coordinator surface.
|
|
assert "mcp__foo__bar" not in names
|
|
# And no MCP listeners were registered (defence-in-depth: MCP tool
|
|
# refreshes can't mutate the coordinator's fixed tool set).
|
|
mcp_client.add_listener.assert_not_called()
|
|
mcp_client.add_resource_listener.assert_not_called()
|
|
mcp_client.add_prompt_listener.assert_not_called()
|