Files
turnstone/tests/test_channel_routing.py
T
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00

572 lines
22 KiB
Python

"""Tests for turnstone.channels._routing.ChannelRouter."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from turnstone.api.console_schemas import RouteCreateResponse
from turnstone.channels._routing import ChannelRouter
from turnstone.sdk._types import TurnstoneAPIError
@pytest.fixture
def mock_storage() -> MagicMock:
"""Return a mock StorageBackend."""
storage = MagicMock()
storage.get_channel_user = MagicMock(return_value=None)
storage.get_channel_route = MagicMock(return_value=None)
storage.get_channel_route_by_ws = MagicMock(return_value=None)
storage.resolve_workstream = MagicMock(side_effect=lambda ws_id: ws_id)
storage.create_channel_route = MagicMock()
storage.delete_channel_route = MagicMock(return_value=True)
return storage
@pytest.fixture
def router(mock_storage: MagicMock) -> ChannelRouter:
return ChannelRouter(
server_url="http://localhost:8080/v1",
storage=mock_storage,
)
@pytest.fixture
def console_router(mock_storage: MagicMock) -> ChannelRouter:
return ChannelRouter(
server_url="http://localhost:8080/v1",
storage=mock_storage,
console_url="http://localhost:8081/v1",
api_token="tok-test",
)
class TestResolveUser:
@pytest.mark.anyio
async def test_linked_user(self, router: ChannelRouter, mock_storage: MagicMock) -> None:
mock_storage.get_channel_user.return_value = {"user_id": "usr-1", "channel_user_id": "d-42"}
result = await router.resolve_user("discord", "d-42")
assert result == "usr-1"
mock_storage.get_channel_user.assert_called_once_with("discord", "d-42")
@pytest.mark.anyio
async def test_unlinked_user(self, router: ChannelRouter, mock_storage: MagicMock) -> None:
mock_storage.get_channel_user.return_value = None
result = await router.resolve_user("slack", "s-99")
assert result is None
class TestSendMessage:
@pytest.mark.anyio
async def test_calls_server_send(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_send = AsyncMock()
monkeypatch.setattr(router._server, "send", mock_send)
await router.send_message("ws-1", "hello world")
mock_send.assert_awaited_once_with("hello world", "ws-1")
@pytest.mark.anyio
async def test_calls_console_route_send(
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert console_router._console is not None
mock_send = AsyncMock()
monkeypatch.setattr(console_router._console, "route_send", mock_send)
await console_router.send_message("ws-1", "hello world")
mock_send.assert_awaited_once_with("hello world", "ws-1")
class TestSendApproval:
@pytest.mark.anyio
async def test_calls_server_approve(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_approve = AsyncMock()
monkeypatch.setattr(router._server, "approve", mock_approve)
await router.send_approval("ws-1", "corr-abc", approved=True, feedback="ok")
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=True, feedback="ok", always=False, cycle_id="corr-abc"
)
@pytest.mark.anyio
async def test_omits_empty_feedback(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_approve = AsyncMock()
monkeypatch.setattr(router._server, "approve", mock_approve)
await router.send_approval("ws-1", "corr-abc", approved=False)
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=False, feedback=None, always=False, cycle_id="corr-abc"
)
@pytest.mark.anyio
async def test_calls_console_route_approve(
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert console_router._console is not None
mock_approve = AsyncMock()
monkeypatch.setattr(console_router._console, "route_approve", mock_approve)
await console_router.send_approval("ws-1", "corr-abc", approved=True, always=True)
mock_approve.assert_awaited_once_with(
ws_id="ws-1", approved=True, feedback="", always=True, cycle_id="corr-abc"
)
class TestDeleteRoute:
@pytest.mark.anyio
async def test_calls_storage_delete(
self, router: ChannelRouter, mock_storage: MagicMock
) -> None:
await router.delete_route("discord", "ch-123")
mock_storage.delete_channel_route.assert_called_once_with("discord", "ch-123")
class TestWorkstreamLiveness:
@pytest.mark.anyio
async def test_direct_mode_uses_manager_authoritative_active_list(
self,
router: ChannelRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert router._server is not None
mock_list = AsyncMock(
return_value=MagicMock(
workstreams=[
MagicMock(ws_id="other", state="idle"),
MagicMock(ws_id="ws-live", state="running"),
MagicMock(ws_id="ws-creating", state="creating"),
]
)
)
monkeypatch.setattr(router._server, "list_workstreams", mock_list)
assert await router._is_ws_live("ws-live") is True
assert await router._is_ws_live("ws-cold") is False
assert await router._is_ws_live("ws-creating") is False
assert mock_list.await_count == 3
@pytest.mark.anyio
async def test_console_mode_uses_routed_live_probe(
self,
console_router: ChannelRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert console_router._console is not None
mock_live = AsyncMock(side_effect=[MagicMock(live=True), MagicMock(live=False)])
monkeypatch.setattr(console_router._console, "route_workstream_live", mock_live)
assert await console_router._is_ws_live("ws-live") is True
assert await console_router._is_ws_live("ws-cold") is False
assert [item.args[0] for item in mock_live.await_args_list] == ["ws-live", "ws-cold"]
class TestGetOrCreateWorkstream:
@pytest.mark.anyio
async def test_creates_new_workstream_via_server(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert router._server is not None
mock_create = AsyncMock()
mock_create.return_value = MagicMock(ws_id="ws-new", name="test")
monkeypatch.setattr(router._server, "create_workstream", mock_create)
ws_id, is_new = await router.get_or_create_workstream("discord", "ch-1", name="test")
assert ws_id == "ws-new"
assert is_new is True
mock_storage.create_channel_route.assert_called_once_with("discord", "ch-1", "ws-new")
mock_create.assert_awaited_once()
@pytest.mark.anyio
async def test_creates_new_workstream_via_console(
self,
console_router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert console_router._console is not None
mock_create = AsyncMock(
return_value=RouteCreateResponse(
ws_id="ws-new",
name="test",
node_url="http://node1:8080/v1",
node_id="node-1",
routing_strategy="rendezvous",
)
)
monkeypatch.setattr(console_router._console, "route_create_workstream", mock_create)
ws_id, is_new = await console_router.get_or_create_workstream(
"discord", "ch-1", name="test"
)
assert ws_id == "ws-new"
assert is_new is True
mock_storage.create_channel_route.assert_called_once_with("discord", "ch-1", "ws-new")
# Node URL should be cached.
assert console_router._node_urls["ws-new"] == "http://node1:8080/v1"
@pytest.mark.anyio
async def test_returns_existing_alive_workstream(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-old",
"channel_type": "discord",
"channel_id": "ch-1",
}
monkeypatch.setattr(router, "_is_ws_live", AsyncMock(return_value=True))
ws_id, is_new = await router.get_or_create_workstream("discord", "ch-1")
assert ws_id == "ws-old"
assert is_new is False
@pytest.mark.anyio
async def test_resumes_stale_workstream(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-stale",
"channel_type": "discord",
"channel_id": "ch-1",
}
# The durable source exists but is no longer loaded on the node.
monkeypatch.setattr(router, "_is_ws_live", AsyncMock(return_value=False))
# Server create returns a resumed workstream.
assert router._server is not None
mock_create = AsyncMock()
mock_create.return_value = MagicMock(ws_id="ws-resumed", name="test")
monkeypatch.setattr(router._server, "create_workstream", mock_create)
ws_id, is_new = await router.get_or_create_workstream("discord", "ch-1", name="test")
assert ws_id == "ws-resumed"
assert is_new is True
# Should have deleted the stale route and created a new one.
mock_storage.delete_channel_route.assert_called_once_with("discord", "ch-1")
mock_storage.create_channel_route.assert_called_once_with("discord", "ch-1", "ws-resumed")
# The create call should include resume_ws pointing at the old ws.
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args[1]
assert call_kwargs["resume_ws"] == "ws-stale"
@pytest.mark.anyio
async def test_missing_stale_source_retries_fresh_via_server(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-pruned",
"channel_type": "discord",
"channel_id": "ch-1",
}
mock_storage.resolve_workstream.side_effect = None
mock_storage.resolve_workstream.return_value = None
assert router._server is not None
mock_create = AsyncMock(
side_effect=[
TurnstoneAPIError(404, "Workstream not found"),
MagicMock(ws_id="ws-fresh", name="test"),
]
)
mock_send = AsyncMock()
monkeypatch.setattr(router._server, "create_workstream", mock_create)
monkeypatch.setattr(router._server, "send", mock_send)
ws_id, is_new = await router.get_or_create_workstream(
"discord",
"ch-1",
name="test",
initial_message="hello",
)
assert (ws_id, is_new) == ("ws-fresh", True)
assert [item.kwargs["resume_ws"] for item in mock_create.await_args_list] == [
"ws-pruned",
"",
]
mock_send.assert_awaited_once_with("hello", "ws-fresh")
mock_storage.create_channel_route.assert_called_once_with("discord", "ch-1", "ws-fresh")
@pytest.mark.anyio
async def test_missing_stale_source_retries_fresh_via_console(
self,
console_router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-pruned",
"channel_type": "slack",
"channel_id": "ch-1",
}
mock_storage.resolve_workstream.side_effect = None
mock_storage.resolve_workstream.return_value = None
assert console_router._console is not None
mock_create = AsyncMock(
side_effect=[
TurnstoneAPIError(404, "Workstream not found"),
RouteCreateResponse(
ws_id="ws-fresh",
name="test",
node_url="http://node2:8080/v1",
node_id="node-2",
routing_strategy="rendezvous",
),
]
)
mock_send = AsyncMock()
monkeypatch.setattr(console_router._console, "route_create_workstream", mock_create)
monkeypatch.setattr(console_router._console, "route_send", mock_send)
ws_id, is_new = await console_router.get_or_create_workstream(
"slack",
"ch-1",
name="test",
initial_message="hello",
)
assert (ws_id, is_new) == ("ws-fresh", True)
assert [item.kwargs["resume_ws"] for item in mock_create.await_args_list] == [
"ws-pruned",
"",
]
mock_send.assert_awaited_once_with("hello", "ws-fresh")
assert console_router._node_urls["ws-fresh"] == "http://node2:8080/v1"
mock_storage.create_channel_route.assert_called_once_with("slack", "ch-1", "ws-fresh")
@pytest.mark.anyio
@pytest.mark.parametrize(
("status_code", "message"),
[
(404, "Workstream not found"),
(403, "Forbidden"),
(503, "Storage unavailable"),
(409, "Fork source is no longer available"),
(404, "Project not found"),
],
ids=["masked-acl", "forbidden", "operational", "conflict", "other-not-found"],
)
@pytest.mark.parametrize("via_console", [False, True], ids=["server", "console"])
async def test_stale_source_does_not_retry_other_failures(
self,
router: ChannelRouter,
console_router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
status_code: int,
message: str,
via_console: bool,
) -> None:
selected = console_router if via_console else router
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-stale",
"channel_type": "discord",
"channel_id": "ch-1",
}
monkeypatch.setattr(selected, "_is_ws_live", AsyncMock(return_value=False))
mock_create = AsyncMock(side_effect=TurnstoneAPIError(status_code, message))
if selected._console is not None:
monkeypatch.setattr(selected._console, "route_create_workstream", mock_create)
else:
assert selected._server is not None
monkeypatch.setattr(selected._server, "create_workstream", mock_create)
with pytest.raises(TurnstoneAPIError) as exc_info:
await selected.get_or_create_workstream("discord", "ch-1")
assert exc_info.value.status_code == status_code
assert exc_info.value.message == message
mock_create.assert_awaited_once()
mock_storage.delete_channel_route.assert_not_called()
mock_storage.create_channel_route.assert_not_called()
@pytest.mark.anyio
async def test_fresh_retry_is_attempted_only_once(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-pruned",
"channel_type": "discord",
"channel_id": "ch-1",
}
mock_storage.resolve_workstream.side_effect = None
mock_storage.resolve_workstream.return_value = None
assert router._server is not None
error = TurnstoneAPIError(404, "Workstream not found")
mock_create = AsyncMock(side_effect=[error, error])
monkeypatch.setattr(router._server, "create_workstream", mock_create)
with pytest.raises(TurnstoneAPIError, match="Workstream not found"):
await router.get_or_create_workstream("discord", "ch-1")
assert [item.kwargs["resume_ws"] for item in mock_create.await_args_list] == [
"ws-pruned",
"",
]
mock_storage.create_channel_route.assert_not_called()
@pytest.mark.anyio
async def test_storage_lookup_failure_preserves_route(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-existing",
"channel_type": "discord",
"channel_id": "ch-1",
}
mock_storage.resolve_workstream.side_effect = RuntimeError("storage offline")
assert router._server is not None
mock_create = AsyncMock()
monkeypatch.setattr(router._server, "create_workstream", mock_create)
with pytest.raises(RuntimeError, match="storage offline"):
await router.get_or_create_workstream("discord", "ch-1")
mock_storage.delete_channel_route.assert_not_called()
mock_create.assert_not_awaited()
@pytest.mark.anyio
@pytest.mark.parametrize("via_console", [False, True], ids=["server", "console"])
async def test_live_probe_failure_preserves_route_without_creating(
self,
router: ChannelRouter,
console_router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
via_console: bool,
) -> None:
selected = console_router if via_console else router
mock_storage.get_channel_route.return_value = {
"ws_id": "ws-existing",
"channel_type": "discord",
"channel_id": "ch-1",
}
probe_error = TurnstoneAPIError(503, "route uncertain")
monkeypatch.setattr(selected, "_is_ws_live", AsyncMock(side_effect=probe_error))
mock_create = AsyncMock()
if selected._console is not None:
monkeypatch.setattr(selected._console, "route_create_workstream", mock_create)
else:
assert selected._server is not None
monkeypatch.setattr(selected._server, "create_workstream", mock_create)
with pytest.raises(TurnstoneAPIError, match="route uncertain"):
await selected.get_or_create_workstream("discord", "ch-1")
mock_storage.delete_channel_route.assert_not_called()
mock_create.assert_not_awaited()
@pytest.mark.anyio
async def test_sends_initial_message_for_new_workstream(
self,
router: ChannelRouter,
mock_storage: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert router._server is not None
mock_create = AsyncMock()
mock_create.return_value = MagicMock(ws_id="ws-new", name="test")
monkeypatch.setattr(router._server, "create_workstream", mock_create)
mock_send = AsyncMock()
monkeypatch.setattr(router._server, "send", mock_send)
await router.get_or_create_workstream("discord", "ch-1", name="test", initial_message="hi")
mock_send.assert_awaited_once_with("hi", "ws-new")
class TestCloseWorkstream:
@pytest.mark.anyio
async def test_calls_server_close(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_close = AsyncMock()
monkeypatch.setattr(router._server, "close_workstream", mock_close)
await router.close_workstream("ws-1")
mock_close.assert_awaited_once_with("ws-1")
@pytest.mark.anyio
async def test_catches_api_error(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_close = AsyncMock(side_effect=TurnstoneAPIError(404, "not found"))
monkeypatch.setattr(router._server, "close_workstream", mock_close)
# Should not raise.
await router.close_workstream("ws-1")
@pytest.mark.anyio
async def test_calls_console_route_close(
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert console_router._console is not None
mock_close = AsyncMock()
monkeypatch.setattr(console_router._console, "route_close", mock_close)
await console_router.close_workstream("ws-1")
mock_close.assert_awaited_once_with("ws-1")
class TestAclose:
@pytest.mark.anyio
async def test_closes_server_client(
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert router._server is not None
mock_close = AsyncMock()
monkeypatch.setattr(router._server, "aclose", mock_close)
await router.aclose()
mock_close.assert_awaited_once()
@pytest.mark.anyio
async def test_closes_console_client(
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert console_router._console is not None
mock_close = AsyncMock()
monkeypatch.setattr(console_router._console, "aclose", mock_close)
await console_router.aclose()
mock_close.assert_awaited_once()
class TestGetNodeUrl:
@pytest.mark.anyio
async def test_returns_cached_url(self, router: ChannelRouter) -> None:
router._node_urls["ws-1"] = "http://node1:8080/v1"
url = await router.get_node_url("ws-1")
assert url == "http://node1:8080/v1"
@pytest.mark.anyio
async def test_falls_back_to_server_url(self, router: ChannelRouter) -> None:
url = await router.get_node_url("ws-unknown")
assert url == "http://localhost:8080/v1"
@pytest.mark.anyio
async def test_queries_console_route_lookup(
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
assert console_router._console is not None
mock_lookup = AsyncMock(return_value={"node_url": "http://node2:8080/v1", "node_id": "n2"})
monkeypatch.setattr(console_router._console, "route_lookup", mock_lookup)
url = await console_router.get_node_url("ws-1")
assert url == "http://node2:8080/v1"
mock_lookup.assert_awaited_once_with("ws-1")
# Should be cached now.
assert console_router._node_urls["ws-1"] == "http://node2:8080/v1"