653 lines
23 KiB
Python
653 lines
23 KiB
Python
import json
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastmcp import Client
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
from t3code_mcp.config import Machine, ModelSpec, load_machines
|
|
from t3code_mcp.server import Registry, build_server
|
|
|
|
|
|
class FakeT3:
|
|
def __init__(
|
|
self, label: str, projects: list[dict[str, Any]], token: str = "tok"
|
|
) -> None:
|
|
self.label = label
|
|
self.token = token
|
|
self.projects = projects
|
|
self.threads: dict[str, dict[str, Any]] = {}
|
|
self.commands: list[dict[str, Any]] = []
|
|
self.polls_until_done = 2
|
|
self.pending_input = False
|
|
self.fail_with: str | None = None
|
|
|
|
def handle(self, request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
if path == "/.well-known/t3/environment":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"label": self.label,
|
|
"serverVersion": "0.0.36",
|
|
"platform": {"os": "linux"},
|
|
},
|
|
)
|
|
if request.headers.get("authorization") != f"Bearer {self.token}":
|
|
return httpx.Response(
|
|
401, json={"code": "auth_invalid", "reason": "missing_credential"}
|
|
)
|
|
if path == "/api/orchestration/shell":
|
|
return httpx.Response(200, json=self._shell())
|
|
if path.startswith("/api/orchestration/threads/"):
|
|
thread_id = path.rsplit("/", 1)[1]
|
|
if thread_id not in self.threads:
|
|
return httpx.Response(
|
|
404, json={"code": "not_found", "reason": "thread_not_found"}
|
|
)
|
|
self._tick(thread_id)
|
|
return httpx.Response(
|
|
200, json={"snapshotSequence": 1, "thread": self.threads[thread_id]}
|
|
)
|
|
if path == "/api/orchestration/dispatch":
|
|
command = json.loads(request.content)
|
|
self.commands.append(command)
|
|
return self._dispatch(command)
|
|
return httpx.Response(404, json={"code": "not_found"})
|
|
|
|
def _shell(self) -> dict[str, Any]:
|
|
threads = [
|
|
{
|
|
k: v
|
|
for k, v in t.items()
|
|
if k not in ("messages", "activities", "checkpoints")
|
|
}
|
|
| {"hasPendingUserInput": self.pending_input, "hasPendingApprovals": False}
|
|
for t in self.threads.values()
|
|
]
|
|
return {
|
|
"snapshotSequence": 1,
|
|
"projects": self.projects,
|
|
"threads": threads,
|
|
"updatedAt": "now",
|
|
}
|
|
|
|
def _tick(self, thread_id: str) -> None:
|
|
thread = self.threads[thread_id]
|
|
if self.fail_with and thread.get("_pending_turn"):
|
|
thread["session"] = {"status": "stopped", "lastError": self.fail_with}
|
|
return
|
|
if thread.get("_pending_turn"):
|
|
thread["latestTurn"] = {
|
|
"turnId": thread.pop("_pending_turn"),
|
|
"state": "running",
|
|
}
|
|
return
|
|
latest = thread["latestTurn"]
|
|
if latest and latest["state"] == "running":
|
|
latest["_polls"] = latest.get("_polls", 0) + 1
|
|
if latest["_polls"] > self.polls_until_done:
|
|
latest["state"] = "completed"
|
|
thread["messages"].append(
|
|
{"role": "assistant", "text": "done: report", "streaming": False}
|
|
)
|
|
thread["session"]["status"] = "ready"
|
|
|
|
def _dispatch(self, command: dict[str, Any]) -> httpx.Response:
|
|
kind = command["type"]
|
|
if kind == "thread.create":
|
|
self.threads[command["threadId"]] = {
|
|
"id": command["threadId"],
|
|
"projectId": command["projectId"],
|
|
"title": command["title"],
|
|
"modelSelection": command["modelSelection"],
|
|
"runtimeMode": command["runtimeMode"],
|
|
"latestTurn": None,
|
|
"session": {"status": "idle", "lastError": None},
|
|
"messages": [],
|
|
"activities": [],
|
|
"checkpoints": [],
|
|
"archivedAt": None,
|
|
"updatedAt": "now",
|
|
}
|
|
elif kind == "thread.turn.start":
|
|
thread = self.threads[command["threadId"]]
|
|
thread["messages"].append(
|
|
{"role": "user", "text": command["message"]["text"], "streaming": False}
|
|
)
|
|
thread["latestTurn"] = None
|
|
thread["_pending_turn"] = "turn-" + command["commandId"][:4]
|
|
thread["session"] = {"status": "running", "lastError": None}
|
|
if "modelSelection" in command:
|
|
thread["modelSelection"] = command["modelSelection"]
|
|
elif kind == "thread.turn.interrupt":
|
|
thread = self.threads[command["threadId"]]
|
|
thread.pop("_pending_turn", None)
|
|
thread["latestTurn"] = {"turnId": "t", "state": "interrupted"}
|
|
elif kind == "thread.user-input.respond":
|
|
self.threads[command["threadId"]]["activities"].append(
|
|
{
|
|
"kind": "user-input.resolved",
|
|
"summary": "User input submitted",
|
|
"tone": "info",
|
|
"payload": {
|
|
"requestId": command["requestId"],
|
|
"answers": command["answers"],
|
|
},
|
|
}
|
|
)
|
|
else:
|
|
return httpx.Response(
|
|
400, json={"code": "invalid_request", "reason": "invalid_command"}
|
|
)
|
|
return httpx.Response(200, json={"sequence": len(self.commands)})
|
|
|
|
|
|
PROJECTS_MAC = [
|
|
{
|
|
"id": "p-smoke",
|
|
"title": "t3-smoke",
|
|
"workspaceRoot": "/Users/h/projects/playgrounds/t3-smoke",
|
|
"defaultModelSelection": {"instanceId": "codex", "model": "gpt-5.6-sol"},
|
|
},
|
|
{
|
|
"id": "p-secret",
|
|
"title": "secret",
|
|
"workspaceRoot": "/Users/h/secret",
|
|
"defaultModelSelection": None,
|
|
},
|
|
]
|
|
PROJECTS_DELL = [
|
|
{
|
|
"id": "p-root",
|
|
"title": "projects",
|
|
"workspaceRoot": "/root/projects",
|
|
"defaultModelSelection": None,
|
|
},
|
|
{
|
|
"id": "p-repo",
|
|
"title": "beaver/x",
|
|
"workspaceRoot": "/root/projects/beaver/x",
|
|
"defaultModelSelection": None,
|
|
},
|
|
]
|
|
|
|
|
|
class Router(httpx.AsyncBaseTransport):
|
|
def __init__(self, fakes: dict[str, FakeT3]) -> None:
|
|
self.fakes = fakes
|
|
|
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
return self.fakes[request.url.host].handle(request)
|
|
|
|
|
|
@pytest.fixture
|
|
def fakes(monkeypatch: pytest.MonkeyPatch) -> dict[str, FakeT3]:
|
|
monkeypatch.setenv("T3_MAC_TOKEN", "tok")
|
|
monkeypatch.setenv("T3_DELL_TOKEN", "tok")
|
|
return {"mac": FakeT3("mac", PROJECTS_MAC), "dell": FakeT3("dell", PROJECTS_DELL)}
|
|
|
|
|
|
@pytest.fixture
|
|
def registry(fakes: dict[str, FakeT3]) -> Registry:
|
|
machines = {
|
|
"mac": Machine.model_validate(
|
|
{
|
|
"name": "mac",
|
|
"url": "http://mac:3773",
|
|
"token_env": "T3_MAC_TOKEN",
|
|
"projects": ("t3-smoke",),
|
|
"model": "claudeAgent/claude-opus-5",
|
|
"options": {"effort": "high"},
|
|
}
|
|
),
|
|
"dell": Machine(
|
|
name="dell",
|
|
url="http://dell:3773",
|
|
token_env="T3_DELL_TOKEN",
|
|
projects=("/root/projects", "/root/projects/*"),
|
|
),
|
|
}
|
|
return Registry(machines, transport=Router(fakes))
|
|
|
|
|
|
@pytest.fixture
|
|
def client(registry: Registry) -> Client:
|
|
return Client(build_server(registry, poll=0.001))
|
|
|
|
|
|
def data(result: Any) -> Any:
|
|
return (
|
|
result.structured_content["result"]
|
|
if "result" in result.structured_content
|
|
else result.structured_content
|
|
)
|
|
|
|
|
|
async def test_machines_and_projects(client: Client) -> None:
|
|
async with client:
|
|
machines = data(await client.call_tool("t3_machines"))
|
|
assert [m["name"] for m in machines] == ["mac", "dell"]
|
|
assert all(m["online"] for m in machines)
|
|
projects = data(await client.call_tool("t3_projects", {"machine": "mac"}))
|
|
assert [p["title"] for p in projects] == ["t3-smoke"]
|
|
assert projects[0]["default_model"] == "codex/gpt-5.6-sol"
|
|
dell = data(await client.call_tool("t3_projects", {"machine": "dell"}))
|
|
assert [p["title"] for p in dell] == ["projects", "beaver/x"]
|
|
|
|
|
|
async def test_dispatch_wait_and_thread(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "mac",
|
|
"project": "t3-smoke",
|
|
"prompt": "write README\nmore",
|
|
},
|
|
)
|
|
)
|
|
assert started["title"] == "write README"
|
|
assert started["model"] == "claudeAgent/claude-opus-5 effort=high"
|
|
kinds = [c["type"] for c in fakes["mac"].commands]
|
|
assert kinds == ["thread.create", "thread.turn.start"]
|
|
assert fakes["mac"].commands[0]["modelSelection"] == {
|
|
"instanceId": "claudeAgent",
|
|
"model": "claude-opus-5",
|
|
"options": [{"id": "effort", "value": "high"}],
|
|
}
|
|
waited = data(
|
|
await client.call_tool(
|
|
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
|
|
)
|
|
)
|
|
assert waited["state"] == "completed"
|
|
assert waited["messages"][-1]["text"] == "done: report"
|
|
assert waited["timed_out"] is False
|
|
view = data(
|
|
await client.call_tool("t3_thread", {"thread_id": started["thread_id"]})
|
|
)
|
|
assert view["project"] == "t3-smoke"
|
|
assert view["pending"] is None
|
|
|
|
|
|
async def test_follow_up_uses_same_thread(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["mac"].polls_until_done = 0
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{"machine": "mac", "project": "t3-smoke", "prompt": "one"},
|
|
)
|
|
)
|
|
await client.call_tool(
|
|
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
|
|
)
|
|
again = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "mac",
|
|
"project": "t3-smoke",
|
|
"prompt": "two",
|
|
"thread_id": started["thread_id"],
|
|
},
|
|
)
|
|
)
|
|
assert again["thread_id"] == started["thread_id"]
|
|
assert [c["type"] for c in fakes["mac"].commands] == [
|
|
"thread.create",
|
|
"thread.turn.start",
|
|
"thread.turn.start",
|
|
]
|
|
|
|
|
|
async def test_allowlist_and_unknown_machine(client: Client) -> None:
|
|
async with client:
|
|
with pytest.raises(ToolError, match="not available on mac"):
|
|
await client.call_tool(
|
|
"t3_dispatch", {"machine": "mac", "project": "secret", "prompt": "x"}
|
|
)
|
|
with pytest.raises(ToolError, match="unknown machine"):
|
|
await client.call_tool("t3_projects", {"machine": "rpi"})
|
|
|
|
|
|
async def test_wait_timeout_and_interrupt(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["dell"].polls_until_done = 10_000
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "dell",
|
|
"project": "/root/projects",
|
|
"prompt": "loop",
|
|
"model": "claudeAgent/claude-sonnet-5",
|
|
},
|
|
)
|
|
)
|
|
assert started["model"] == "claudeAgent/claude-sonnet-5"
|
|
waited = data(
|
|
await client.call_tool(
|
|
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
|
|
)
|
|
)
|
|
assert waited["state"] == "running"
|
|
assert waited["timed_out"] is True
|
|
stopped = data(
|
|
await client.call_tool("t3_interrupt", {"thread_id": started["thread_id"]})
|
|
)
|
|
assert stopped["state"] == "interrupting"
|
|
assert fakes["dell"].commands[-1]["type"] == "thread.turn.interrupt"
|
|
view = data(
|
|
await client.call_tool("t3_thread", {"thread_id": started["thread_id"]})
|
|
)
|
|
assert view["state"] == "interrupted"
|
|
|
|
|
|
async def test_wait_returns_on_pending_input(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["mac"].polls_until_done = 10_000
|
|
fakes["mac"].pending_input = True
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{"machine": "mac", "project": "t3-smoke", "prompt": "ask"},
|
|
)
|
|
)
|
|
waited = data(
|
|
await client.call_tool(
|
|
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
|
|
)
|
|
)
|
|
assert waited["pending"] == {
|
|
"approval": False,
|
|
"user_input": True,
|
|
"plan": False,
|
|
}
|
|
assert waited["state"] == "running"
|
|
|
|
|
|
async def test_locate_without_cache(
|
|
registry: Registry, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["dell"].polls_until_done = 0
|
|
async with Client(build_server(registry, poll=0.001)) as client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "dell",
|
|
"project": "beaver/x",
|
|
"prompt": "hi",
|
|
"model": "claudeAgent/claude-opus-5",
|
|
},
|
|
)
|
|
)
|
|
registry.threads.clear()
|
|
async with Client(build_server(registry, poll=0.001)) as client:
|
|
view = data(
|
|
await client.call_tool("t3_thread", {"thread_id": started["thread_id"]})
|
|
)
|
|
assert view["machine"] == "dell"
|
|
|
|
|
|
def test_config_parsing(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("T3_MAC_TOKEN", "abc")
|
|
cfg = tmp_path / "t3code.toml"
|
|
cfg.write_text(
|
|
'[machines.mac]\nurl = "http://x"\ntoken_env = "T3_MAC_TOKEN"\nprojects = ["a"]\nmodel = "claudeAgent/claude-opus-5"\noptions = { effort = "high" }\n'
|
|
)
|
|
machines = load_machines(cfg)
|
|
assert machines["mac"].model == ModelSpec(
|
|
instance="claudeAgent", model="claude-opus-5", options={"effort": "high"}
|
|
)
|
|
assert machines["mac"].token == "abc"
|
|
monkeypatch.delenv("T3_MAC_TOKEN")
|
|
with pytest.raises(ValueError, match="T3_MAC_TOKEN is empty"):
|
|
load_machines(cfg)
|
|
|
|
|
|
async def test_wait_returns_on_runtime_failure(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["dell"].fail_with = "Claude runtime stream failed."
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "dell",
|
|
"project": "projects",
|
|
"prompt": "x",
|
|
"model": "claudeAgent/claude-opus-5",
|
|
},
|
|
)
|
|
)
|
|
waited = data(
|
|
await client.call_tool(
|
|
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
|
|
)
|
|
)
|
|
assert waited["state"] == "starting"
|
|
assert waited["error"] == "Claude runtime stream failed."
|
|
assert waited["waited"] < 1
|
|
|
|
|
|
def shell_thread(
|
|
tid: str, state: str | None, *, user_input: bool = False, error: str | None = None
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": tid,
|
|
"latestTurn": {"turnId": "t", "state": state} if state else None,
|
|
"session": {"status": "stopped" if error else "running", "lastError": error},
|
|
"hasPendingUserInput": user_input,
|
|
"hasPendingApprovals": False,
|
|
}
|
|
|
|
|
|
def test_tracker_transitions(tmp_path) -> None:
|
|
from t3code_mcp.watch import Tracker
|
|
|
|
tracker = Tracker(tmp_path / "state.json")
|
|
tracker.track("t1", "dell", "projects", "do x")
|
|
assert tracker.apply("dell", shell_thread("t1", None)) == []
|
|
assert tracker.apply("dell", shell_thread("t1", "running")) == []
|
|
assert [
|
|
e.kind
|
|
for e in tracker.apply("dell", shell_thread("t1", "running", user_input=True))
|
|
] == ["question"]
|
|
assert tracker.apply("dell", shell_thread("t1", "running", user_input=True)) == []
|
|
assert [e.kind for e in tracker.apply("dell", shell_thread("t1", "completed"))] == [
|
|
"completed"
|
|
]
|
|
assert tracker.apply("dell", shell_thread("t1", "completed")) == []
|
|
assert tracker.apply("mac", shell_thread("t1", "running")) == []
|
|
assert tracker.apply("dell", shell_thread("untracked", "completed")) == []
|
|
tracker.track("t2", "dell", "projects", "fail")
|
|
assert [
|
|
e.kind
|
|
for e in tracker.apply(
|
|
"dell", shell_thread("t2", None, error="Claude runtime stream failed.")
|
|
)
|
|
] == ["failed"]
|
|
reloaded = Tracker(tmp_path / "state.json")
|
|
assert reloaded.threads["t1"].state == "completed"
|
|
assert reloaded.threads["t2"].error == "Claude runtime stream failed."
|
|
|
|
|
|
async def test_hook_outbox_retries(tmp_path) -> None:
|
|
from t3code_mcp.watch import Hook, Tracker
|
|
|
|
attempts: list[dict[str, Any]] = []
|
|
fail_first = {"n": 1}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.headers["authorization"] == "Bearer gw"
|
|
attempts.append(json.loads(request.content))
|
|
if fail_first["n"]:
|
|
fail_first["n"] -= 1
|
|
return httpx.Response(503, text="down")
|
|
return httpx.Response(202, json={"job": 1})
|
|
|
|
tracker = Tracker(tmp_path / "state.json")
|
|
hook = Hook(
|
|
"http://gateway/hooks/t3code",
|
|
"gw",
|
|
tracker,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
hook.send({"event": "completed", "thread_id": "t1"})
|
|
assert Tracker(tmp_path / "state.json").outbox == [
|
|
{"event": "completed", "thread_id": "t1"}
|
|
]
|
|
assert await hook.deliver_once() is False
|
|
assert await hook.deliver_once() is True
|
|
assert tracker.outbox == [] and len(attempts) == 2
|
|
await hook.aclose()
|
|
|
|
|
|
async def test_answer_pending_question(
|
|
client: Client, fakes: dict[str, FakeT3]
|
|
) -> None:
|
|
fakes["mac"].polls_until_done = 10_000
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{"machine": "mac", "project": "t3-smoke", "prompt": "ask"},
|
|
)
|
|
)
|
|
tid = started["thread_id"]
|
|
with pytest.raises(ToolError, match="no pending question"):
|
|
await client.call_tool(
|
|
"t3_answer", {"thread_id": tid, "answers": {"q1": "yes"}}
|
|
)
|
|
fakes["mac"].threads[tid]["activities"].append(
|
|
{
|
|
"kind": "user-input.requested",
|
|
"summary": "User input requested",
|
|
"tone": "info",
|
|
"payload": {
|
|
"requestId": "req-1",
|
|
"questions": [
|
|
{
|
|
"id": "q1",
|
|
"header": "Deploy",
|
|
"question": "Deploy now?",
|
|
"options": [
|
|
{"label": "yes", "description": "go"},
|
|
{"label": "no", "description": "wait"},
|
|
],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
)
|
|
view = data(await client.call_tool("t3_thread", {"thread_id": tid}))
|
|
assert view["question"]["request_id"] == "req-1"
|
|
assert view["question"]["questions"][0]["options"][0]["label"] == "yes"
|
|
with pytest.raises(ToolError, match="unknown question ids"):
|
|
await client.call_tool(
|
|
"t3_answer", {"thread_id": tid, "answers": {"zz": "yes"}}
|
|
)
|
|
answered = data(
|
|
await client.call_tool(
|
|
"t3_answer", {"thread_id": tid, "answers": {"q1": "yes"}}
|
|
)
|
|
)
|
|
assert answered["answered"] == ["q1"]
|
|
cmd = fakes["mac"].commands[-1]
|
|
assert (
|
|
cmd["type"] == "thread.user-input.respond"
|
|
and cmd["requestId"] == "req-1"
|
|
and cmd["answers"] == {"q1": "yes"}
|
|
)
|
|
|
|
|
|
async def test_dispatch_tracks_thread(client: Client, registry: Registry) -> None:
|
|
async with client:
|
|
started = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{"machine": "mac", "project": "t3-smoke", "prompt": "track me"},
|
|
)
|
|
)
|
|
tracked = registry.tracker.threads[started["thread_id"]]
|
|
assert (tracked.machine, tracked.project, tracked.title) == (
|
|
"mac",
|
|
"t3-smoke",
|
|
"track me",
|
|
)
|
|
|
|
|
|
def test_tracker_watch_flag(tmp_path) -> None:
|
|
from t3code_mcp.watch import Tracker
|
|
|
|
tracker = Tracker(tmp_path / "state.json")
|
|
tracker.track("t1", "dell", "projects", "quiet", watch=False)
|
|
tracker.apply("dell", shell_thread("t1", "running"))
|
|
assert tracker.apply("dell", shell_thread("t1", "completed")) == []
|
|
assert tracker.threads["t1"].state == "completed"
|
|
tracker.set_watch("t1", enabled=True)
|
|
tracker.apply("dell", shell_thread("t1", "running"))
|
|
assert [e.kind for e in tracker.apply("dell", shell_thread("t1", "completed"))] == [
|
|
"completed"
|
|
]
|
|
assert Tracker(tmp_path / "state.json").threads["t1"].watch is True
|
|
|
|
|
|
async def test_watch_tool_lists_and_toggles(client: Client) -> None:
|
|
async with client:
|
|
loud = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{"machine": "mac", "project": "t3-smoke", "prompt": "tell me"},
|
|
)
|
|
)
|
|
quiet = data(
|
|
await client.call_tool(
|
|
"t3_dispatch",
|
|
{
|
|
"machine": "mac",
|
|
"project": "t3-smoke",
|
|
"prompt": "leave me alone",
|
|
"watch": False,
|
|
},
|
|
)
|
|
)
|
|
assert (loud["watch"], quiet["watch"]) == (True, False)
|
|
view = data(await client.call_tool("t3_watch"))
|
|
assert [t["thread_id"] for t in view["watched"]] == [loud["thread_id"]]
|
|
assert [t["thread_id"] for t in view["unwatched"]] == [quiet["thread_id"]]
|
|
view = data(
|
|
await client.call_tool(
|
|
"t3_watch", {"thread_id": loud["thread_id"], "enabled": False}
|
|
)
|
|
)
|
|
assert view["watched"] == []
|
|
assert {t["thread_id"] for t in view["unwatched"]} == {
|
|
loud["thread_id"],
|
|
quiet["thread_id"],
|
|
}
|
|
view = data(
|
|
await client.call_tool(
|
|
"t3_watch", {"thread_id": quiet["thread_id"], "enabled": True}
|
|
)
|
|
)
|
|
assert [t["thread_id"] for t in view["watched"]] == [quiet["thread_id"]]
|
|
with pytest.raises(ToolError, match="nothing to unwatch"):
|
|
await client.call_tool("t3_watch", {"thread_id": "nope", "enabled": False})
|
|
with pytest.raises(ToolError, match="enabled=true or enabled=false"):
|
|
await client.call_tool("t3_watch", {"thread_id": loud["thread_id"]})
|