feat: 线程操作与 skills/MCP 管理面暴露(host CLI + 服务端 API + 控制台)
线程:host.py 新增 thread-list/read/rename/archive/unarchive/compact 子命令(纯 JSON stdout 契约,read/rename/unarchive/compact 先 resume);服务端按任务定 scope(thread/list 的 cwd 过滤解析,Docker/cloud/异机 409,改操作仅终态,审计 thread_*);控制台线程区(元数据、列表、重命名、归档、取消归档、压缩,终态门控)。 skills/MCP:host.py 新增 skills-list/apps-list/mcp-servers-list/mcp-servers-reload/mcp-server-oauth-login 子命令;服务端按项目 scope(读 read,改 manage_project,审计 mcp_*);控制台技能与 MCP 区(列表、重载、OAuth 发起展示,绝不谎称登录完成)。 测试:host 84、cloud 687(4 跳过)、deploy 19 全过。诚实边界:Docker 任务线程在容器内故 409;无仓库任务推不出 cwd 时 404;OAuth 只做到发起透传,完成态靠协议通知;merged/merge-failed 改操作 409(比控制台门控窄,服务端报错原文展示)。
This commit is contained in:
1 parent
154c18e25d
commit
ef532a38c2
7 files changed
+2128
-2
No files matched your search
@@ -0,0 +1,363 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
|
||||
|
||||
class FakeServer:
|
||||
# Fake app-server: records every call in order, never spawns a
|
||||
# real codex binary. request returns {"result": ...} per method,
|
||||
# or an error response when the method is in error_methods.
|
||||
# resume_thread / archive_thread mimic the real Server methods:
|
||||
# they raise RuntimeError on an error response.
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls = []
|
||||
self.results = {
|
||||
"thread/list": {"data": [{"id": "th-1"}]},
|
||||
"thread/read": {"thread": {"id": "th-1"}},
|
||||
"thread/name/set": {},
|
||||
"thread/archive": {},
|
||||
"thread/unarchive": {"thread": {"id": "th-1"}},
|
||||
"thread/compact/start": {},
|
||||
}
|
||||
self.error_methods = set()
|
||||
self.resume_error = False
|
||||
self.archive_error = False
|
||||
|
||||
def initialize(self):
|
||||
self.calls.append(("initialize",))
|
||||
return {}
|
||||
|
||||
def close(self):
|
||||
self.calls.append(("close",))
|
||||
|
||||
def request(self, method, params=None, timeout=120):
|
||||
self.calls.append(("request", method, params))
|
||||
if method in self.error_methods:
|
||||
return {"error": {"message": "boom"}}
|
||||
return {"result": self.results.get(method, {})}
|
||||
|
||||
def resume_thread(self, thread_id):
|
||||
self.calls.append(("resume", thread_id))
|
||||
if self.resume_error:
|
||||
raise RuntimeError("thread/resume 失败:boom")
|
||||
return {"thread": {"id": thread_id}}
|
||||
|
||||
def archive_thread(self, thread_id):
|
||||
self.calls.append(("archive", thread_id))
|
||||
if self.archive_error:
|
||||
raise RuntimeError("thread/archive 失败:boom")
|
||||
return {"result": {}}
|
||||
|
||||
|
||||
class ThreadCliTest(unittest.TestCase):
|
||||
def _run(self, fake, cli_args, tasks=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks or {}, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
||||
argv += cli_args
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
return code, out.getvalue(), state_path
|
||||
|
||||
def _calls_before_close(self, fake):
|
||||
return [c for c in fake.calls if c[0] != "close"
|
||||
and c[0] != "initialize"]
|
||||
|
||||
def test_list_no_filters_no_resume(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(fake, ["thread-list"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "thread/list", {})])
|
||||
|
||||
def test_list_cwd_and_archived_passthrough(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-list", "--cwd", "/tmp/work", "--archived"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "thread/list",
|
||||
{"cwd": "/tmp/work", "archived": True})])
|
||||
|
||||
def test_read_defaults_include_turns_false_and_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(fake, ["thread-read", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/read",
|
||||
{"threadId": "th-1", "includeTurns": False}),
|
||||
])
|
||||
|
||||
def test_read_include_turns_flag(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-read", "--thread-id", "th-1", "--include-turns"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls[1], ("request", "thread/read",
|
||||
{"threadId": "th-1",
|
||||
"includeTurns": True}))
|
||||
|
||||
def test_rename_resume_first_and_params(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-rename", "--thread-id", "th-1",
|
||||
"--name", "new-name"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/name/set",
|
||||
{"threadId": "th-1", "name": "new-name"}),
|
||||
])
|
||||
|
||||
def test_archive_no_resume(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-archive", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("archive", "th-1")])
|
||||
|
||||
def test_unarchive_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-unarchive", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/unarchive", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_compact_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-compact", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/compact/start", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_method_error_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.error_methods = {"thread/list"}
|
||||
code, out, _ = self._run(fake, ["thread-list"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
self.assertIn("thread/list", parsed["error"])
|
||||
|
||||
def test_resume_failure_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.resume_error = True
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-rename", "--thread-id", "th-1",
|
||||
"--name", "new-name"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
# rename must not reach thread/name/set when resume fails
|
||||
methods = [c[1] for c in fake.calls if c[0] == "request"]
|
||||
self.assertNotIn("thread/name/set", methods)
|
||||
|
||||
def test_old_compact_unchanged(self):
|
||||
fake = FakeServer()
|
||||
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
||||
"cwd": "/tmp/work"}}
|
||||
code, out, _ = self._run(fake, ["compact", "--task", "t1"],
|
||||
tasks=tasks)
|
||||
self.assertEqual(code, 0)
|
||||
# old human-readable output, not the frozen JSON contract
|
||||
self.assertIn("compact t1:", out)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/compact/start", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_old_archive_unchanged(self):
|
||||
fake = FakeServer()
|
||||
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
||||
"cwd": "/tmp/work"}}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(
|
||||
sys, "argv",
|
||||
["host.py", "--config", cfg_path,
|
||||
"--state", state_path,
|
||||
"archive", "--task", "t1"]):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("archived t1", out.getvalue())
|
||||
saved = json.load(open(state_path, encoding="utf-8"))
|
||||
self.assertEqual(saved["tasks"]["t1"]["status"], "archived")
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("archive", "th-1")])
|
||||
|
||||
|
||||
class SkillsCliTest(unittest.TestCase):
|
||||
# skills/mcp CLI 子命令:与 thread-* 同款冻结契约(纯 JSON
|
||||
# stdout、失败非零),但不带 threadId、不 resume。
|
||||
|
||||
def _run(self, fake, cli_args):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": {}, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
||||
argv += cli_args
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
return code, out.getvalue()
|
||||
|
||||
def _calls_before_close(self, fake):
|
||||
return [c for c in fake.calls if c[0] != "close"
|
||||
and c[0] != "initialize"]
|
||||
|
||||
def _fake_with(self, results):
|
||||
fake = FakeServer()
|
||||
fake.results.update(results)
|
||||
return fake
|
||||
|
||||
def test_skills_list_cwd_maps_to_cwds(self):
|
||||
fake = self._fake_with({
|
||||
"skills/list": {"data": [{"cwd": "/tmp/work", "errors": [],
|
||||
"skills": []}]}})
|
||||
code, out = self._run(fake, ["skills-list", "--cwd", "/tmp/work"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"cwd": "/tmp/work",
|
||||
"errors": [],
|
||||
"skills": []}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "skills/list",
|
||||
{"cwds": ["/tmp/work"]})])
|
||||
|
||||
def test_skills_list_no_cwd_empty_params(self):
|
||||
fake = self._fake_with({"skills/list": {"data": []}})
|
||||
code, out = self._run(fake, ["skills-list"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "skills/list", {})])
|
||||
|
||||
def test_apps_list(self):
|
||||
fake = self._fake_with({"app/list": {"data": []}})
|
||||
code, out = self._run(fake, ["apps-list"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": []})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "app/list", {})])
|
||||
|
||||
def test_mcp_servers_list(self):
|
||||
fake = self._fake_with({"mcpServerStatus/list": {"data": []}})
|
||||
code, out = self._run(fake, ["mcp-servers-list"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServerStatus/list", {})])
|
||||
|
||||
def test_mcp_servers_reload(self):
|
||||
fake = self._fake_with({"config/mcpServer/reload": {}})
|
||||
code, out = self._run(fake, ["mcp-servers-reload"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "config/mcpServer/reload",
|
||||
None)])
|
||||
|
||||
def test_oauth_login_full_flags(self):
|
||||
fake = self._fake_with({
|
||||
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login",
|
||||
"loginId": "lg-1"}})
|
||||
code, out = self._run(fake, ["mcp-server-oauth-login",
|
||||
"--server-name", "srv",
|
||||
"--scopes", "a,b",
|
||||
"--timeout-secs", "30"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"authorizationUrl":
|
||||
"https://x/login",
|
||||
"loginId": "lg-1"})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
||||
{"name": "srv", "scopes": ["a", "b"],
|
||||
"timeoutSecs": 30})])
|
||||
|
||||
def test_oauth_login_minimal(self):
|
||||
fake = self._fake_with({
|
||||
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login"}})
|
||||
code, _ = self._run(fake, ["mcp-server-oauth-login",
|
||||
"--server-name", "srv"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
||||
{"name": "srv"})])
|
||||
|
||||
def test_method_error_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.error_methods = {"app/list"}
|
||||
code, out = self._run(fake, ["apps-list"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
self.assertIn("app/list", parsed["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user