- host.py: 7 one-shot reads switched to with open (run_health_check pidfile, daemon-status pid/log, daemon-stop pid, daemon old pid, interrupt poll state, parallel jobs); read content, encoding args and error semantics unchanged. - tests/test_host_thread_cli.py: test_old_archive_unchanged state read switched to with open; added a warnings-capture assertion scoped to the state read so a regression fails the test. - Intentionally untouched: daemonize log handle (daemon-lifetime dup2 target) and the two _load_config_file / init sites owned by PR #266.
386 lines
16 KiB
Python
386 lines
16 KiB
Python
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()
|