Files
codex-app-server/tests/test_host_thread_cli.py
T
host 436a92ba1d fix: close bare open() in host.py and ResourceWarning in test_host_thread_cli
- 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.
2026-11-03 02:49:26 +00:00

386 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()