feat(watch,server): thread events over t3 websocket to the gateway hook, t3_answer for pending questions

This commit is contained in:
hh
2026-08-29 22:17:26 +02:00
parent d445dad57f
commit a98211b79f
11 changed files with 747 additions and 92 deletions
+159
View File
@@ -125,6 +125,18 @@ class FakeT3:
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"}
@@ -430,3 +442,150 @@ async def test_wait_returns_on_runtime_failure(
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",
)