import gc import io import json import os import sys import tempfile import unittest import warnings from contextlib import redirect_stdout from unittest import mock 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()) # 第 4 项回归:此处裸 open() 曾在 GC 时报 ResourceWarning; # 只捕获本段读 state 的警告断言(_load_config_file 的 # 警告属 #266 轨,不在此断言范围内)。 with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", ResourceWarning) with open(state_path, encoding="utf-8") as f: saved = json.load(f) gc.collect() self.assertEqual( [w for w in caught if issubclass(w.category, ResourceWarning) and "host-state.json" in str(w.message)], []) 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()