feat(*): t3code-mcp connector - machines, projects, dispatch, wait, interrupt
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
## Code Principles
|
||||||
|
- **Simplicity**: Write simple, straightforward code
|
||||||
|
- **Readability**: Make code easy to understand
|
||||||
|
- **Maintainability**: Write code that's easy to update
|
||||||
|
- **Less Code = Less Debt**: Minimize code footprint
|
||||||
|
- **NEVER write comments** - code should be self-documenting
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- `src/t3code_mcp` - FastMCP server: `config.py` (machines from TOML), `t3.py` (HTTP client for a T3 Code server), `server.py` (tools)
|
||||||
|
- `t3code.example.toml` - config template; tokens come from env only
|
||||||
|
- `docs/PROGRESS.md` - keep current
|
||||||
|
|
||||||
|
## Checking commands
|
||||||
|
After writing code, always run (or `make check`):
|
||||||
|
```shell
|
||||||
|
ruff format # python formatter
|
||||||
|
ruff check --fix # python linter
|
||||||
|
ty check # python type-checker
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
Dependencies: `uv add`, never edit lock files by hand.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "f=$(python3 -c 'import json,sys;print(json.load(sys.stdin).get(\"tool_input\",{}).get(\"file_path\",\"\"))'); case \"$f\" in *.py) uv run ruff format \"$f\" && uv run ruff check --fix \"$f\" ;; esac"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
|
t3code.toml
|
||||||
|
tests
|
||||||
|
docs
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
|
t3code.toml
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.20
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
types_or: [ python, pyi ]
|
||||||
|
args: [ --fix ]
|
||||||
|
- id: ruff-format
|
||||||
|
types_or: [ python, pyi ]
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ty
|
||||||
|
name: ty check
|
||||||
|
entry: uv run ty check
|
||||||
|
language: system
|
||||||
|
types_or: [ python, pyi ]
|
||||||
|
pass_filenames: false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
T3CODE_MCP_CONFIG=/config/t3code.toml
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"
|
||||||
|
|
||||||
|
CMD ["python", "-m", "t3code_mcp"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.PHONY: sync check fix run test
|
||||||
|
|
||||||
|
sync:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
check:
|
||||||
|
uv run ruff format --check
|
||||||
|
uv run ruff check
|
||||||
|
uv run ty check
|
||||||
|
uv run pytest -q
|
||||||
|
|
||||||
|
fix:
|
||||||
|
uv run ruff format
|
||||||
|
uv run ruff check --fix
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest -q
|
||||||
|
|
||||||
|
run:
|
||||||
|
uv run python -m t3code_mcp
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# t3code-mcp
|
||||||
|
|
||||||
|
MCP-коннектор к серверам [T3 Code](https://github.com/pingdotgg/t3code) (`архитектура.md` §5): диспетчер бобра запускает кодинг-треды на маке и на dell, ждёт их и читает результат. Ни шелла, ни терминала - только оркестрация по HTTP API T3 (`packages/contracts/src/environmentHttp.ts`).
|
||||||
|
|
||||||
|
## Тулзы
|
||||||
|
|
||||||
|
| Тулза | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `t3_machines()` | машины из конфига, кто сейчас online (`/.well-known/t3/environment`), allowlist проектов |
|
||||||
|
| `t3_projects(machine)` | проекты машины, прошедшие allowlist: путь, модель по умолчанию, число тредов |
|
||||||
|
| `t3_dispatch(machine, project, prompt, title?, model?, thread_id?)` | `thread.create` + `thread.turn.start` (или только `turn.start` в существующий тред) → `thread_id` сразу |
|
||||||
|
| `t3_thread(thread_id, turns?)` | состояние без ожидания: тёрн, сессия, последние сообщения, тулзы, файлы, `pending` (вопрос/аппрув) |
|
||||||
|
| `t3_wait(thread_id, timeout?)` | блокируется до `completed` / `interrupted` / `error`, вопроса или таймаута; та же вьюха |
|
||||||
|
| `t3_interrupt(thread_id)` | `thread.turn.interrupt` |
|
||||||
|
|
||||||
|
`t3_wait` - обычный долгий вызов, не MCP Task: Claude Code расширение Tasks не поддерживает, зато сам уводит вызов дольше двух минут в фоновую задачу (`CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`). Треды бегут в `full-access` без аппрувов; тред ищется по всем машинам, если коннектор перезапускался.
|
||||||
|
|
||||||
|
## Конфиг
|
||||||
|
|
||||||
|
`t3code.toml` (путь - `T3CODE_MCP_CONFIG`, в образе `/config/t3code.toml`), пример - `t3code.example.toml`. Токены - только из env (`token_env`), выпускаются на каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver-t3code-mcp`. `projects` - fnmatch по названию и по workspace root; что не совпало, для диспетчера не существует. Порт и адрес - `T3CODE_MCP_HOST` / `T3CODE_MCP_PORT` (8000, путь `/mcp`, `/healthz`).
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make sync # uv sync
|
||||||
|
make check # ruff format --check, ruff check (ALL), ty, pytest
|
||||||
|
make run # T3CODE_MCP_CONFIG=... uv run python -m t3code_mcp
|
||||||
|
uv run python scripts/smoke.py http://127.0.0.1:8000/mcp mac t3-smoke # живой диспатч + ожидание
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Сервис `t3code-mcp` в `beaver-agent/docker-compose.yml` (профиль `t3`), образ собирается из этой репы (`T3CODE_MCP_REF`), gateway подключает его как `McpServer.http(name="t3code", url="http://t3code-mcp:8000/mcp")` и отдаёт только диспетчеру.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# PROGRESS - t3code-mcp
|
||||||
|
|
||||||
|
> Что сделано, что нет, что проверить руками. Свежие записи сверху. Архитектурная правда - `архитектура.md` §5 (vault, симлинк в `../../архитектура.md`), журнал всей второй итерации - `../../PROGRESS.md`.
|
||||||
|
|
||||||
|
## 2026-08-29 - S10 (M6b): коннектор, две машины, dell как фоновые руки
|
||||||
|
|
||||||
|
Сделано: репа `beaver/t3code-mcp` (Python 3.13, FastMCP 3.4.7, httpx, pydantic-settings; ruff ALL + ty + pytest, `make check` чистый, 9 тестов на фейковом T3 через `httpx` transport). Тулзы `t3_machines`, `t3_projects`, `t3_dispatch` (`thread.create` + `thread.turn.start`, или только `turn.start` при `thread_id=`), `t3_thread`, `t3_wait`, `t3_interrupt` - по `packages/contracts/src/environmentHttp.ts` + `orchestration.ts` из `~/projects/playgrounds/t3code` (HEAD 2026-08-29, серверы 0.0.36). Конфиг `t3code.toml` (машины, `token_env`, allowlist fnmatch по названию и workspace root, модель по умолчанию), токены только из env. Проверено живьём: диспатч в `t3-smoke` на маке (`~/projects/playgrounds/t3-smoke`, заведён `t3 project add`) → `turn.completed` за 10 с, ответ и `smoke.txt` на месте; диспатч в `projects` на dell → Клод прочитал `~/.claude/CLAUDE.md`, склонировал `beaver/beaver-land` по ssh как `hh`, сделал `t3 project add`, проверил `gh` и токен Gitea - 25 с. Dell поднят как вторая машина: node 22 + `t3@0.0.36` (`npm -g`, нужен `build-essential` для node-pty), `claude` CLI 2.1.251, `uv`, `gh` (залогинен как haikesan), `t3 service install` (user-unit `t3code.service`, linger включён) с drop-in `EnvironmentFile=/root/.t3/service.env` (`T3CODE_HOST=100.76.140.93`, `T3CODE_PORT=3773`, `CLAUDE_CODE_OAUTH_TOKEN` из `beaver-agent/.env`, PATH с bun/uv, `IS_SANDBOX=1` - иначе Claude Code отказывает в `bypassPermissions` под root), `textGenerationModelSelection` → claudeAgent/sonnet (иначе заголовки тредов пытаются звать codex, которого нет). На dell: `/root/.claude/{CLAUDE.md,commands/commit.md,skills/komodo,settings.json}` (те же `/commit` и `komodo`, что на маке; CLAUDE.md - раскладка `~/projects/<org>/<repo>` как организации Gitea, правила пуша), `~/.gitconfig`, `~/.config/komodo/credentials`, `~/.config/gitea/token` (новый токен `dell-claude-2026-08`: repository/organization/issue/package), свой ssh-ключ `id_ed25519_gitea` в аккаунте hh (старый `id_ed25519` - read-only deploy key cars-demo), `/root/projects` заведён проектом `projects`. Токены `t3 auth session issue --ttl 365d --label beaver-t3code-mcp` с обеих машин записаны в `/root/beaver-agent/.env` как `T3_MAC_TOKEN`/`T3_DELL_TOKEN`, там же `COMPOSE_PROFILES=…,t3` и `T3CODE_MCP_REF=main`. В `beaver-agent`: сервис `t3code-mcp` в compose (профиль `t3`, образ из git), `t3code.toml`, `McpServer.http("t3code")` в `config.py`, отдаётся только диспетчеру; `.env.example` без `T3_URL`/`T3_TOKEN`. Скилл `мета/бобер/скиллы/диспетчер/t3code/SKILL.md` написан целиком.
|
||||||
|
|
||||||
|
Решения по ходу (легко откатить): (1) `t3_wait` - обычный долгий вызов, не MCP Task: расширение Tasks в 2026-07-28 вынесено в `io.modelcontextprotocol/tasks`, python-SDK 1.29 на 2025-11-25, Claude Code Tasks не поддерживает и сам уводит вызов > 2 мин в фон (`CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`); потолок ожидания 3600 с, опрос 5 с, флаги `pending` из shell-снапшота раз в 30 с. (2) Сразу после `turn.start` у треда ещё нет `latestTurn` - это состояние `starting`; `t3_wait` не выходит из него, пока не появится тёрн, сессия не упадёт (`lastError`) или тред не простоит `stopped` три опроса. (3) На маке отдельный `t3 serve` не поднимал: сервер десктопного T3 Code уже слушает `0.0.0.0:3773`, из tailnet и из контейнера на dell доступен по `http://100.65.207.48:3773`; жив, пока запущено приложение. Tailscale Serve/HTTPS не включал - внутри tailnet хватает http. (4) Треды без worktree (`branch`/`worktreePath` = null), в workspace root. (5) `t3_dispatch` умеет `thread_id=` для продолжения треда - шестая тулза из §5 не добавлялась, это параметр. (6) Dell работает под root (так устроен весь `/root/*`), граница - allowlist проектов и `IS_SANDBOX`; отдельный пользователь для t3 - если понадобится.
|
||||||
|
|
||||||
|
Не сделано: деплой (пуш `beaver-agent main:stable` - за h; после него `t3code-mcp` соберётся из Gitea `main`); ответ на `pending.user_input`/аппрувы через MCP (`thread.user-input.respond` не обёрнут - тред прерывается и продолжается новым `t3_dispatch(thread_id=)`); вложения; worktree-режим; codex на dell; pre-commit на dell не ставил. Расхождения с `архитектура.md` §5, не правил: «`t3 serve --tailscale-serve` на маке» - фактически сервер десктопа по http в tailnet; «Tasks вместо `t3_wait`» - проверено, неприменимо (см. выше); `t3_thread(thread_id)` без машины - тред ищется по всем машинам.
|
||||||
|
|
||||||
|
Проверить руками:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd t3code-mcp && make check
|
||||||
|
# живой диспатч с мака (токен - `t3 auth session issue --token-only --ttl 1h`):
|
||||||
|
T3_MAC_TOKEN=... T3CODE_MCP_CONFIG=t3code.example.toml uv run python -m t3code_mcp # в другом окне
|
||||||
|
uv run python scripts/smoke.py http://127.0.0.1:8000/mcp mac t3-smoke
|
||||||
|
# dell:
|
||||||
|
ssh root@100.76.140.93 'XDG_RUNTIME_DIR=/run/user/0 systemctl --user status t3code.service | head -5; curl -s http://100.76.140.93:3773/.well-known/t3/environment'
|
||||||
|
# после `make deploy` в beaver-agent:
|
||||||
|
ssh root@100.76.140.93 'docker logs beaver-t3code-mcp --tail 5; docker exec beaver-gateway python -c "import urllib.request;print(urllib.request.urlopen(\"http://t3code-mcp:8000/healthz\").read())"'
|
||||||
|
# в телеге: «запусти на dell в проекте projects: …» → диспетчер зовёт t3_dispatch, t3_wait
|
||||||
|
```
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
[project]
|
||||||
|
name = "t3code-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP connector to T3 Code servers: dispatch coding threads on Бобёр's machines"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "h", email = "h@kotikot.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=3.4.7",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"pydantic>=2.13",
|
||||||
|
"pydantic-settings>=2.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
t3code-mcp = "t3code_mcp:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.14,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9",
|
||||||
|
"pytest-asyncio>=1.3",
|
||||||
|
"ruff>=0.15.13",
|
||||||
|
"ty>=0.0.37",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"D203",
|
||||||
|
"D212",
|
||||||
|
"COM812",
|
||||||
|
"T201",
|
||||||
|
"D1",
|
||||||
|
"PLC0415",
|
||||||
|
"ANN401",
|
||||||
|
"PLR0913",
|
||||||
|
"PLR2004",
|
||||||
|
"C901",
|
||||||
|
"PLR0911",
|
||||||
|
"PLR0912",
|
||||||
|
"PLR0915",
|
||||||
|
"PLR0917",
|
||||||
|
"CPY001",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"S104",
|
||||||
|
]
|
||||||
|
unfixable = ["F401"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["ALL"]
|
||||||
|
"scripts/*" = ["INP001", "T201"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
split-on-trailing-comma = false
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
docstring-code-format = true
|
||||||
|
skip-magic-trailing-comma = true
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Live smoke against a running t3code-mcp: dispatch into a project and wait.
|
||||||
|
|
||||||
|
uv run python scripts/smoke.py http://127.0.0.1:8000/mcp mac t3-smoke
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
|
PROMPT = (
|
||||||
|
"Smoke test from t3code-mcp. Create or overwrite `smoke.txt` in the repo root "
|
||||||
|
"with one line: the current date and the word `ok`. Do not commit. Reply with "
|
||||||
|
"the exact line you wrote."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show(label: str, result: object) -> None:
|
||||||
|
print(f"\n== {label}\n{json.dumps(result, ensure_ascii=False, indent=1)[:3000]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(url: str, machine: str, project: str) -> None:
|
||||||
|
async with Client(url) as client:
|
||||||
|
machines = (await client.call_tool("t3_machines")).structured_content
|
||||||
|
show("t3_machines", machines)
|
||||||
|
projects = (
|
||||||
|
await client.call_tool("t3_projects", {"machine": machine})
|
||||||
|
).structured_content
|
||||||
|
show("t3_projects", projects)
|
||||||
|
started = (
|
||||||
|
await client.call_tool(
|
||||||
|
"t3_dispatch",
|
||||||
|
{"machine": machine, "project": project, "prompt": PROMPT},
|
||||||
|
)
|
||||||
|
).structured_content
|
||||||
|
show("t3_dispatch", started)
|
||||||
|
thread_id = started["thread_id"]
|
||||||
|
waited = (
|
||||||
|
await client.call_tool("t3_wait", {"thread_id": thread_id, "timeout": 600})
|
||||||
|
).structured_content
|
||||||
|
show("t3_wait", waited)
|
||||||
|
print("\nSTATE:", waited["state"], "waited:", waited["waited"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main(*sys.argv[1:4]))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from t3code_mcp.config import Settings, load_machines
|
||||||
|
from t3code_mcp.server import Registry, build_server
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
||||||
|
settings = Settings()
|
||||||
|
machines = load_machines(settings.config)
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"t3code-mcp: machines %s, listening on %s:%s",
|
||||||
|
list(machines),
|
||||||
|
settings.host,
|
||||||
|
settings.port,
|
||||||
|
)
|
||||||
|
server = build_server(Registry(machines))
|
||||||
|
server.run(transport="http", host=settings.host, port=settings.port, path="/mcp")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from t3code_mcp import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Machines and their project allowlists, from TOML; tokens only from env.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[machines.mac]
|
||||||
|
url = "http://100.65.207.48:3773"
|
||||||
|
token_env = "T3_MAC_TOKEN"
|
||||||
|
projects = ["t3-smoke", "/Users/h/projects/openprise/beaver/*"]
|
||||||
|
model = "claudeAgent/claude-opus-5"
|
||||||
|
options = { effort = "high", contextWindow = "1m" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`projects` entries are `fnmatch` patterns matched against a project's title
|
||||||
|
and its workspace root. A machine without `model` uses the project default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSpec(BaseModel):
|
||||||
|
"""`instance/model` plus provider options, as T3's `ModelSelection`."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
instance: str
|
||||||
|
model: str
|
||||||
|
options: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, text: str, options: dict[str, str] | None = None) -> Self:
|
||||||
|
instance, sep, model = text.partition("/")
|
||||||
|
if not sep or not instance or not model:
|
||||||
|
msg = f"model must be `instance/model`, got {text!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return cls(instance=instance, model=model, options=options or {})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_selection(cls, selection: dict[str, Any]) -> Self:
|
||||||
|
options = {o["id"]: str(o["value"]) for o in selection.get("options") or []}
|
||||||
|
return cls(
|
||||||
|
instance=selection["instanceId"], model=selection["model"], options=options
|
||||||
|
)
|
||||||
|
|
||||||
|
def selection(self) -> dict[str, Any]:
|
||||||
|
wire: dict[str, Any] = {"instanceId": self.instance, "model": self.model}
|
||||||
|
if self.options:
|
||||||
|
wire["options"] = [{"id": k, "value": v} for k, v in self.options.items()]
|
||||||
|
return wire
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
suffix = "".join(f" {k}={v}" for k, v in self.options.items())
|
||||||
|
return f"{self.instance}/{self.model}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
class Machine(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
token_env: str
|
||||||
|
projects: tuple[str, ...] = ()
|
||||||
|
model: ModelSpec | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _fold_model(cls, data: Any) -> Any:
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("model"), str):
|
||||||
|
data = {
|
||||||
|
**data,
|
||||||
|
"model": ModelSpec.parse(data["model"], data.pop("options", None)),
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
token = os.environ.get(self.token_env, "").strip()
|
||||||
|
if not token:
|
||||||
|
msg = f"machine {self.name!r}: env {self.token_env} is empty"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def allows(self, project: dict[str, Any]) -> bool:
|
||||||
|
candidates = (project.get("title", ""), project.get("workspaceRoot", ""))
|
||||||
|
return any(
|
||||||
|
fnmatch.fnmatchcase(c, pattern)
|
||||||
|
for pattern in self.projects
|
||||||
|
for c in candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_machines(path: Path) -> dict[str, Machine]:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
raw = tomllib.load(f)
|
||||||
|
machines = {
|
||||||
|
name: Machine(name=name, **fields)
|
||||||
|
for name, fields in raw.get("machines", {}).items()
|
||||||
|
}
|
||||||
|
if not machines:
|
||||||
|
msg = f"{path}: no [machines.<name>] sections"
|
||||||
|
raise ValueError(msg)
|
||||||
|
for machine in machines.values():
|
||||||
|
_ = machine.token
|
||||||
|
return machines
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="T3CODE_MCP_")
|
||||||
|
|
||||||
|
config: Path = Path("t3code.toml")
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""MCP tools over one or more T3 Code servers.
|
||||||
|
|
||||||
|
`t3_wait` blocks on purpose: Claude Code has no MCP Tasks support and moves
|
||||||
|
any tool call still running after two minutes into a background task by
|
||||||
|
itself, so a plain long poll is the cheapest correct shape here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
from pydantic import Field
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from t3code_mcp import t3
|
||||||
|
from t3code_mcp.config import Machine, ModelSpec
|
||||||
|
from t3code_mcp.t3 import T3Client, T3Error
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
TITLE_MAX = 80
|
||||||
|
TEXT_MAX = 6000
|
||||||
|
TOOLS_MAX = 12
|
||||||
|
WAIT_MAX = 3600.0
|
||||||
|
PENDING_EVERY = 6
|
||||||
|
BUSY = frozenset({"running", "starting"})
|
||||||
|
STALLED_POLLS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Registry:
|
||||||
|
machines: dict[str, Machine]
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None
|
||||||
|
clients: dict[str, T3Client] = field(default_factory=dict)
|
||||||
|
threads: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def machine(self, name: str) -> Machine:
|
||||||
|
try:
|
||||||
|
return self.machines[name]
|
||||||
|
except KeyError:
|
||||||
|
msg = f"unknown machine {name!r}; known: {', '.join(self.machines)}"
|
||||||
|
raise ToolError(msg) from None
|
||||||
|
|
||||||
|
def client(self, name: str) -> T3Client:
|
||||||
|
machine = self.machine(name)
|
||||||
|
if name not in self.clients:
|
||||||
|
self.clients[name] = T3Client(
|
||||||
|
machine.url, machine.token, transport=self.transport
|
||||||
|
)
|
||||||
|
return self.clients[name]
|
||||||
|
|
||||||
|
async def locate(self, thread_id: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
names = (
|
||||||
|
[self.threads[thread_id]]
|
||||||
|
if thread_id in self.threads
|
||||||
|
else list(self.machines)
|
||||||
|
)
|
||||||
|
for name in names:
|
||||||
|
detail = await self.client(name).thread(thread_id, turn_limit=1)
|
||||||
|
if detail is not None:
|
||||||
|
self.threads[thread_id] = name
|
||||||
|
return name, detail
|
||||||
|
msg = f"thread {thread_id} not found on {', '.join(names)}"
|
||||||
|
raise ToolError(msg)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
for client in self.clients.values():
|
||||||
|
await client.aclose()
|
||||||
|
self.clients.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _title(prompt: str, title: str | None) -> str:
|
||||||
|
text = (title or prompt).strip().splitlines()[0].strip()
|
||||||
|
if len(text) > TITLE_MAX:
|
||||||
|
text = text[:TITLE_MAX].rsplit(" ", 1)[0] + "…"
|
||||||
|
return text or "t3code-mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def _state(thread: dict[str, Any]) -> str:
|
||||||
|
latest = thread.get("latestTurn")
|
||||||
|
if latest:
|
||||||
|
return latest["state"]
|
||||||
|
messages = thread.get("messages", [])
|
||||||
|
return "starting" if messages and messages[-1]["role"] == "user" else "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project(
|
||||||
|
machine: Machine, projects: list[dict[str, Any]], key: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
allowed = [p for p in projects if not p.get("deletedAt") and machine.allows(p)]
|
||||||
|
for project in allowed:
|
||||||
|
if key in (project["id"], project["title"], project["workspaceRoot"]):
|
||||||
|
return project
|
||||||
|
names = ", ".join(p["title"] for p in allowed) or "(none allowlisted)"
|
||||||
|
msg = f"project {key!r} is not available on {machine.name}; allowed: {names}"
|
||||||
|
raise ToolError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_view(
|
||||||
|
project: dict[str, Any], threads: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
own = [
|
||||||
|
t
|
||||||
|
for t in threads
|
||||||
|
if t["projectId"] == project["id"] and not t.get("archivedAt")
|
||||||
|
]
|
||||||
|
running = sum(
|
||||||
|
1 for t in own if (t.get("latestTurn") or {}).get("state") == "running"
|
||||||
|
)
|
||||||
|
default = project.get("defaultModelSelection")
|
||||||
|
return {
|
||||||
|
"id": project["id"],
|
||||||
|
"title": project["title"],
|
||||||
|
"path": project["workspaceRoot"],
|
||||||
|
"default_model": str(ModelSpec.from_selection(default)) if default else None,
|
||||||
|
"threads": len(own),
|
||||||
|
"running": running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
"summary": a["summary"],
|
||||||
|
"detail": str((a.get("payload") or {}).get("detail", ""))[:200],
|
||||||
|
}
|
||||||
|
for a in thread.get("activities", [])
|
||||||
|
if a.get("kind") == "tool.completed"
|
||||||
|
][-TOOLS_MAX:]
|
||||||
|
files = [
|
||||||
|
{"path": f["path"], "kind": f["kind"], "+": f["additions"], "-": f["deletions"]}
|
||||||
|
for c in thread.get("checkpoints", [])[-1:]
|
||||||
|
for f in c.get("files", [])
|
||||||
|
]
|
||||||
|
context = next(
|
||||||
|
(
|
||||||
|
a["payload"]
|
||||||
|
for a in reversed(thread.get("activities", []))
|
||||||
|
if a.get("kind") == "context-window.updated"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"thread_id": thread["id"],
|
||||||
|
"machine": machine,
|
||||||
|
"project": project or thread["projectId"],
|
||||||
|
"title": thread["title"],
|
||||||
|
"model": str(ModelSpec.from_selection(thread["modelSelection"])),
|
||||||
|
"state": _state(thread),
|
||||||
|
"turn_id": latest.get("turnId"),
|
||||||
|
"session": session.get("status"),
|
||||||
|
"error": session.get("lastError"),
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
"files": files,
|
||||||
|
"context_tokens": (context or {}).get("usedTokens"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pending(shell: dict[str, Any], thread_id: str) -> dict[str, bool] | None:
|
||||||
|
for thread in shell.get("threads", []):
|
||||||
|
if thread["id"] == thread_id:
|
||||||
|
flags = {
|
||||||
|
"approval": bool(thread.get("hasPendingApprovals")),
|
||||||
|
"user_input": bool(thread.get("hasPendingUserInput")),
|
||||||
|
"plan": bool(thread.get("hasActionableProposedPlan")),
|
||||||
|
}
|
||||||
|
return flags if any(flags.values()) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||||
|
mcp = FastMCP(
|
||||||
|
"t3code",
|
||||||
|
instructions=(
|
||||||
|
"Coding threads on Бобёр's machines through T3 Code. Flow: t3_machines → "
|
||||||
|
"t3_projects(machine) → t3_dispatch → t3_wait / t3_thread. Prompts run "
|
||||||
|
"autonomously (full-access, no approvals): say what to change, what to "
|
||||||
|
"check, what not to touch, and that the answer should end with a report."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.custom_route("/healthz", methods=["GET"])
|
||||||
|
async def healthz(_: Request) -> JSONResponse:
|
||||||
|
return JSONResponse({"status": "ok", "machines": list(registry.machines)})
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_machines() -> list[dict[str, Any]]:
|
||||||
|
"""Machines with a T3 Code server: reachable now or not, project allowlist."""
|
||||||
|
result = []
|
||||||
|
for name, machine in registry.machines.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"url": machine.url,
|
||||||
|
"projects": machine.projects,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
descriptor = await registry.client(name).descriptor()
|
||||||
|
except (httpx.HTTPError, T3Error) as exc:
|
||||||
|
entry.update(online=False, error=str(exc)[:200])
|
||||||
|
else:
|
||||||
|
entry.update(
|
||||||
|
online=True,
|
||||||
|
label=descriptor.get("label"),
|
||||||
|
version=descriptor.get("serverVersion"),
|
||||||
|
os=(descriptor.get("platform") or {}).get("os"),
|
||||||
|
)
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_projects(
|
||||||
|
machine: Annotated[str, Field(description="Machine name from t3_machines")],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Allowlisted projects on a machine: default model, thread counts."""
|
||||||
|
spec = registry.machine(machine)
|
||||||
|
shell = await _call(registry.client(machine).shell())
|
||||||
|
return [
|
||||||
|
_project_view(p, shell["threads"])
|
||||||
|
for p in shell["projects"]
|
||||||
|
if not p.get("deletedAt") and spec.allows(p)
|
||||||
|
]
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_dispatch(
|
||||||
|
machine: Annotated[str, Field(description="Machine name from t3_machines")],
|
||||||
|
project: Annotated[
|
||||||
|
str, Field(description="Project title, path or id from t3_projects")
|
||||||
|
],
|
||||||
|
prompt: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="The task for the coding agent, complete and self-contained"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
title: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Thread title; default - first line of the prompt"),
|
||||||
|
] = None,
|
||||||
|
model: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"`instance/model`, e.g. claudeAgent/claude-opus-5; "
|
||||||
|
"default from config or the project"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
thread_id: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Continue this existing thread instead of creating one"),
|
||||||
|
] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Start a coding thread on a machine (or follow up in one); returns at once.
|
||||||
|
|
||||||
|
The thread runs in the background: t3_wait blocks until it finishes,
|
||||||
|
t3_thread peeks without blocking.
|
||||||
|
"""
|
||||||
|
text = prompt.strip()
|
||||||
|
if not text:
|
||||||
|
msg = "prompt is empty"
|
||||||
|
raise ToolError(msg)
|
||||||
|
spec = registry.machine(machine)
|
||||||
|
client = registry.client(machine)
|
||||||
|
shell = await _call(client.shell())
|
||||||
|
target = _find_project(spec, shell["projects"], project)
|
||||||
|
if model:
|
||||||
|
selection = ModelSpec.parse(
|
||||||
|
model, spec.model.options if spec.model else None
|
||||||
|
)
|
||||||
|
elif spec.model:
|
||||||
|
selection = spec.model
|
||||||
|
elif target.get("defaultModelSelection"):
|
||||||
|
selection = ModelSpec.from_selection(target["defaultModelSelection"])
|
||||||
|
else:
|
||||||
|
msg = f"no model: set `model` in config for {machine} or pass model="
|
||||||
|
raise ToolError(msg)
|
||||||
|
heading = _title(text, title)
|
||||||
|
if thread_id:
|
||||||
|
existing = next((t for t in shell["threads"] if t["id"] == thread_id), None)
|
||||||
|
if existing is None or existing["projectId"] != target["id"]:
|
||||||
|
msg = f"thread {thread_id} is not in {target['title']} on {machine}"
|
||||||
|
raise ToolError(msg)
|
||||||
|
if (existing.get("latestTurn") or {}).get("state") == "running":
|
||||||
|
msg = f"thread {thread_id} is running; t3_wait or t3_interrupt first"
|
||||||
|
raise ToolError(msg)
|
||||||
|
tid = thread_id
|
||||||
|
await _call(
|
||||||
|
client.dispatch(t3.turn_start(tid, text, selection.selection()))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tid = t3.new_id()
|
||||||
|
await _call(
|
||||||
|
client.dispatch(
|
||||||
|
t3.thread_create(tid, target["id"], heading, selection.selection())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
|
||||||
|
registry.threads[tid] = machine
|
||||||
|
return {
|
||||||
|
"thread_id": tid,
|
||||||
|
"machine": machine,
|
||||||
|
"project": target["title"],
|
||||||
|
"path": target["workspaceRoot"],
|
||||||
|
"title": heading,
|
||||||
|
"model": str(selection),
|
||||||
|
"state": "running",
|
||||||
|
}
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_thread(
|
||||||
|
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||||
|
turns: Annotated[
|
||||||
|
int, Field(description="How many recent user turns to include", ge=1, le=20)
|
||||||
|
] = 2,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""State of a thread without waiting.
|
||||||
|
|
||||||
|
Turn state, last messages, tools run, files changed, pending questions.
|
||||||
|
"""
|
||||||
|
machine, _ = await registry.locate(thread_id)
|
||||||
|
client = registry.client(machine)
|
||||||
|
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 = _summary(machine, detail, project=_project_title(shell, detail))
|
||||||
|
summary["pending"] = _pending(shell, thread_id)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_wait(
|
||||||
|
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||||
|
timeout: Annotated[ # noqa: ASYNC109 - the tool's contract, not asyncio's
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
description="Seconds to wait before giving the current state back",
|
||||||
|
ge=5,
|
||||||
|
le=WAIT_MAX,
|
||||||
|
),
|
||||||
|
] = 900,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Block until the turn finishes, asks a question, or timeout runs out.
|
||||||
|
|
||||||
|
Ends on completed / interrupted / error, on a pending approval or
|
||||||
|
user-input request, or after `timeout` seconds; returns the t3_thread view.
|
||||||
|
"""
|
||||||
|
machine, detail = await registry.locate(thread_id)
|
||||||
|
client = registry.client(machine)
|
||||||
|
started = time.monotonic()
|
||||||
|
polls = 0
|
||||||
|
pending: dict[str, bool] | None = None
|
||||||
|
project: str | None = None
|
||||||
|
stalled = 0
|
||||||
|
while True:
|
||||||
|
thread = detail["thread"]
|
||||||
|
state = _state(thread)
|
||||||
|
session = thread.get("session") or {}
|
||||||
|
status = session.get("status")
|
||||||
|
if polls % PENDING_EVERY == 0:
|
||||||
|
shell = await _call(client.shell())
|
||||||
|
pending = _pending(shell, thread_id)
|
||||||
|
project = _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
|
||||||
|
if done or timed_out:
|
||||||
|
summary = _summary(machine, detail, project=project)
|
||||||
|
summary["pending"] = pending
|
||||||
|
summary["timed_out"] = timed_out and not done
|
||||||
|
summary["waited"] = round(time.monotonic() - started, 1)
|
||||||
|
return summary
|
||||||
|
await asyncio.sleep(poll)
|
||||||
|
polls += 1
|
||||||
|
refreshed = await _call(client.thread(thread_id, turn_limit=1))
|
||||||
|
if refreshed is None:
|
||||||
|
msg = f"thread {thread_id} vanished from {machine}"
|
||||||
|
raise ToolError(msg)
|
||||||
|
detail = refreshed
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def t3_interrupt(
|
||||||
|
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Interrupt the running turn; continue later via t3_dispatch(thread_id=...)."""
|
||||||
|
machine, detail = await registry.locate(thread_id)
|
||||||
|
latest = detail["thread"].get("latestTurn") or {}
|
||||||
|
if latest.get("state") != "running":
|
||||||
|
return {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"machine": machine,
|
||||||
|
"state": latest.get("state", "idle"),
|
||||||
|
}
|
||||||
|
await _call(
|
||||||
|
registry.client(machine).dispatch(
|
||||||
|
t3.turn_interrupt(thread_id, latest.get("turnId"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"thread_id": thread_id, "machine": machine, "state": "interrupting"}
|
||||||
|
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call(awaitable: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return await awaitable
|
||||||
|
except T3Error as exc:
|
||||||
|
raise ToolError(str(exc)) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
msg = f"T3 unreachable: {exc}"
|
||||||
|
raise ToolError(msg) from exc
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""HTTP client for one T3 Code server (`packages/contracts/src/environmentHttp.ts`).
|
||||||
|
|
||||||
|
Four routes are used: the unauthenticated descriptor, the shell read-model
|
||||||
|
(projects + threads without bodies), one thread with a turn window, and
|
||||||
|
`dispatch` with a `ClientOrchestrationCommand`. The WebSocket RPC
|
||||||
|
(`terminal.*` and friends) is deliberately not wrapped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
DESCRIPTOR = "/.well-known/t3/environment"
|
||||||
|
SHELL = "/api/orchestration/shell"
|
||||||
|
THREADS = "/api/orchestration/threads"
|
||||||
|
DISPATCH = "/api/orchestration/dispatch"
|
||||||
|
|
||||||
|
RUNTIME_MODE = "full-access"
|
||||||
|
INTERACTION_MODE = "default"
|
||||||
|
PROBE_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class T3Error(Exception):
|
||||||
|
def __init__(self, status: int, body: Any) -> None:
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
detail = (
|
||||||
|
f"{body.get('code')}: {body.get('reason') or body.get('message')}"
|
||||||
|
if isinstance(body, dict)
|
||||||
|
else str(body)[:300]
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def thread_create(
|
||||||
|
thread_id: str, project_id: str, title: str, selection: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "thread.create",
|
||||||
|
"commandId": new_id(),
|
||||||
|
"threadId": thread_id,
|
||||||
|
"projectId": project_id,
|
||||||
|
"title": title,
|
||||||
|
"modelSelection": selection,
|
||||||
|
"runtimeMode": RUNTIME_MODE,
|
||||||
|
"interactionMode": INTERACTION_MODE,
|
||||||
|
"branch": None,
|
||||||
|
"worktreePath": None,
|
||||||
|
"createdAt": now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def turn_start(
|
||||||
|
thread_id: str,
|
||||||
|
text: str,
|
||||||
|
selection: dict[str, Any] | None = None,
|
||||||
|
title_seed: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
command: dict[str, Any] = {
|
||||||
|
"type": "thread.turn.start",
|
||||||
|
"commandId": new_id(),
|
||||||
|
"threadId": thread_id,
|
||||||
|
"message": {
|
||||||
|
"messageId": new_id(),
|
||||||
|
"role": "user",
|
||||||
|
"text": text,
|
||||||
|
"attachments": [],
|
||||||
|
},
|
||||||
|
"runtimeMode": RUNTIME_MODE,
|
||||||
|
"interactionMode": INTERACTION_MODE,
|
||||||
|
"createdAt": now_iso(),
|
||||||
|
}
|
||||||
|
if selection is not None:
|
||||||
|
command["modelSelection"] = selection
|
||||||
|
if title_seed:
|
||||||
|
command["titleSeed"] = title_seed
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def turn_interrupt(thread_id: str, turn_id: str | None = None) -> dict[str, Any]:
|
||||||
|
command: dict[str, Any] = {
|
||||||
|
"type": "thread.turn.interrupt",
|
||||||
|
"commandId": new_id(),
|
||||||
|
"threadId": thread_id,
|
||||||
|
"createdAt": now_iso(),
|
||||||
|
}
|
||||||
|
if turn_id:
|
||||||
|
command["turnId"] = turn_id
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
class T3Client:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
self.url = url.rstrip("/")
|
||||||
|
self._http = httpx.AsyncClient(
|
||||||
|
base_url=self.url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=timeout,
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._http.aclose()
|
||||||
|
|
||||||
|
async def descriptor(self) -> dict[str, Any]:
|
||||||
|
response = await self._http.get(
|
||||||
|
DESCRIPTOR, headers={"Authorization": ""}, timeout=PROBE_TIMEOUT
|
||||||
|
)
|
||||||
|
return self._json(response)
|
||||||
|
|
||||||
|
async def shell(self) -> dict[str, Any]:
|
||||||
|
return self._json(await self._http.get(SHELL))
|
||||||
|
|
||||||
|
async def thread(
|
||||||
|
self, thread_id: str, turn_limit: int | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
return self._json(response)
|
||||||
|
|
||||||
|
async def dispatch(self, command: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return self._json(await self._http.post(DISPATCH, json=command))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
body: Any = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = response.text
|
||||||
|
if response.is_error or not isinstance(body, dict):
|
||||||
|
raise T3Error(response.status_code, body)
|
||||||
|
return body
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Machines with a T3 Code server. Tokens never live here: `token_env` names
|
||||||
|
# the env var holding the bearer from `t3 auth session issue --token-only`.
|
||||||
|
# `projects` are fnmatch patterns against a project's title and workspace root;
|
||||||
|
# a project that matches nothing does not exist for the dispatcher.
|
||||||
|
# `model` is optional (`instance/model`); without it the project default applies.
|
||||||
|
|
||||||
|
[machines.mac]
|
||||||
|
url = "http://100.65.207.48:3773"
|
||||||
|
token_env = "T3_MAC_TOKEN"
|
||||||
|
projects = ["t3-smoke", "/Users/h/projects/openprise/beaver/*"]
|
||||||
|
model = "claudeAgent/claude-opus-5"
|
||||||
|
options = { effort = "high", contextWindow = "1m" }
|
||||||
|
|
||||||
|
[machines.dell]
|
||||||
|
url = "http://100.76.140.93:3773"
|
||||||
|
token_env = "T3_DELL_TOKEN"
|
||||||
|
projects = ["/root/projects", "/root/projects/*"]
|
||||||
|
model = "claudeAgent/claude-opus-5"
|
||||||
|
options = { effort = "high", contextWindow = "1m" }
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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"}
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user