"""覆盖率 85% 第 4 步第 6 批:host.py 纯函数与小方法长尾。 只动测试。全部是直接调用生产函数/方法:用量汇总、链路 摘要、等待轮询、状态锁与恢复、git diff/合并/验测、PR 解析 与正文、通知、doctor、Server 的 trace/close(不拉起真实 app-server 进程)。 """ import json import os import queue import subprocess import tempfile import time import unittest from unittest import mock import host def _git(repo, *args, **kw): return subprocess.run(["git", "-C", repo] + list(args), capture_output=True, text=True, **kw) def _make_repo(base): repo = os.path.join(base, "repo") os.makedirs(repo) _git(repo, "init", "-q", "-b", "main") _git(repo, "config", "user.email", "t@t.t") _git(repo, "config", "user.name", "t") with open(os.path.join(repo, "a.py"), "w") as f: f.write("x = 1\n") _git(repo, "add", ".") _git(repo, "commit", "-q", "-m", "init") return repo class UsageAndEventsTest(unittest.TestCase): def test_sum_turn_usage(self): events = [ {"method": "other", "params": {}}, {"method": "thread/tokenUsage/updated", "params": {"threadId": "th-1", "turnId": "tu-1", "tokenUsage": {"last": { "inputTokens": 10, "outputTokens": 5, "cachedInputTokens": 2, "totalTokens": 15}}}}, {"method": "thread/tokenUsage/updated", "params": {"threadId": "th-1", "turnId": "tu-2", "tokenUsage": {"last": { "inputTokens": 7, "outputTokens": 3}}}}, {"method": "thread/tokenUsage/updated", "params": {"threadId": "th-2", "turnId": "tu-1", "tokenUsage": {"last": {"inputTokens": 99}}}}, ] total = host._sum_turn_usage(events, "th-1", "tu-1") self.assertEqual(total["input_tokens"], 10) self.assertEqual(total["output_tokens"], 5) self.assertEqual(total["cached_input_tokens"], 2) self.assertEqual(total["completions"], 1) total = host._sum_turn_usage(events, "th-1", None) self.assertEqual(total["input_tokens"], 17) self.assertEqual(total["completions"], 2) self.assertIsNone(host._sum_turn_usage(events, "th-9", None)) self.assertIsNone(host._sum_turn_usage([], "th-1", None)) self.assertIsNone(host._sum_turn_usage(None, "th-1", None)) def test_partial_text_from_events(self): events = [ {"params": {"item": {"type": "agentMessage", "text": "first"}}}, {"params": {"item": {"type": "agentMessage", "text": "first"}}}, {"params": {"item": {"type": "delta", "text": "skip"}}}, {"params": {"item": {"type": "agentMessage", "text": "second"}}}, {"params": {}}, ] self.assertEqual(host._partial_text_from_events(events), "first\nsecond") self.assertEqual(host._partial_text_from_events([]), "") self.assertEqual(host._partial_text_from_events(None), "") def test_add_usage(self): self.assertIsNone(host._add_usage(None, None)) self.assertEqual(host._add_usage(None, {"a": 1}), {"a": 1}) self.assertEqual(host._add_usage({"a": 1}, None), {"a": 1}) out = host._add_usage({"a": 1, "s": "x"}, {"a": 2, "b": 3}) self.assertEqual(out, {"a": 3, "s": "x", "b": 3}) def test_interrupt_request_path_sanitizes(self): tmp = tempfile.TemporaryDirectory() try: p = host._interrupt_request_path( os.path.join(tmp.name, "host-state.json"), "a/b c") self.assertEqual(os.path.basename(p), "a-b-c.json") self.assertEqual(os.path.basename(os.path.dirname(p)), "interrupt-requests") finally: tmp.cleanup() class WaitTurnTest(unittest.TestCase): def test_completed_immediately(self): q = queue.Queue() q.put({"done": True}) status, done = host._wait_turn(q, timeout=5) self.assertEqual((status, done), ("completed", {"done": True})) def test_timeout_and_progress(self): q = queue.Queue() seen = [] status, done = host._wait_turn( q, timeout=0.35, progress_interval=0.1, on_progress=lambda e: seen.append(round(e, 1))) self.assertEqual(status, "timeout") self.assertIsNone(done) self.assertGreaterEqual(len(seen), 2) def test_interrupt_requested_and_swallow(self): q = queue.Queue() calls = {"n": 0} def _check(): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("check 炸了") return True status, _ = host._wait_turn(q, timeout=5, interrupt_check=_check) self.assertEqual(status, "interrupted-requested") # on_progress 抛错也被吞掉,不影响 timeout 判定。 status, _ = host._wait_turn( q, timeout=0.25, progress_interval=0.1, on_progress=lambda e: (_ for _ in ()).throw( RuntimeError("progress 炸了"))) self.assertEqual(status, "timeout") def test_unlimited_waits_for_done(self): q = queue.Queue() def _put(): time.sleep(0.2) q.put("x") import threading threading.Thread(target=_put, daemon=True).start() status, done = host._wait_turn(q, timeout=0) self.assertEqual((status, done), ("completed", "x")) class SummarizeTraceTest(unittest.TestCase): def _params(self, item, **kw): p = {"item": item} p.update(kw) return p def test_all_item_types(self): out = host._summarize_trace_item( "m", self._params({"type": "commandExecution", "id": "c1", "command": "ls", "cwd": "/tmp", "exitCode": 0, "durationMs": 5}, completedAtMs=123, threadId="th-1")) self.assertEqual(out[0]["kind"], "exec") self.assertEqual(out[0]["ts"], 123) out = host._summarize_trace_item( "m", self._params({"type": "fileChange", "id": "f1", "status": "done", "changes": [{"path": "a.py", "kind": "edit"}, "bad", {"path": "b.py"}]}, thread={"id": "th-9"})) self.assertEqual([e["id"] for e in out], ["f1:a.py", "f1:b.py"]) self.assertEqual(out[0]["thread"], "th-9") long_text = "z" * 400 out = host._summarize_trace_item( "m", self._params({"type": "agentMessage", "id": "m1", "text": long_text})) self.assertEqual(len(out[0]["text"]), 300) self.assertTrue(out[0]["truncated"]) out = host._summarize_trace_item( "m", self._params({"type": "reasoning", "id": "r1", "summary": ["a", 5, "b"]})) self.assertEqual(out[0]["summary"], "a b") out = host._summarize_trace_item( "m", self._params({"type": "mcpToolCall", "id": "t1", "server": "s", "tool": "t", "status": "ok"})) self.assertEqual(out[0]["kind"], "tool") out = host._summarize_trace_item( "m", self._params({"type": "functionCallOutput", "id": "o1", "name": "f"})) self.assertEqual(out[0]["kind"], "call") out = host._summarize_trace_item( "m", self._params({"type": "plan", "id": "p1", "text": "plan text"})) self.assertEqual(out[0]["kind"], "plan") def test_empty_and_unknown(self): self.assertEqual(host._summarize_trace_item("m", {}), []) self.assertEqual(host._summarize_trace_item( "m", {"item": "not-a-dict"}), []) self.assertEqual(host._summarize_trace_item( "m", {"item": {"type": "userMessage", "text": "hi"}}), []) self.assertEqual(host._summarize_trace_item( "m", {"item": {"type": "agentMessage", "text": ""}}), []) self.assertEqual(host._summarize_trace_item( "m", {"item": {"type": "reasoning", "summary": []}}), []) self.assertEqual(host._summarize_trace_item( "m", {"item": {"type": "plan", "text": ""}}), []) def test_trace_path_and_marker(self): tmp = tempfile.TemporaryDirectory() try: state_path = os.path.join(tmp.name, "host-state.json") path = host._trace_file_path(state_path, "a/b") self.assertEqual(os.path.basename(path), "a-b.jsonl") host._append_trace_marker(state_path, "a/b", {"kind": "mark"}) with open(path) as f: row = json.loads(f.readline()) self.assertEqual(row["kind"], "mark") finally: tmp.cleanup() class StateLockAndReadTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.path = os.path.join(self.tmp.name, "host-state.json") def tearDown(self): self.tmp.cleanup() def test_tmp_path_unique(self): a = host._state_tmp_path(self.path) b = host._state_tmp_path(self.path) self.assertNotEqual(a, b) self.assertTrue(a.startswith(self.path + ".tmp.")) def test_lock_exclusive_and_shared(self): with host._state_lock(self.path): self.assertTrue(os.path.exists(self.path + ".lockdir")) self.assertFalse(os.path.exists(self.path + ".lockdir")) with host._state_lock(self.path, exclusive=False): self.assertFalse(os.path.exists(self.path + ".lockdir")) def test_lock_stale_removed_and_timeout(self): lockdir = self.path + ".lockdir" os.makedirs(lockdir) old = time.time() - 10000 os.utime(lockdir, (old, old)) with host._state_lock(self.path): pass self.assertFalse(os.path.exists(lockdir)) os.makedirs(lockdir) with mock.patch.object(host, "_STATE_LOCK_TIMEOUT", 0.01): with self.assertRaises(RuntimeError): with host._state_lock(self.path): pass os.rmdir(lockdir) def test_read_state_file_variants(self): self.assertEqual(host._read_state_file(self.path), {"tasks": {}, "automations": []}) with open(self.path, "w") as f: f.write("{bad") self.assertEqual(host._read_state_file(self.path), {"tasks": {}, "automations": []}) with self.assertRaises(RuntimeError): host._read_state_file_for_save(self.path) with open(self.path, "w") as f: json.dump(["not", "a", "dict"], f) with self.assertRaises(RuntimeError): host._read_state_file_for_save(self.path) with open(self.path, "w") as f: json.dump({"tasks": [], "automations": []}, f) with self.assertRaises(RuntimeError): host._read_state_file_for_save(self.path) with open(self.path, "w") as f: json.dump({"tasks": {}, "automations": {}}, f) with self.assertRaises(RuntimeError): host._read_state_file_for_save(self.path) with open(self.path, "w") as f: json.dump({"tasks": {}, "automations": []}, f) self.assertEqual(host._read_state_file_for_save(self.path), {"tasks": {}, "automations": []}) def test_load_state_strict(self): data, err = host.load_state_strict(self.path) self.assertIsNone(err) with open(self.path, "w") as f: f.write("{bad") data, err = host.load_state_strict(self.path) self.assertIsNone(data) self.assertIsNotNone(err) class RestoreAndRecoverTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.path = os.path.join(self.tmp.name, "host-state.json") def tearDown(self): self.tmp.cleanup() def _write(self, path, obj): with open(path, "w") as f: json.dump(obj, f) def test_restore_good_is_noop(self): self._write(self.path, {"tasks": {}, "automations": [], "version": 5}) ok, msg = host.restore_state(self.path) self.assertTrue(ok) self.assertIn("完好", msg) def test_restore_from_bak1_when_bak_bad(self): with open(self.path, "w") as f: f.write("{bad") with open(self.path + ".bak", "w") as f: f.write("{also bad") self._write(self.path + ".bak.1", {"tasks": {}, "automations": [], "version": 7}) ok, msg = host.restore_state(self.path) self.assertTrue(ok) self.assertIn(".bak.1", msg) self.assertEqual(host.load_state(self.path)["version"], 7) leftovers = [n for n in os.listdir(self.tmp.name) if n.startswith("host-state.json.corrupt.")] self.assertEqual(len(leftovers), 1) def test_restore_no_backup(self): with open(self.path, "w") as f: f.write("{bad") ok, msg = host.restore_state(self.path) self.assertFalse(ok) self.assertIn("没有可用的备份", msg) def test_recover_marks_only_old_running(self): now = time.time() state = {"tasks": { "old": {"name": "old", "status": "running", "created_at": now - 5000}, "fresh": {"name": "fresh", "status": "running", "last_turn_at": now - 10}, "done": {"name": "done", "status": "completed", "created_at": now - 5000}}, "automations": []} got = host.recover_interrupted(state, self.path, older_than=3600) self.assertEqual(got, ["old"]) self.assertEqual(state["tasks"]["old"]["status"], "interrupted") self.assertEqual(state["tasks"]["fresh"]["status"], "running") self.assertEqual(state["tasks"]["done"]["status"], "completed") class GitHelpersTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.repo = _make_repo(self.tmp.name) def tearDown(self): self.tmp.cleanup() def test_task_diff_tracked_and_untracked(self): with open(os.path.join(self.repo, "a.py"), "a") as f: f.write("y = 2\n") with open(os.path.join(self.repo, "new.txt"), "w") as f: f.write("hello\n") os.makedirs(os.path.join(self.repo, "__pycache__"), exist_ok=True) with open(os.path.join(self.repo, "__pycache__", "x.pyc"), "wb") as f: f.write(b"\x00\x01") out = host.task_diff(self.repo) self.assertIn("y = 2", out["diff"]) self.assertIn("new.txt", out["untracked"]) self.assertEqual(out["untracked_contents"]["new.txt"], "hello\n") self.assertNotIn("__pycache__/x.pyc", out["untracked_contents"]) def test_task_diff_non_git_dir(self): plain = os.path.join(self.tmp.name, "plain") os.makedirs(plain) with open(os.path.join(plain, "f.txt"), "w") as f: f.write("x") out = host.task_diff(plain) self.assertEqual(out["diff"], "") self.assertIn("f.txt", out["untracked"]) def test_topo_sort_and_errors(self): jobs = [{"name": "b", "depends_on": ["a"], "priority": 1}, {"name": "a", "priority": 5}, {"name": "c", "depends_on": ["a"], "priority": 9}] ordered = [j["name"] for j in host.topo_sort_jobs(jobs)] self.assertEqual(ordered, ["a", "c", "b"]) with self.assertRaises(ValueError): host.topo_sort_jobs([{"name": "a", "depends_on": ["ghost"]}]) with self.assertRaises(ValueError): host.topo_sort_jobs([{"name": "a", "depends_on": ["a"]}]) with self.assertRaises(ValueError): host.topo_sort_jobs([{"name": "a", "depends_on": ["b"]}, {"name": "b", "depends_on": ["a"]}]) def test_detect_verify_and_run_verify(self): self.assertIsNone(host.detect_verify_command(self.repo)) os.makedirs(os.path.join(self.repo, "tests")) self.assertIn("unittest", host.detect_verify_command(self.repo)) self.assertIsNone(host.detect_verify_command(None)) self.assertEqual(host.run_verify("", self.repo), (0, "")) rc, out = host.run_verify("echo hi", self.repo) self.assertEqual((rc, out), (0, "hi")) rc, _ = host.run_verify("false", self.repo) self.assertNotEqual(rc, 0) def test_merge_task_success_and_verify_fail(self): wt = os.path.join(self.tmp.name, "wt1") _git(self.repo, "worktree", "add", wt, "-b", "host/t1") with open(os.path.join(wt, "a.py"), "a") as f: f.write("z = 3\n") ok, msg = host.merge_task(self.repo, wt, "host/t1", "t1", verify_command="true") self.assertTrue(ok, msg) self.assertFalse(os.path.exists(wt)) self.assertNotIn("host/t1", _git(self.repo, "branch").stdout) with open(os.path.join(self.repo, "a.py")) as f: self.assertIn("z = 3", f.read()) # 验测失败:回滚主仓、分支与 worktree 保留。 wt2 = os.path.join(self.tmp.name, "wt2") _git(self.repo, "worktree", "add", wt2, "-b", "host/t2") with open(os.path.join(wt2, "a.py"), "a") as f: f.write("w = 4\n") ok, msg = host.merge_task(self.repo, wt2, "host/t2", "t2", verify_command="false") self.assertFalse(ok) self.assertIn("验测失败", msg) with open(os.path.join(self.repo, "a.py")) as f: self.assertNotIn("w = 4", f.read()) self.assertIn("host/t2", _git(self.repo, "branch").stdout) _git(self.repo, "worktree", "remove", "--force", wt2) _git(self.repo, "branch", "-D", "host/t2") class BudgetAndNotifyTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.path = os.path.join(self.tmp.name, "host-state.json") def tearDown(self): self.tmp.cleanup() def test_record_usage_and_budget(self): state = {} u = host.record_turn_usage(state) self.assertEqual(u["turns"], 1) u = host.record_turn_usage(state, failed=True) self.assertEqual((u["turns"], u["failed_turns"]), (2, 1)) # 跨天重置。 state["usage"] = {"date": "2000-01-01", "turns": 9, "failed_turns": 9} u = host.record_turn_usage(state) self.assertEqual((u["turns"], u["failed_turns"]), (1, 0)) today = time.strftime("%Y-%m-%d", time.localtime()) self.assertFalse(host.budget_exceeded( {"usage": {"date": today, "turns": 1}}, {})) self.assertFalse(host.budget_exceeded( {"usage": {"date": today, "turns": 99}}, {"max_daily_turns": "bogus"})) self.assertFalse(host.budget_exceeded( {"usage": {"date": "2000-01-01", "turns": 99}}, {"max_daily_turns": 5})) self.assertTrue(host.budget_exceeded( {"usage": {"date": today, "turns": 5}}, {"max_daily_turns": 5})) def test_notify_failure_log_only_and_command(self): msg = host.notify_failure(self.path, {}, "t1", {"status": "failed", "text": "boom"}) self.assertIn("FAILED t1", msg) log_path = os.path.join(self.tmp.name, "host-run.log") self.assertTrue(os.path.exists(log_path)) out_file = os.path.join(self.tmp.name, "notified.txt") cfg = {"notify_command": "echo {task} {status} > " + out_file} host.notify_failure(self.path, cfg, "t1", {"status": "failed", "text": "boom"}) with open(out_file) as f: self.assertIn("t1", f.read()) # 坏模板:记 template error,不跑命令。 before = open(log_path).read() host.notify_failure(self.path, {"notify_command": "echo {nope}"}, "t1", {"status": "failed", "text": "x"}) after = open(log_path).read() self.assertIn("template error", after[len(before):]) def test_build_fix_prompt_and_pr_body(self): task = {"name": "t1", "prompt": "do it", "review_comments": [ {"file": "a.py", "line": 3, "comment": "fix", "status": "open"}, {"file": "b.py", "line": 1, "comment": "old", "status": "resolved"}]} prompt = host.build_fix_prompt(task) self.assertIn("a.py:3: fix", prompt) self.assertNotIn("old", prompt) body = host.build_pr_body(task) self.assertIn("host 任务:t1", body) self.assertIn("a.py:3: fix", body) body = host.build_pr_body({"name": "t2"}) self.assertIn("(无提示)", body) self.assertIn("无未解决评论", body) def test_check_state(self): state = {"version": 5, "tasks": { "bad": {"status": "failed", "cwd": "/nonexistent-x"}, "ok": {"status": "completed"}, "gone": {"status": "running", "cwd": "/nonexistent-x", "review_comments": [ {"status": "open", "file": "a.py", "line": 1, "comment": "c"}]}}, "automations": []} out = host.check_state(self.path, state) self.assertEqual(out["version"], 5) self.assertFalse(out["backup_exists"]) self.assertEqual(out["failed_tasks"], ["bad"]) self.assertEqual(sorted(out["missing_worktrees"]), ["bad", "gone"]) self.assertEqual(out["open_review_comments"], 1) class ParsePrChecksTest(unittest.TestCase): def test_tab_and_human_formats(self): checks, all_pass = host.parse_pr_checks( "name\tstate\nbuild\tpass\nlint\tfail\n") self.assertEqual(checks, [{"name": "build", "state": "pass"}, {"name": "lint", "state": "fail"}]) self.assertFalse(all_pass) checks, all_pass = host.parse_pr_checks( "✓ build pass\n✓ lint passing\n") self.assertTrue(all_pass) self.assertEqual([c["state"] for c in checks], ["pass", "pass"]) checks, all_pass = host.parse_pr_checks( "no checks reported\n") self.assertEqual((checks, all_pass), ([], False)) checks, all_pass = host.parse_pr_checks("") self.assertEqual((checks, all_pass), ([], False)) checks, _ = host.parse_pr_checks("build queued\ntest skipped\n" "job cancelled\nx neutral\n" "y in_progress\nz failing\n") self.assertEqual([c["state"] for c in checks], ["pending", "skipped", "cancelled", "neutral", "pending", "fail"]) class CliHelpersTest(unittest.TestCase): def test_load_config_and_effective_cfg(self): tmp = tempfile.TemporaryDirectory() try: self.assertEqual(host._load_config_file(None), {}) self.assertEqual(host._load_config_file( os.path.join(tmp.name, "nope.json")), {}) bad = os.path.join(tmp.name, "bad.json") with open(bad, "w") as f: f.write("{bad") with self.assertRaises(RuntimeError): host._load_config_file(bad) cfg = {"max_daily_turns": 5, "other": 1} self.assertIs(host._effective_cfg(cfg, False), cfg) out = host._effective_cfg(cfg, True) self.assertIsNone(out["max_daily_turns"]) self.assertEqual(out["other"], 1) self.assertEqual(cfg["max_daily_turns"], 5) finally: tmp.cleanup() def test_resolve_path_and_thread_result(self): self.assertEqual(host._resolve_path("/base", ""), "") self.assertEqual(host._resolve_path("/base", "/abs"), "/abs") self.assertEqual(host._resolve_path("/base", "rel"), "/base/rel") self.assertEqual(host._thread_cli_result({"result": {"a": 1}}, "thread-list"), {"a": 1}) self.assertEqual(host._thread_cli_result({"result": None}, "thread-list"), {}) with self.assertRaises(RuntimeError): host._thread_cli_result({"error": "boom"}, "thread-list") with self.assertRaises(RuntimeError): host._thread_cli_result(None, "thread-list") def test_server_command(self): cmd = host._server_command("/bin/codex") self.assertEqual(cmd[-1], "app-server") self.assertIn("-c", cmd) with self.assertRaises(ValueError): host._server_command("/bin/codex", provider="other") with mock.patch.dict(os.environ, {"SUB2API_BASE_URL": ""}): with self.assertRaises(RuntimeError): host._server_command("/bin/codex", provider="sub2api") with mock.patch.dict(os.environ, {"SUB2API_BASE_URL": "https://gw.example.com/"}): cmd = host._server_command("/bin/codex", provider="sub2api") self.assertTrue(any("gw.example.com/v1" in c for c in cmd)) class ServerTraceCloseTest(unittest.TestCase): def test_append_trace_and_close(self): tmp = tempfile.TemporaryDirectory() try: srv = host.Server(codex_bin="/bin/false") state_path = os.path.join(tmp.name, "host-state.json") srv.set_trace_file(state_path, "t1") self.assertIsNotNone(srv.trace_path) srv._append_trace({"method": "x", "params": { "item": {"type": "agentMessage", "id": "m1", "text": "hello"}}}) srv._append_trace({"method": "x", "params": { "item": {"type": "userMessage", "text": "skip"}}}) with open(srv.trace_path) as f: lines = f.readlines() self.assertEqual(len(lines), 1) self.assertIn("hello", lines[0]) # trace_path 为 None 时 _append_trace 是空操作。 srv.trace_path = None srv._append_trace({"method": "x", "params": { "item": {"type": "agentMessage", "id": "m2", "text": "nope"}}}) finally: tmp.cleanup() def test_close_with_fake_proc(self): class _Stdin: def __init__(self): self.closed = False def close(self): self.closed = True class _Proc: def __init__(self, wait_raises=False): self.stdin = _Stdin() self.stdout = _Stdin() self.wait_raises = wait_raises self.killed = False def wait(self, timeout=None): if self.wait_raises: raise RuntimeError("not dead") return 0 def kill(self): self.killed = True srv = host.Server(codex_bin="/bin/false") proc = _Proc() srv.proc = proc srv.close() self.assertTrue(proc.stdin.closed) self.assertFalse(proc.killed) proc2 = _Proc(wait_raises=True) srv.proc = proc2 srv.close() self.assertTrue(proc2.killed) self.assertTrue(proc2.stdout.closed) if __name__ == "__main__": unittest.main()