feat(watch,server): thread events over t3 websocket to the gateway hook, t3_answer for pending questions
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user