feat(t3,views,server,watch): t3 code orchestration protocol 2

This commit is contained in:
hh committed 2026-10-16 01:48:32 +02:00
1 parent 133bc6e915
commit f4c54b6e16
7 files changed
+448 -294

No files matched your search

+5 -5
View File
@@ -1,6 +1,6 @@
# t3code-mcp # t3code-mcp
An MCP connector to [T3 Code](https://github.com/pingdotgg/t3code) servers. It fills the one gap T3 Code leaves for agents: they cannot create their own threads. With this connector an agent dispatches a coding thread on any machine that runs T3 Code, waits for it, answers its questions and reads the result - all through T3's HTTP API, with no shell or terminal involved. An MCP connector to [T3 Code](https://github.com/pingdotgg/t3code) servers. It fills the one gap T3 Code leaves for agents: they cannot create their own threads. With this connector an agent dispatches a coding thread on any machine that runs T3 Code, waits for it, answers its questions and reads the result - all through T3's own API (orchestration protocol 2: reads over HTTP, commands over the WebSocket RPC), with no shell or terminal involved.
Built for the dispatcher in [beaver-agent](https://git.kotikot.com/beaver/beaver-agent); works with any MCP client. Built for the dispatcher in [beaver-agent](https://git.kotikot.com/beaver/beaver-agent); works with any MCP client.
@@ -10,11 +10,11 @@ Built for the dispatcher in [beaver-agent](https://git.kotikot.com/beaver/beaver
|---|---| |---|---|
| `t3_machines()` | machines from the config, which are online now, their project allowlists | | `t3_machines()` | machines from the config, which are online now, their project allowlists |
| `t3_projects(machine)` | allowlisted projects on a machine: path, default model, thread count | | `t3_projects(machine)` | allowlisted projects on a machine: path, default model, thread count |
| `t3_dispatch(machine, project, prompt, title?, model?, thread_id?, watch?)` | creates a thread and starts a turn (or starts a turn in an existing thread); returns `thread_id` at once; `watch=false` starts it without reporting events | | `t3_dispatch(machine, project, prompt, title?, model?, thread_id?, watch?)` | creates a thread and starts a run (or starts a run in an existing thread); returns `thread_id` at once; `watch=false` starts it without reporting events |
| `t3_thread(thread_id, turns?)` | the state without waiting: turn, session, last messages, tools, files, a pending question | | `t3_thread(thread_id, turns?)` | the state without waiting: latest run, session, messages of the last runs, tools, files, a pending question |
| `t3_wait(thread_id, timeout?)` | blocks until completed, interrupted, failed, a question, or the timeout | | `t3_wait(thread_id, timeout?)` | blocks until completed, interrupted, failed, a question, or the timeout |
| `t3_answer(thread_id, answers)` | answers a question the thread is waiting on (question id → label; a list for multi-select) | | `t3_answer(thread_id, answers)` | answers a question the thread is waiting on (question id → label; a list for multi-select) |
| `t3_interrupt(thread_id)` | interrupts the running turn | | `t3_interrupt(thread_id)` | interrupts the running run |
| `t3_watch(thread_id?, enabled?)` | without arguments lists the watched and unwatched threads; with both turns a thread's events on or off | | `t3_watch(thread_id?, enabled?)` | without arguments lists the watched and unwatched threads; with both turns a thread's events on or off |
`t3_wait` is an ordinary long call, not an MCP Task: Claude Code moves calls over two minutes into the background by itself (`CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`). Threads run in `full-access` without approvals. A thread is looked up across all machines, so the connector may restart in between. `t3_wait` is an ordinary long call, not an MCP Task: Claude Code moves calls over two minutes into the background by itself (`CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`). Threads run in `full-access` without approvals. A thread is looked up across all machines, so the connector may restart in between.
@@ -36,7 +36,7 @@ model = "claudeAgent/claude-opus-5"
options = { effort = "high", contextWindow = "1m" } options = { effort = "high", contextWindow = "1m" }
``` ```
Tokens never live in the file: `token_env` names the variable, and the token is issued on each machine with `t3 auth session issue --token-only --ttl 365d --label beaver`. `projects` are fnmatch patterns against a project's title and workspace root; a project that matches nothing does not exist for the client. The server listens on `T3CODE_MCP_HOST` / `T3CODE_MCP_PORT` (8000), MCP at `/mcp`, health at `/healthz`. Tokens never live in the file: `token_env` names the variable, and the token is issued on each machine with `t3 auth session issue --scope orchestration:read --scope orchestration:operate --token-only --ttl 365d --label beaver` - nothing beyond those two scopes is used. T3 Code from 0.1.0 (orchestration protocol 2) is required; older servers do not answer. `projects` are fnmatch patterns against a project's title and workspace root; a project that matches nothing does not exist for the client. The server listens on `T3CODE_MCP_HOST` / `T3CODE_MCP_PORT` (8000), MCP at `/mcp`, health at `/healthz`.
## Develop ## Develop
+8
View File
@@ -2,6 +2,14 @@
> Что сделано, что нет, что проверить руками. Свежие записи сверху. Архитектурная правда - `архитектура.md` §5 (vault, симлинк в `../../архитектура.md`), журнал всей второй итерации - `../../PROGRESS.md`. > Что сделано, что нет, что проверить руками. Свежие записи сверху. Архитектурная правда - `архитектура.md` §5 (vault, симлинк в `../../архитектура.md`), журнал всей второй итерации - `../../PROGRESS.md`.
## 2026-10-16 - T3 Code 0.1.0, orchestration protocol 2
T3 Code 0.1.0 (desktop на маке и `t3` на dell-dev) перешёл на протокол оркестрации 2, и коннектор ослеп: WS отвечал 426, HTTP без заголовка - 400, а `POST /api/orchestration/dispatch` удалён совсем. Сделано: каждый HTTP-запрос несёт `x-t3-orchestration-protocol: 2`, WS - `orchestrationProtocol=2` в URL; команды идут через WS RPC `orchestration.dispatchCommand` (одно соединение на вызов, ответ - `Exit`). Команды v2: `thread.create` + `message.dispatch` (`dispatchMode: start_immediately`, `createdBy: agent`, `creationSource: mcp`), `run.interrupt` по id активного run, `runtime-request.respond` для `t3_answer`. Тред теперь проекция (`runs`, `messages`, `turnItems`, `runtimeRequests`, `checkpoints`, `providerTurns`): состояние - статус последнего run, вопрос - pending `runtimeRequest` с `kind: user_input` и его `user_input_request` из `turnItems`, ошибка - `error`-item упавшего run или `lastError` сессии провайдера. В выдаче `turn_id` стал `run_id`, `turns` у `t3_thread` считает run'ы. Watcher: элементы потока `thread.updated`, строка shell с `status`/`lastError`/`pendingRuntimeRequest`; `failed`/`cancelled`/`rolled_back` - терминальные; курсор хранится под ключом `shell_sequences`, старый v1-курсор игнорируется. Токены перевыпущены с минимумом скоупов `orchestration:read` + `orchestration:operate`, метка `beaver-t3code-mcp-v2`.
Проверено живьём локальным коннектором: мак, `t3-smoke` - диспатч → `completed` за 10 с, продолжение треда с AskUserQuestion → `t3_wait` вернул вопрос → `t3_answer` → `completed`, долгий run → `t3_interrupt` → `interrupted` за 5 с; события `completed`/`question`/`interrupted` в логе watcher'а. dell-dev, `projects` - диспатч → `completed` за 10 с. 15 тестов на фейковом T3 v2.
Не сделано: `t3_wait` тянет полную проекцию треда каждые 5 с (у длинных тредов сотни КБ) - если станет тяжело, перейти на `orchestration.subscribeThread`; аппрувы по-прежнему не отвечаются (в `full-access` их нет).
## 2026-09-01 - подписка на события треда управляется из диспетчера ## 2026-09-01 - подписка на события треда управляется из диспетчера
Сделано: у `Tracked` появился флаг `watch` (по умолчанию `true`, лежит в том же `T3CODE_MCP_STATE`); `Tracker.apply()` при `watch=false` состояние треда обновляет как раньше, но события гасит - значит после обратной подписки не прилетит задним числом переход, случившийся в тишине. Тулза `t3_watch(thread_id?, enabled?)`: без аргументов - списки `watched` / `unwatched` (тред, машина, проект, заголовок, состояние), с обоими - снять или вернуть подписку; неизвестный тред - `ToolError`. У `t3_dispatch` параметр `watch` (по умолчанию `true`) - чтобы не ловить гонку на быстрых тредах; каждый диспатч в тред флаг перезаписывает, продолжение треда через `thread_id=` тоже. Загрузка стейта теперь игнорирует незнакомые поля - откат образа на версию без `watch` не уронит `Tracker`. 15 тестов. Сделано: у `Tracked` появился флаг `watch` (по умолчанию `true`, лежит в том же `T3CODE_MCP_STATE`); `Tracker.apply()` при `watch=false` состояние треда обновляет как раньше, но события гасит - значит после обратной подписки не прилетит задним числом переход, случившийся в тишине. Тулза `t3_watch(thread_id?, enabled?)`: без аргументов - списки `watched` / `unwatched` (тред, машина, проект, заголовок, состояние), с обоими - снять или вернуть подписку; неизвестный тред - `ToolError`. У `t3_dispatch` параметр `watch` (по умолчанию `true`) - чтобы не ловить гонку на быстрых тредах; каждый диспатч в тред флаг перезаписывает, продолжение треда через `thread_id=` тоже. Загрузка стейта теперь игнорирует незнакомые поля - откат образа на версию без `watch` не уронит `Tracker`. 15 тестов.
+41 -49
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, Any from typing import TYPE_CHECKING, Annotated, Any
import httpx import httpx
import websockets
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from pydantic import Field from pydantic import Field
@@ -30,16 +31,17 @@ if TYPE_CHECKING:
from starlette.requests import Request from starlette.requests import Request
from t3code_mcp.t3 import RpcCall
TITLE_MAX = 80 TITLE_MAX = 80
WAIT_MAX = 3600.0 WAIT_MAX = 3600.0
PENDING_EVERY = 6
STALLED_POLLS = 3
@dataclass @dataclass
class Registry: class Registry:
machines: dict[str, Machine] machines: dict[str, Machine]
transport: httpx.AsyncBaseTransport | None = None transport: httpx.AsyncBaseTransport | None = None
rpc: Callable[[str], RpcCall] | None = None
clients: dict[str, T3Client] = field(default_factory=dict) clients: dict[str, T3Client] = field(default_factory=dict)
threads: dict[str, str] = field(default_factory=dict) threads: dict[str, str] = field(default_factory=dict)
tracker: Tracker = field(default_factory=Tracker) tracker: Tracker = field(default_factory=Tracker)
@@ -55,7 +57,10 @@ class Registry:
machine = self.machine(name) machine = self.machine(name)
if name not in self.clients: if name not in self.clients:
self.clients[name] = T3Client( self.clients[name] = T3Client(
machine.url, machine.token, transport=self.transport machine.url,
machine.token,
transport=self.transport,
rpc=self.rpc(name) if self.rpc else None,
) )
return self.clients[name] return self.clients[name]
@@ -67,7 +72,7 @@ class Registry:
) )
names = [known] if known in self.machines else list(self.machines) names = [known] if known in self.machines else list(self.machines)
for name in names: for name in names:
detail = await self.client(name).thread(thread_id, turn_limit=1) detail = await self.client(name).thread(thread_id)
if detail is not None: if detail is not None:
self.threads[thread_id] = name self.threads[thread_id] = name
return name, detail return name, detail
@@ -90,7 +95,7 @@ def _title(prompt: str, title: str | None) -> str:
def _find_project( def _find_project(
machine: Machine, projects: list[dict[str, Any]], key: str machine: Machine, projects: list[dict[str, Any]], key: str
) -> dict[str, Any]: ) -> dict[str, Any]:
allowed = [p for p in projects if not p.get("deletedAt") and machine.allows(p)] allowed = [p for p in projects if machine.allows(p)]
for project in allowed: for project in allowed:
if key in (project["id"], project["title"], project["workspaceRoot"]): if key in (project["id"], project["title"], project["workspaceRoot"]):
return project return project
@@ -107,9 +112,7 @@ def _project_view(
for t in threads for t in threads
if t["projectId"] == project["id"] and not t.get("archivedAt") if t["projectId"] == project["id"] and not t.get("archivedAt")
] ]
running = sum( running = sum(1 for t in own if views.shell_state(t) in BUSY)
1 for t in own if (t.get("latestTurn") or {}).get("state") == "running"
)
default = project.get("defaultModelSelection") default = project.get("defaultModelSelection")
return { return {
"id": project["id"], "id": project["id"],
@@ -201,7 +204,7 @@ def build_server(
return [ return [
_project_view(p, shell["threads"]) _project_view(p, shell["threads"])
for p in shell["projects"] for p in shell["projects"]
if not p.get("deletedAt") and spec.allows(p) if spec.allows(p)
] ]
@mcp.tool @mcp.tool
@@ -273,12 +276,12 @@ def build_server(
if existing is None or existing["projectId"] != target["id"]: if existing is None or existing["projectId"] != target["id"]:
msg = f"thread {thread_id} is not in {target['title']} on {machine}" msg = f"thread {thread_id} is not in {target['title']} on {machine}"
raise ToolError(msg) raise ToolError(msg)
if (existing.get("latestTurn") or {}).get("state") == "running": if views.shell_state(existing) in BUSY:
msg = f"thread {thread_id} is running; t3_wait or t3_interrupt first" msg = f"thread {thread_id} is running; t3_wait or t3_interrupt first"
raise ToolError(msg) raise ToolError(msg)
tid = thread_id tid = thread_id
await _call( await _call(
client.dispatch(t3.turn_start(tid, text, selection.selection())) client.dispatch(t3.message_dispatch(tid, text, selection.selection()))
) )
else: else:
tid = t3.new_id() tid = t3.new_id()
@@ -287,7 +290,9 @@ def build_server(
t3.thread_create(tid, target["id"], heading, selection.selection()) t3.thread_create(tid, target["id"], heading, selection.selection())
) )
) )
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading))) await _call(
client.dispatch(t3.message_dispatch(tid, text, title_seed=heading))
)
registry.threads[tid] = machine registry.threads[tid] = machine
registry.tracker.track(tid, machine, target["title"], heading, watch=watch) registry.tracker.track(tid, machine, target["title"], heading, watch=watch)
return { return {
@@ -338,24 +343,22 @@ def build_server(
async def t3_thread( async def t3_thread(
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")], thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
turns: Annotated[ turns: Annotated[
int, Field(description="How many recent user turns to include", ge=1, le=20) int,
Field(
description="How many recent runs (user turns) to include", ge=1, le=20
),
] = 2, ] = 2,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""State of a thread without waiting. """State of a thread without waiting.
Turn state, last messages, tools run, files changed, pending questions. Turn state, last messages, tools run, files changed, pending questions.
""" """
machine, _ = await registry.locate(thread_id) machine, detail = await registry.locate(thread_id)
client = registry.client(machine) shell = await _call(registry.client(machine).shell())
detail = await _call(client.thread(thread_id, turn_limit=turns))
shell = await _call(client.shell())
if detail is None:
msg = f"thread {thread_id} vanished from {machine}"
raise ToolError(msg)
summary = views.summary( summary = views.summary(
machine, detail, project=views.project_title(shell, detail) machine, detail, project=views.project_title(shell, detail), turns=turns
) )
summary["pending"] = views.pending_in(shell, thread_id) summary["pending"] = views.pending_detail(detail["projection"])
return summary return summary
@mcp.tool @mcp.tool
@@ -378,23 +381,13 @@ def build_server(
machine, detail = await registry.locate(thread_id) machine, detail = await registry.locate(thread_id)
client = registry.client(machine) client = registry.client(machine)
started = time.monotonic() started = time.monotonic()
polls = 0 project = views.project_title(await _call(client.shell()), detail)
pending: dict[str, bool] | None = None
project: str | None = None
stalled = 0
while True: while True:
thread = detail["thread"] projection = detail["projection"]
state = views.state(thread) state = views.state(projection)
session = thread.get("session") or {} pending = views.pending_detail(projection)
status = session.get("status") stuck = views.latest_run(projection) is None and views.error(projection)
if polls % PENDING_EVERY == 0: done = state not in BUSY or pending is not None or stuck
shell = await _call(client.shell())
pending = views.pending_in(shell, thread_id)
project = views.project_title(shell, detail)
failed = status == "error" or bool(session.get("lastError"))
stalled = stalled + 1 if state == "starting" and status == "stopped" else 0
busy = (state in BUSY or status == "starting") and stalled < STALLED_POLLS
done = not busy or failed or pending is not None
timed_out = time.monotonic() - started >= timeout timed_out = time.monotonic() - started >= timeout
if done or timed_out: if done or timed_out:
summary = views.summary(machine, detail, project=project) summary = views.summary(machine, detail, project=project)
@@ -403,8 +396,7 @@ def build_server(
summary["waited"] = round(time.monotonic() - started, 1) summary["waited"] = round(time.monotonic() - started, 1)
return summary return summary
await asyncio.sleep(poll) await asyncio.sleep(poll)
polls += 1 refreshed = await _call(client.thread(thread_id))
refreshed = await _call(client.thread(thread_id, turn_limit=1))
if refreshed is None: if refreshed is None:
msg = f"thread {thread_id} vanished from {machine}" msg = f"thread {thread_id} vanished from {machine}"
raise ToolError(msg) raise ToolError(msg)
@@ -425,7 +417,7 @@ def build_server(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Answer the pending question of a thread; its turn resumes.""" """Answer the pending question of a thread; its turn resumes."""
machine, detail = await registry.locate(thread_id) machine, detail = await registry.locate(thread_id)
pending = views.question(detail) pending = views.question(detail["projection"])
if pending is None or not pending.get("request_id"): if pending is None or not pending.get("request_id"):
msg = f"thread {thread_id} has no pending question" msg = f"thread {thread_id} has no pending question"
raise ToolError(msg) raise ToolError(msg)
@@ -436,7 +428,9 @@ def build_server(
raise ToolError(msg) raise ToolError(msg)
await _call( await _call(
registry.client(machine).dispatch( registry.client(machine).dispatch(
t3.user_input_respond(thread_id, pending["request_id"], dict(answers)) t3.runtime_request_respond(
thread_id, pending["request_id"], dict(answers)
)
) )
) )
return { return {
@@ -452,17 +446,15 @@ def build_server(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Interrupt the running turn; continue later via t3_dispatch(thread_id=...).""" """Interrupt the running turn; continue later via t3_dispatch(thread_id=...)."""
machine, detail = await registry.locate(thread_id) machine, detail = await registry.locate(thread_id)
latest = detail["thread"].get("latestTurn") or {} latest = views.latest_run(detail["projection"])
if latest.get("state") != "running": if latest is None or latest["status"] not in BUSY:
return { return {
"thread_id": thread_id, "thread_id": thread_id,
"machine": machine, "machine": machine,
"state": latest.get("state", "idle"), "state": latest["status"] if latest else "idle",
} }
await _call( await _call(
registry.client(machine).dispatch( registry.client(machine).dispatch(t3.run_interrupt(thread_id, latest["id"]))
t3.turn_interrupt(thread_id, latest.get("turnId"))
)
) )
return {"thread_id": thread_id, "machine": machine, "state": "interrupting"} return {"thread_id": thread_id, "machine": machine, "state": "interrupting"}
@@ -474,6 +466,6 @@ async def _call(awaitable: Any) -> Any:
return await awaitable return await awaitable
except T3Error as exc: except T3Error as exc:
raise ToolError(str(exc)) from exc raise ToolError(str(exc)) from exc
except httpx.HTTPError as exc: except (httpx.HTTPError, websockets.WebSocketException, OSError) as exc:
msg = f"T3 unreachable: {exc}" msg = f"T3 unreachable: {exc}"
raise ToolError(msg) from exc raise ToolError(msg) from exc
+88 -43
View File
@@ -1,29 +1,45 @@
"""HTTP client for one T3 Code server (`packages/contracts/src/environmentHttp.ts`). """Client for one T3 Code server, orchestration protocol 2.
Four routes are used: the unauthenticated descriptor, the shell read-model Reads go over HTTP (`packages/contracts/src/environmentHttp.ts`): the
(projects + threads without bodies), one thread with a turn window, and unauthenticated descriptor, the shell read-model (projects + thread rows)
`dispatch` with a `ClientOrchestrationCommand`. The WebSocket RPC and one thread projection. Writes have no HTTP route since protocol 2: an
(`terminal.*` and friends) is deliberately not wrapped. `OrchestrationV2Command` goes through the WebSocket RPC
`orchestration.dispatchCommand` (`packages/contracts/src/orchestrationV2.ts`).
Every request names the protocol - a header over HTTP, a query parameter on
the WebSocket upgrade - or the server answers 400 / 426.
The token needs the `orchestration:read` and `orchestration:operate` scopes.
""" """
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from datetime import UTC, datetime from typing import TYPE_CHECKING, Any
from typing import Any
import httpx import httpx
import websockets
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
RpcCall = Callable[[str, dict[str, Any]], Awaitable[Any]]
DESCRIPTOR = "/.well-known/t3/environment" DESCRIPTOR = "/.well-known/t3/environment"
SHELL = "/api/orchestration/shell" SHELL = "/api/orchestration/shell"
THREADS = "/api/orchestration/threads" THREADS = "/api/orchestration/threads"
DISPATCH = "/api/orchestration/dispatch"
WS_TICKET = "/api/auth/websocket-ticket" WS_TICKET = "/api/auth/websocket-ticket"
WS_PATH = "/ws" WS_PATH = "/ws"
PROTOCOL = "2"
PROTOCOL_HEADER = "x-t3-orchestration-protocol"
PROTOCOL_PARAM = "orchestrationProtocol"
SUBSCRIBE_SHELL = "orchestration.subscribeShell" SUBSCRIBE_SHELL = "orchestration.subscribeShell"
DISPATCH_COMMAND = "orchestration.dispatchCommand"
RUNTIME_MODE = "full-access" RUNTIME_MODE = "full-access"
INTERACTION_MODE = "default" INTERACTION_MODE = "default"
CREATED_BY = "agent"
CREATION_SOURCE = "mcp"
PROBE_TIMEOUT = 5.0 PROBE_TIMEOUT = 5.0
@@ -32,17 +48,14 @@ class T3Error(Exception):
self.status = status self.status = status
self.body = body self.body = body
detail = ( detail = (
f"{body.get('code')}: {body.get('reason') or body.get('message')}" f"{body.get('code') or body.get('_tag')}: "
f"{body.get('reason') or body.get('detail') or body.get('message')}"
if isinstance(body, dict) if isinstance(body, dict)
else str(body)[:300] else str(body)[:300]
) )
super().__init__(f"T3 responded {status} - {detail}") super().__init__(f"T3 responded {status} - {detail}")
def now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def new_id() -> str: def new_id() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -52,6 +65,8 @@ def thread_create(
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"type": "thread.create", "type": "thread.create",
"createdBy": CREATED_BY,
"creationSource": CREATION_SOURCE,
"commandId": new_id(), "commandId": new_id(),
"threadId": thread_id, "threadId": thread_id,
"projectId": project_id, "projectId": project_id,
@@ -61,29 +76,25 @@ def thread_create(
"interactionMode": INTERACTION_MODE, "interactionMode": INTERACTION_MODE,
"branch": None, "branch": None,
"worktreePath": None, "worktreePath": None,
"createdAt": now_iso(),
} }
def turn_start( def message_dispatch(
thread_id: str, thread_id: str,
text: str, text: str,
selection: dict[str, Any] | None = None, selection: dict[str, Any] | None = None,
title_seed: str | None = None, title_seed: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
command: dict[str, Any] = { command: dict[str, Any] = {
"type": "thread.turn.start", "type": "message.dispatch",
"createdBy": CREATED_BY,
"creationSource": CREATION_SOURCE,
"commandId": new_id(), "commandId": new_id(),
"threadId": thread_id, "threadId": thread_id,
"message": { "messageId": new_id(),
"messageId": new_id(), "text": text,
"role": "user", "attachments": [],
"text": text, "dispatchMode": {"type": "start_immediately"},
"attachments": [],
},
"runtimeMode": RUNTIME_MODE,
"interactionMode": INTERACTION_MODE,
"createdAt": now_iso(),
} }
if selection is not None: if selection is not None:
command["modelSelection"] = selection command["modelSelection"] = selection
@@ -92,31 +103,38 @@ def turn_start(
return command return command
def turn_interrupt(thread_id: str, turn_id: str | None = None) -> dict[str, Any]: def run_interrupt(thread_id: str, run_id: str) -> dict[str, Any]:
command: dict[str, Any] = { return {
"type": "thread.turn.interrupt", "type": "run.interrupt",
"commandId": new_id(), "commandId": new_id(),
"threadId": thread_id, "threadId": thread_id,
"createdAt": now_iso(), "runId": run_id,
} }
if turn_id:
command["turnId"] = turn_id
return command
def user_input_respond( def runtime_request_respond(
thread_id: str, request_id: str, answers: dict[str, Any] thread_id: str, request_id: str, answers: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"type": "thread.user-input.respond", "type": "runtime-request.respond",
"commandId": new_id(), "commandId": new_id(),
"threadId": thread_id, "threadId": thread_id,
"requestId": request_id, "requestId": request_id,
"answers": answers, "answers": answers,
"createdAt": now_iso(),
} }
def rpc_exit(message: dict[str, Any]) -> Any:
"""The value of an Effect RPC `Exit`, or a `T3Error` with its cause."""
exit_ = message.get("exit") or {}
if exit_.get("_tag") == "Success":
return exit_.get("value")
causes = exit_.get("cause") or [{}]
cause = causes[0] if isinstance(causes, list) else causes
body = cause.get("error") or {"message": str(cause.get("defect") or cause)[:300]}
raise T3Error(0, body)
class T3Client: class T3Client:
def __init__( def __init__(
self, self,
@@ -124,12 +142,15 @@ class T3Client:
token: str, token: str,
*, *,
transport: httpx.AsyncBaseTransport | None = None, transport: httpx.AsyncBaseTransport | None = None,
rpc: RpcCall | None = None,
timeout: float = 30.0, timeout: float = 30.0,
) -> None: ) -> None:
self.url = url.rstrip("/") self.url = url.rstrip("/")
self.timeout = timeout
self._rpc = rpc or self._ws_rpc
self._http = httpx.AsyncClient( self._http = httpx.AsyncClient(
base_url=self.url, base_url=self.url,
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}", PROTOCOL_HEADER: PROTOCOL},
timeout=timeout, timeout=timeout,
transport=transport, transport=transport,
) )
@@ -146,22 +167,46 @@ class T3Client:
async def shell(self) -> dict[str, Any]: async def shell(self) -> dict[str, Any]:
return self._json(await self._http.get(SHELL)) return self._json(await self._http.get(SHELL))
async def thread( async def thread(self, thread_id: str) -> dict[str, Any] | None:
self, thread_id: str, turn_limit: int | None = None """`{snapshotSequence, projection}` of a thread, None when it is unknown."""
) -> dict[str, Any] | None: response = await self._http.get(f"{THREADS}/{thread_id}")
params = {"turnLimit": turn_limit} if turn_limit else None
response = await self._http.get(f"{THREADS}/{thread_id}", params=params)
if response.status_code == httpx.codes.NOT_FOUND: if response.status_code == httpx.codes.NOT_FOUND:
return None return None
return self._json(response) return self._json(response)
async def dispatch(self, command: dict[str, Any]) -> dict[str, Any]: async def dispatch(self, command: dict[str, Any]) -> dict[str, Any]:
return self._json(await self._http.post(DISPATCH, json=command)) return await self._rpc(DISPATCH_COMMAND, command)
async def ws_url(self) -> str: async def ws_url(self) -> str:
ticket = self._json(await self._http.post(WS_TICKET))["ticket"] ticket = self._json(await self._http.post(WS_TICKET))["ticket"]
scheme = "wss" if self.url.startswith("https") else "ws" scheme = "wss" if self.url.startswith("https") else "ws"
return f"{scheme}://{self.url.split('://', 1)[1]}{WS_PATH}?wsTicket={ticket}" host = self.url.split("://", 1)[1]
return (
f"{scheme}://{host}{WS_PATH}?wsTicket={ticket}&{PROTOCOL_PARAM}={PROTOCOL}"
)
async def _ws_rpc(self, tag: str, payload: dict[str, Any]) -> Any:
url = await self.ws_url()
async with websockets.connect(
url, max_size=None, open_timeout=self.timeout
) as ws:
request = {
"_tag": "Request",
"id": "1",
"tag": tag,
"payload": payload,
"headers": [],
}
await ws.send(json.dumps(request))
async for raw in ws:
decoded = json.loads(raw)
for message in decoded if isinstance(decoded, list) else [decoded]:
if message.get("_tag") == "Exit":
return rpc_exit(message)
if message.get("_tag") in ("Defect", "ClientProtocolError"):
raise T3Error(0, str(message)[:300])
msg = f"{tag}: socket closed before the result"
raise T3Error(0, msg)
@staticmethod @staticmethod
def _json(response: httpx.Response) -> dict[str, Any]: def _json(response: httpx.Response) -> dict[str, Any]:
+155 -83
View File
@@ -1,4 +1,10 @@
"""Read-model helpers shared by the tools and the watcher.""" """Read-model helpers shared by the tools and the watcher.
A thread detail is `{snapshotSequence, projection}`; the projection holds
runs (one per user message), messages, turn items, runtime requests and
checkpoints. A shell row carries the latest run's `status`, `lastError` and
the newest `pendingRuntimeRequest`.
"""
from __future__ import annotations from __future__ import annotations
@@ -8,122 +14,188 @@ from t3code_mcp.config import ModelSpec
TEXT_MAX = 6000 TEXT_MAX = 6000
TOOLS_MAX = 12 TOOLS_MAX = 12
BUSY = frozenset({"running", "starting"}) BUSY = frozenset({"preparing", "queued", "starting", "running", "waiting"})
TOOL_ITEMS = frozenset(
{"command_execution", "dynamic_tool", "file_change", "web_search", "subagent"}
)
NOT_APPROVALS = frozenset({"user_input", "dynamic_tool_call", "auth_refresh"})
def state(thread: dict[str, Any]) -> str: def runs(projection: dict[str, Any]) -> list[dict[str, Any]]:
latest = thread.get("latestTurn") return sorted(projection.get("runs", []), key=lambda r: r["ordinal"])
def latest_run(projection: dict[str, Any]) -> dict[str, Any] | None:
held = [r for r in runs(projection) if not r.get("queueHeld")]
return held[-1] if held else None
def state(projection: dict[str, Any]) -> str:
latest = latest_run(projection)
if latest: if latest:
return latest["state"] return latest["status"]
messages = thread.get("messages", []) messages = projection.get("messages", [])
return "starting" if messages and messages[-1]["role"] == "user" else "idle" return "starting" if messages and messages[-1]["role"] == "user" else "idle"
def shell_state(shell_thread: dict[str, Any]) -> str: def shell_state(shell_thread: dict[str, Any]) -> str:
latest = shell_thread.get("latestTurn") return shell_thread.get("status") or "idle"
return latest["state"] if latest else "starting"
def pending(shell_thread: dict[str, Any]) -> dict[str, bool] | None: def _flags(request_kind: str | None, *, plan: bool) -> dict[str, bool] | None:
flags = { flags = {
"approval": bool(shell_thread.get("hasPendingApprovals")), "approval": request_kind is not None and request_kind not in NOT_APPROVALS,
"user_input": bool(shell_thread.get("hasPendingUserInput")), "user_input": request_kind == "user_input",
"plan": bool(shell_thread.get("hasActionableProposedPlan")), "plan": plan,
} }
return flags if any(flags.values()) else None return flags if any(flags.values()) else None
def pending_in(shell: dict[str, Any], thread_id: str) -> dict[str, bool] | None: def pending(shell_thread: dict[str, Any]) -> dict[str, bool] | None:
for thread in shell.get("threads", []): request = shell_thread.get("pendingRuntimeRequest") or {}
if thread["id"] == thread_id: return _flags(
return pending(thread) request.get("kind"), plan=bool(shell_thread.get("hasActionableProposedPlan"))
return None )
def question(detail: dict[str, Any]) -> dict[str, Any] | None: def pending_detail(projection: dict[str, Any]) -> dict[str, bool] | None:
"""The unanswered `user-input.requested` of a thread, newest first.""" requests = [
for activity in reversed(detail["thread"].get("activities", [])): r for r in projection.get("runtimeRequests", []) if r["status"] == "pending"
kind = activity.get("kind")
if kind == "user-input.resolved":
return None
if kind == "user-input.requested":
payload = activity.get("payload") or {}
return {
"request_id": payload.get("requestId"),
"questions": [
{
"id": q["id"],
"header": q.get("header"),
"question": q["question"],
"options": [
{"label": o["label"], "description": o.get("description")}
for o in q.get("options", [])
],
"multi_select": bool(q.get("multiSelect")),
}
for q in payload.get("questions", [])
],
}
return None
def project_title(shell: dict[str, Any], detail: dict[str, Any]) -> str | None:
project_id = detail["thread"]["projectId"]
return next((p["title"] for p in shell["projects"] if p["id"] == project_id), None)
def summary(
machine: str, detail: dict[str, Any], *, project: str | None = None
) -> dict[str, Any]:
thread = detail["thread"]
latest = thread.get("latestTurn") or {}
session = thread.get("session") or {}
messages = [
{"role": m["role"], "text": m["text"][-TEXT_MAX:], "streaming": m["streaming"]}
for m in thread.get("messages", [])
if m.get("text")
] ]
tools = [ plan = any(
{ p.get("kind") == "proposed_plan" and p.get("status") == "active"
"summary": a["summary"], for p in projection.get("plans", [])
"detail": str((a.get("payload") or {}).get("detail", ""))[:200], )
} return _flags(requests[-1]["kind"] if requests else None, plan=plan)
for a in thread.get("activities", [])
if a.get("kind") == "tool.completed"
][-TOOLS_MAX:] def question(projection: dict[str, Any]) -> dict[str, Any] | None:
files = [ """The pending user-input request of a thread with its questions."""
{"path": f["path"], "kind": f["kind"], "+": f["additions"], "-": f["deletions"]} request = next(
for c in thread.get("checkpoints", [])[-1:]
for f in c.get("files", [])
]
context = next(
( (
a["payload"] r
for a in reversed(thread.get("activities", [])) for r in reversed(projection.get("runtimeRequests", []))
if a.get("kind") == "context-window.updated" if r["status"] == "pending" and r["kind"] == "user_input"
), ),
None, None,
) )
if request is None:
return None
item = next(
(
i
for i in projection.get("turnItems", [])
if i.get("type") == "user_input_request" and i["requestId"] == request["id"]
),
{},
)
return {
"request_id": request["id"],
"questions": [
{
"id": q["id"],
"header": q.get("header"),
"question": q["question"],
"options": [
{"label": o["label"], "description": o.get("description")}
for o in q.get("options", [])
],
"multi_select": bool(q.get("multiSelect")),
}
for q in item.get("questions", [])
],
}
def project_title(shell: dict[str, Any], detail: dict[str, Any]) -> str | None:
project_id = detail["projection"]["thread"]["projectId"]
return next((p["title"] for p in shell["projects"] if p["id"] == project_id), None)
def error(projection: dict[str, Any]) -> str | None:
latest = latest_run(projection)
if latest and latest["status"] == "failed":
failure = next(
(
i["failure"]["message"]
for i in reversed(projection.get("turnItems", []))
if i.get("type") == "error" and i.get("runId") == latest["id"]
),
None,
)
if failure:
return failure
sessions = projection.get("providerSessions", [])
return sessions[-1].get("lastError") if sessions else None
def _tool(item: dict[str, Any]) -> dict[str, Any]:
kind = item["type"]
if kind == "command_execution":
detail = item.get("input", "")
elif kind == "file_change":
detail = item.get("fileName", "")
elif kind == "subagent":
detail = item.get("prompt", "")
else:
detail = item.get("toolName") or item.get("input") or ""
return {"summary": item.get("title") or kind, "detail": str(detail)[:200]}
def summary(
machine: str, detail: dict[str, Any], *, project: str | None = None, turns: int = 1
) -> dict[str, Any]:
projection = detail["projection"]
thread = projection["thread"]
window = {r["id"] for r in runs(projection)[-turns:]}
latest = latest_run(projection)
messages = [
{"role": m["role"], "text": m["text"][-TEXT_MAX:], "streaming": m["streaming"]}
for m in projection.get("messages", [])
if m.get("text") and m.get("runId") in window
]
items = projection.get("turnItems", [])
tools = [
_tool(i)
for i in items
if i.get("type") in TOOL_ITEMS
and i.get("status") == "completed"
and i.get("runId") in window
][-TOOLS_MAX:]
files = [
{"path": f["path"], "kind": f["kind"], "+": f["additions"], "-": f["deletions"]}
for c in projection.get("checkpoints", [])[-1:]
for f in c.get("files", [])
]
usage = next(
(
t["tokenUsage"]
for t in reversed(projection.get("providerTurns", []))
if t.get("tokenUsage")
),
{},
)
sessions = projection.get("providerSessions", [])
return { return {
"thread_id": thread["id"], "thread_id": thread["id"],
"machine": machine, "machine": machine,
"project": project or thread["projectId"], "project": project or thread["projectId"],
"title": thread["title"], "title": thread["title"],
"model": str(ModelSpec.from_selection(thread["modelSelection"])), "model": str(ModelSpec.from_selection(thread["modelSelection"])),
"state": state(thread), "state": state(projection),
"turn_id": latest.get("turnId"), "run_id": latest["id"] if latest else None,
"session": session.get("status"), "session": sessions[-1]["status"] if sessions else None,
"error": session.get("lastError"), "error": error(projection),
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"files": files, "files": files,
"context_tokens": (context or {}).get("usedTokens"), "context_tokens": usage.get("usedTokens"),
"question": question(detail), "question": question(projection),
} }
def last_answer(detail: dict[str, Any]) -> str: def last_answer(detail: dict[str, Any]) -> str:
for m in reversed(detail["thread"].get("messages", [])): for m in reversed(detail["projection"].get("messages", [])):
if m["role"] == "assistant" and m.get("text"): if m["role"] == "assistant" and m.get("text"):
return m["text"] return m["text"]
return "" return ""
+13 -10
View File
@@ -2,8 +2,9 @@
One `orchestration.subscribeShell` stream per machine (Effect RPC over JSON: One `orchestration.subscribeShell` stream per machine (Effect RPC over JSON:
`Request` → `Chunk`s acknowledged with `Ack`, `Ping` keepalive). The shell `Request` → `Chunk`s acknowledged with `Ack`, `Ping` keepalive). The shell
read-model carries everything a transition needs - `latestTurn.state`, row carries everything a transition needs - the latest run's `status`,
`session`, `hasPendingUserInput` - so no per-thread subscription is required. `lastError`, `pendingRuntimeRequest` - so no per-thread subscription is
required.
Reconnects resume with `afterSequence`; a fresh snapshot re-derives every Reconnects resume with `afterSequence`; a fresh snapshot re-derives every
tracked thread, so transitions missed while offline are still reported. tracked thread, so transitions missed while offline are still reported.
@@ -39,7 +40,9 @@ EventKind = Literal["completed", "interrupted", "failed", "question", "approval"
TERMINAL: dict[str, EventKind] = { TERMINAL: dict[str, EventKind] = {
"completed": "completed", "completed": "completed",
"interrupted": "interrupted", "interrupted": "interrupted",
"error": "failed", "cancelled": "interrupted",
"rolled_back": "interrupted",
"failed": "failed",
} }
KEEP_DAYS = 14 KEEP_DAYS = 14
PING_EVERY = 20.0 PING_EVERY = 20.0
@@ -81,7 +84,7 @@ class Tracker:
for k, v in raw.get("threads", {}).items() for k, v in raw.get("threads", {}).items()
} }
self.outbox = raw.get("outbox", []) self.outbox = raw.get("outbox", [])
self.sequences = raw.get("sequences", {}) self.sequences = raw.get("shell_sequences", {})
def save(self) -> None: def save(self) -> None:
if self.path is None: if self.path is None:
@@ -92,7 +95,7 @@ class Tracker:
payload = { payload = {
"threads": {k: asdict(v) for k, v in self.threads.items()}, "threads": {k: asdict(v) for k, v in self.threads.items()},
"outbox": self.outbox, "outbox": self.outbox,
"sequences": self.sequences, "shell_sequences": self.sequences,
} }
tmp = self.path.with_suffix(".tmp") tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
@@ -124,9 +127,11 @@ class Tracker:
if tracked is None or tracked.machine != machine: if tracked is None or tracked.machine != machine:
return [] return []
state = views.shell_state(shell_thread) state = views.shell_state(shell_thread)
if state == "idle" and tracked.state in views.BUSY:
state = tracked.state
flags = views.pending(shell_thread) or {} flags = views.pending(shell_thread) or {}
pending = next((k for k in ("user_input", "approval") if flags.get(k)), None) pending = next((k for k in ("user_input", "approval") if flags.get(k)), None)
error = (shell_thread.get("session") or {}).get("lastError") error = shell_thread.get("lastError")
events: list[Event] = [] events: list[Event] = []
if pending and pending != tracked.pending: if pending and pending != tracked.pending:
kind: EventKind = "question" if pending == "user_input" else "approval" kind: EventKind = "question" if pending == "user_input" else "approval"
@@ -298,7 +303,7 @@ class Watcher:
self.tracker.sequences[name] = snapshot["snapshotSequence"] self.tracker.sequences[name] = snapshot["snapshotSequence"]
for thread in snapshot["threads"]: for thread in snapshot["threads"]:
await self._events(self.tracker.apply(name, thread)) await self._events(self.tracker.apply(name, thread))
elif kind == "thread-upserted": elif kind == "thread.updated":
self.tracker.sequences[name] = item["sequence"] self.tracker.sequences[name] = item["sequence"]
await self._events(self.tracker.apply(name, item["thread"])) await self._events(self.tracker.apply(name, item["thread"]))
elif "sequence" in item: elif "sequence" in item:
@@ -323,9 +328,7 @@ class Watcher:
"error": tracked.error, "error": tracked.error,
} }
try: try:
detail = await self.registry.client(event.machine).thread( detail = await self.registry.client(event.machine).thread(event.thread_id)
event.thread_id, turn_limit=1
)
except (httpx.HTTPError, t3.T3Error) as exc: except (httpx.HTTPError, t3.T3Error) as exc:
payload["detail_error"] = str(exc)[:200] payload["detail_error"] = str(exc)[:200]
return payload return payload
+138 -104
View File
@@ -8,6 +8,7 @@ from fastmcp.exceptions import ToolError
from t3code_mcp.config import Machine, ModelSpec, load_machines from t3code_mcp.config import Machine, ModelSpec, load_machines
from t3code_mcp.server import Registry, build_server from t3code_mcp.server import Registry, build_server
from t3code_mcp.t3 import T3Error
class FakeT3: class FakeT3:
@@ -30,7 +31,7 @@ class FakeT3:
200, 200,
json={ json={
"label": self.label, "label": self.label,
"serverVersion": "0.0.36", "serverVersion": "0.1.0",
"platform": {"os": "linux"}, "platform": {"os": "linux"},
}, },
) )
@@ -38,6 +39,8 @@ class FakeT3:
return httpx.Response( return httpx.Response(
401, json={"code": "auth_invalid", "reason": "missing_credential"} 401, json={"code": "auth_invalid", "reason": "missing_credential"}
) )
if request.headers.get("x-t3-orchestration-protocol") != "2":
return httpx.Response(400, json={"code": "invalid_request"})
if path == "/api/orchestration/shell": if path == "/api/orchestration/shell":
return httpx.Response(200, json=self._shell()) return httpx.Response(200, json=self._shell())
if path.startswith("/api/orchestration/threads/"): if path.startswith("/api/orchestration/threads/"):
@@ -48,100 +51,127 @@ class FakeT3:
) )
self._tick(thread_id) self._tick(thread_id)
return httpx.Response( return httpx.Response(
200, json={"snapshotSequence": 1, "thread": self.threads[thread_id]} 200, json={"snapshotSequence": 1, "projection": 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"}) return httpx.Response(404, json={"code": "not_found"})
async def rpc(self, tag: str, payload: dict[str, Any]) -> Any:
assert tag == "orchestration.dispatchCommand"
self.commands.append(payload)
return self._dispatch(payload)
def _shell(self) -> dict[str, Any]: def _shell(self) -> dict[str, Any]:
threads = [ threads = []
{ for projection in self.threads.values():
k: v thread = projection["thread"]
for k, v in t.items() runs = projection["runs"]
if k not in ("messages", "activities", "checkpoints") pending = [
} r for r in projection["runtimeRequests"] if r["status"] == "pending"
| {"hasPendingUserInput": self.pending_input, "hasPendingApprovals": False} ]
for t in self.threads.values() threads.append(
] {
return { "id": thread["id"],
"snapshotSequence": 1, "projectId": thread["projectId"],
"projects": self.projects, "title": thread["title"],
"threads": threads, "archivedAt": None,
"updatedAt": "now", "status": runs[-1]["status"] if runs else "idle",
} "lastError": self.fail_with,
"pendingRuntimeRequest": (
{"id": pending[-1]["id"], "kind": pending[-1]["kind"]}
if pending
else None
),
"hasActionableProposedPlan": False,
}
)
return {"snapshotSequence": 1, "projects": self.projects, "threads": threads}
def _tick(self, thread_id: str) -> None: def _tick(self, thread_id: str) -> None:
thread = self.threads[thread_id] projection = self.threads[thread_id]
if self.fail_with and thread.get("_pending_turn"): if self.pending_input and not projection["runtimeRequests"]:
thread["session"] = {"status": "stopped", "lastError": self.fail_with} projection["runtimeRequests"].append(
{"id": "req-0", "kind": "user_input", "status": "pending"}
)
if not projection["runs"]:
return return
if thread.get("_pending_turn"): run = projection["runs"][-1]
thread["latestTurn"] = { if self.fail_with and run["status"] == "running":
"turnId": thread.pop("_pending_turn"), run["status"] = "failed"
"state": "running", projection["turnItems"].append(
} {
"type": "error",
"runId": run["id"],
"status": "failed",
"failure": {"message": self.fail_with},
}
)
return return
latest = thread["latestTurn"] if run["status"] == "running":
if latest and latest["state"] == "running": run["_polls"] = run.get("_polls", 0) + 1
latest["_polls"] = latest.get("_polls", 0) + 1 if run["_polls"] > self.polls_until_done:
if latest["_polls"] > self.polls_until_done: run["status"] = "completed"
latest["state"] = "completed" projection["messages"].append(
thread["messages"].append( {
{"role": "assistant", "text": "done: report", "streaming": False} "role": "assistant",
"text": "done: report",
"streaming": False,
"runId": run["id"],
}
) )
thread["session"]["status"] = "ready"
def _dispatch(self, command: dict[str, Any]) -> httpx.Response: def _dispatch(self, command: dict[str, Any]) -> dict[str, Any]:
kind = command["type"] kind = command["type"]
if kind == "thread.create": if kind == "thread.create":
self.threads[command["threadId"]] = { self.threads[command["threadId"]] = {
"id": command["threadId"], "thread": {
"projectId": command["projectId"], "id": command["threadId"],
"title": command["title"], "projectId": command["projectId"],
"modelSelection": command["modelSelection"], "title": command["title"],
"runtimeMode": command["runtimeMode"], "modelSelection": command["modelSelection"],
"latestTurn": None, "runtimeMode": command["runtimeMode"],
"session": {"status": "idle", "lastError": None}, "archivedAt": None,
},
"runs": [],
"messages": [], "messages": [],
"activities": [], "turnItems": [],
"runtimeRequests": [],
"checkpoints": [], "checkpoints": [],
"archivedAt": None, "providerSessions": [],
"updatedAt": "now", "providerTurns": [],
"plans": [],
} }
elif kind == "thread.turn.start": elif kind == "message.dispatch":
thread = self.threads[command["threadId"]] assert command["dispatchMode"] == {"type": "start_immediately"}
thread["messages"].append( projection = self.threads[command["threadId"]]
{"role": "user", "text": command["message"]["text"], "streaming": False} run_id = f"run-{len(projection['runs']) + 1}"
) projection["runs"].append(
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", "id": run_id,
"summary": "User input submitted", "ordinal": len(projection["runs"]) + 1,
"tone": "info", "status": "running",
"payload": {
"requestId": command["requestId"],
"answers": command["answers"],
},
} }
) )
else: projection["messages"].append(
return httpx.Response( {
400, json={"code": "invalid_request", "reason": "invalid_command"} "role": "user",
"text": command["text"],
"streaming": False,
"runId": run_id,
}
) )
return httpx.Response(200, json={"sequence": len(self.commands)}) if "modelSelection" in command:
projection["thread"]["modelSelection"] = command["modelSelection"]
elif kind == "run.interrupt":
run = self.threads[command["threadId"]]["runs"][-1]
assert run["id"] == command["runId"]
run["status"] = "interrupted"
elif kind == "runtime-request.respond":
for request in self.threads[command["threadId"]]["runtimeRequests"]:
if request["id"] == command["requestId"]:
request["status"] = "resolved"
else:
raise T3Error(0, {"_tag": "OrchestrationV2DispatchCommandError"})
return {"sequence": len(self.commands)}
PROJECTS_MAC = [ PROJECTS_MAC = [
@@ -209,7 +239,7 @@ def registry(fakes: dict[str, FakeT3]) -> Registry:
projects=("/root/projects", "/root/projects/*"), projects=("/root/projects", "/root/projects/*"),
), ),
} }
return Registry(machines, transport=Router(fakes)) return Registry(machines, transport=Router(fakes), rpc=lambda name: fakes[name].rpc)
@pytest.fixture @pytest.fixture
@@ -254,7 +284,7 @@ async def test_dispatch_wait_and_thread(
assert started["title"] == "write README" assert started["title"] == "write README"
assert started["model"] == "claudeAgent/claude-opus-5 effort=high" assert started["model"] == "claudeAgent/claude-opus-5 effort=high"
kinds = [c["type"] for c in fakes["mac"].commands] kinds = [c["type"] for c in fakes["mac"].commands]
assert kinds == ["thread.create", "thread.turn.start"] assert kinds == ["thread.create", "message.dispatch"]
assert fakes["mac"].commands[0]["modelSelection"] == { assert fakes["mac"].commands[0]["modelSelection"] == {
"instanceId": "claudeAgent", "instanceId": "claudeAgent",
"model": "claude-opus-5", "model": "claude-opus-5",
@@ -303,8 +333,8 @@ async def test_follow_up_uses_same_thread(
assert again["thread_id"] == started["thread_id"] assert again["thread_id"] == started["thread_id"]
assert [c["type"] for c in fakes["mac"].commands] == [ assert [c["type"] for c in fakes["mac"].commands] == [
"thread.create", "thread.create",
"thread.turn.start", "message.dispatch",
"thread.turn.start", "message.dispatch",
] ]
@@ -346,7 +376,7 @@ async def test_wait_timeout_and_interrupt(
await client.call_tool("t3_interrupt", {"thread_id": started["thread_id"]}) await client.call_tool("t3_interrupt", {"thread_id": started["thread_id"]})
) )
assert stopped["state"] == "interrupting" assert stopped["state"] == "interrupting"
assert fakes["dell"].commands[-1]["type"] == "thread.turn.interrupt" assert fakes["dell"].commands[-1]["type"] == "run.interrupt"
view = data( view = data(
await client.call_tool("t3_thread", {"thread_id": started["thread_id"]}) await client.call_tool("t3_thread", {"thread_id": started["thread_id"]})
) )
@@ -439,7 +469,7 @@ async def test_wait_returns_on_runtime_failure(
"t3_wait", {"thread_id": started["thread_id"], "timeout": 5} "t3_wait", {"thread_id": started["thread_id"], "timeout": 5}
) )
) )
assert waited["state"] == "starting" assert waited["state"] == "failed"
assert waited["error"] == "Claude runtime stream failed." assert waited["error"] == "Claude runtime stream failed."
assert waited["waited"] < 1 assert waited["waited"] < 1
@@ -449,10 +479,12 @@ def shell_thread(
) -> dict[str, Any]: ) -> dict[str, Any]:
return { return {
"id": tid, "id": tid,
"latestTurn": {"turnId": "t", "state": state} if state else None, "status": state or "idle",
"session": {"status": "stopped" if error else "running", "lastError": error}, "lastError": error,
"hasPendingUserInput": user_input, "pendingRuntimeRequest": (
"hasPendingApprovals": False, {"id": "req-1", "kind": "user_input"} if user_input else None
),
"hasActionableProposedPlan": False,
} }
@@ -533,25 +565,27 @@ async def test_answer_pending_question(
await client.call_tool( await client.call_tool(
"t3_answer", {"thread_id": tid, "answers": {"q1": "yes"}} "t3_answer", {"thread_id": tid, "answers": {"q1": "yes"}}
) )
fakes["mac"].threads[tid]["activities"].append( projection = fakes["mac"].threads[tid]
projection["runtimeRequests"].append(
{"id": "req-1", "kind": "user_input", "status": "pending"}
)
projection["turnItems"].append(
{ {
"kind": "user-input.requested", "type": "user_input_request",
"summary": "User input requested", "runId": "run-1",
"tone": "info", "status": "waiting",
"payload": { "requestId": "req-1",
"requestId": "req-1", "questions": [
"questions": [ {
{ "id": "q1",
"id": "q1", "header": "Deploy",
"header": "Deploy", "question": "Deploy now?",
"question": "Deploy now?", "options": [
"options": [ {"label": "yes", "description": "go"},
{"label": "yes", "description": "go"}, {"label": "no", "description": "wait"},
{"label": "no", "description": "wait"}, ],
], }
} ],
],
},
} }
) )
view = data(await client.call_tool("t3_thread", {"thread_id": tid})) view = data(await client.call_tool("t3_thread", {"thread_id": tid}))
@@ -569,7 +603,7 @@ async def test_answer_pending_question(
assert answered["answered"] == ["q1"] assert answered["answered"] == ["q1"]
cmd = fakes["mac"].commands[-1] cmd = fakes["mac"].commands[-1]
assert ( assert (
cmd["type"] == "thread.user-input.respond" cmd["type"] == "runtime-request.respond"
and cmd["requestId"] == "req-1" and cmd["requestId"] == "req-1"
and cmd["answers"] == {"q1": "yes"} and cmd["answers"] == {"q1": "yes"}
) )