Files
codex-app-server/tests/test_host_thread_cli.py

378 lines
15 KiB
Python

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_no_resume(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, [
("request", "thread/unarchive", {"threadId": "th-1"}),
])
def test_unarchive_resume_failure_does_not_block(self):
# 回归:已归档 thread 的 resume 会失败;unarchive 不再调
# resume,所以 resume 失败也不应挡住 unarchive(此前
# resume 抛错 → CLI 退出 1 → 服务端回 502)。
fake = FakeServer()
fake.resume_error = True
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, [
("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()