Files
codex-app-server/tests/test_host_coverage85.py
T
host 81a58daca2 coverage85-step1: server/worker/host 主力缺口真实行为测试(+113 项)
只补 cloud/server.py、cloud/worker.py、host.py 的真实行为测试,
不动生产代码:
- server:助手函数(路径/租户/提取/链路读/配额/限流/心跳/
  host CLI stub 脚本)40 项
- worker:纯助手 + Worker DB/认领/容量/配额/跟进/租户/协调员
  孤儿/回退 token,真实 SQLite 与真实子进程 36 项
- host:假 app-server 脚本走真实 JSON-RPC 协议,run_turn
  成功/超时/中断/失败/历史错误回捞 + 状态/git/PR 助手 37 项
不含 mock 被测代码、无 skip、无 no-coverage、无空循环刷行。
2026-11-03 02:48:54 +00:00

1115 lines
48 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.
"""覆盖率 78%->85% 第 1 步:host.py 主力缺口的真实行为测试。
Server.run_turn 整段(620-791 行)此前 0 覆盖:本文件用一个假
app-server 脚本(标准 JSON-RPC 行协议,与真实 Server._start /
_read_loop / request 走同一条路,不 mock 被测代码)真实跑通
成功 / 超时 / 中断 / 失败 / 历史错误回捞五路;其余助手函数用
真实临时 git 仓库与真实 state 文件断言。不跑真实模型、不联网。
"""
import json
import os
import queue
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
import host
from host import Server
FAKE_SCRIPT = r'''#!/usr/bin/env python3
"""假 app-server:只讲 JSON-RPC 行协议,不跑模型。"""
import json
import os
import sys
mode = os.environ.get("FAKE_MODE", "ok")
req_log = os.environ.get("FAKE_REQ_LOG", "")
resp_log = os.environ.get("FAKE_RESP_LOG", "")
probe = os.environ.get("FAKE_PROBE", "") == "1"
def log(path, obj):
if not path:
return
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
def send(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def result_for(method, params):
if method == "thread/start":
return {"thread": {"id": "th-1"}}
if method == "thread/list":
if mode == "list-error":
return None
return {"threads": [{"id": "th-1"}]}
if method == "thread/resume":
return {"resumed": True}
if method == "review/start":
return {"review": "ok"}
if method == "thread/fork":
return {"thread": {"id": "th-2"}}
if method == "thread/archive":
return {}
if method == "turn/interrupt":
return {"interrupted": True}
if method == "initialize":
if mode == "init-error":
return None
return {"server": "fake"}
if method == "turn/start":
return {"turn": {"id": "turn-1"}}
return {}
def error_for(method):
if method == "thread/list" and mode == "list-error":
return {"message": "list boom"}
if method == "initialize" and mode == "init-error":
return {"message": "init boom"}
if method == "turn/start" and mode == "turn-error":
return {"message": "turn boom"}
return None
def main():
if probe:
# 注意:必须是原始行(真空行、真非法 JSON),不能走 send() 的
# json.dumps——dumps("") 会写出 `""`(合法 JSON 字符串),
# host._read_loop 对非 dict 消息会 AttributeError 而静默死亡,
# 那是生产侧的健壮性问题(见回执),不是本测试要测的分支。
sys.stdout.write("\n")
sys.stdout.write("not-json{\n")
sys.stdout.flush()
send({"jsonrpc": "2.0", "method": "thread/tokenUsage/updated",
"params": {"thread": {"id": "th-x"},
"tokenUsage": {"last": {"inputTokens": 1}}}})
expecting = set()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except ValueError:
continue
if "method" not in msg:
# 客户端对探针请求的回答。
if msg.get("id") in (9001, 9002):
log(resp_log, msg)
expecting.discard(msg.get("id"))
continue
method = msg.get("method")
params = msg.get("params") or {}
log(req_log, {"method": method, "params": params})
if method == "initialize" and probe and not expecting:
send({"jsonrpc": "2.0", "id": 9001,
"method": "mcpServer/elicitation/request", "params": {}})
send({"jsonrpc": "2.0", "id": 9002,
"method": "approval/request", "params": {}})
expecting = {9001, 9002}
# 先等客户端回答探针,再回 initialize(回答由上支处理,
# 这里靠“下一行到达”驱动:把 initialize 暂存,下一轮
# 循环到达时若已收齐则补发——简化:直接等两行回答)。
# 为避免死锁,回答在后续行里到达后由分支补发。
pending_init = msg
# 读回答行,直到收齐。
for ans in sys.stdin:
ans = ans.strip()
if not ans:
continue
try:
amsg = json.loads(ans)
except ValueError:
continue
if amsg.get("id") in (9001, 9002):
log(resp_log, amsg)
expecting.discard(amsg.get("id"))
if not expecting:
break
err = error_for(method)
if err is not None:
send({"jsonrpc": "2.0", "id": pending_init["id"],
"error": err})
else:
send({"jsonrpc": "2.0", "id": pending_init["id"],
"result": result_for(method, params)})
continue
if mode == "hang" and method == "thread/list":
continue # 故意不回,触发客户端超时。
err = error_for(method)
if err is not None:
send({"jsonrpc": "2.0", "id": msg["id"], "error": err})
continue
if "id" in msg:
send({"jsonrpc": "2.0", "id": msg["id"],
"result": result_for(method, params)})
if method == "turn/start":
if mode == "ok":
send({"jsonrpc": "2.0",
"method": "thread/tokenUsage/updated",
"params": {"threadId": "th-1", "turnId": "turn-1",
"tokenUsage": {"last": {
"inputTokens": 10,
"outputTokens": 5,
"cachedInputTokens": 2,
"cacheWriteInputTokens": 0,
"reasoningOutputTokens": 1,
"totalTokens": 15}}}})
send({"jsonrpc": "2.0", "method": "turn/completed",
"params": {"threadId": "th-1",
"turn": {"id": "turn-1",
"status": "completed",
"rootTurnId": "root-9",
"items": [{"type": "agentMessage",
"phase": "final_answer",
"text": "done-text"}]}}})
elif mode == "turn-failed":
send({"jsonrpc": "2.0", "method": "turn/completed",
"params": {"threadId": "th-1",
"turn": {"id": "turn-1",
"status": "failed",
"items": []}}})
# turn-never / turn-error:不发 completed。
main()
'''
class _Env:
def __init__(self, **kw):
self.kw = kw
self.old = {}
def __enter__(self):
for k, v in self.kw.items():
self.old[k] = os.environ.get(k)
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
return self
def __exit__(self, *exc):
for k, v in self.old.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
return False
def _wait_for(predicate, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.05)
return predicate()
class FakeServerCase(unittest.TestCase):
"""起一个真 Server(真子进程 + 真读线程),用后关闭。"""
mode = "ok"
approval = "deny"
probe = False
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.fake = os.path.join(self.tmp.name, "fake-server.py")
with open(self.fake, "w", encoding="utf-8") as f:
f.write(FAKE_SCRIPT)
os.chmod(self.fake, 0o755)
self.req_log = os.path.join(self.tmp.name, "req.jsonl")
self.resp_log = os.path.join(self.tmp.name, "resp.jsonl")
self.codex_home = os.path.join(self.tmp.name, "codex-home")
os.makedirs(self.codex_home, exist_ok=True)
env = {"FAKE_MODE": self.mode,
"FAKE_REQ_LOG": self.req_log,
"FAKE_RESP_LOG": self.resp_log,
"FAKE_PROBE": "1" if self.probe else ""}
self._old_env = {}
for k, v in env.items():
self._old_env[k] = os.environ.get(k)
os.environ[k] = v
self.server = Server(codex_bin=self.fake,
codex_home=self.codex_home,
approval=self.approval)
self.addCleanup(self._close_server)
def _close_server(self):
try:
self.server.close()
except Exception:
pass
for k, v in self._old_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _requests(self, method):
if not os.path.exists(self.req_log):
return []
out = []
with open(self.req_log, encoding="utf-8") as f:
for line in f:
try:
obj = json.loads(line)
except ValueError:
continue
if obj.get("method") == method:
out.append(obj)
return out
class PureHelpersTestCase(unittest.TestCase):
def test_sum_turn_usage(self):
events = [
{"method": "other", "params": {}},
{"method": "thread/tokenUsage/updated",
"params": {"threadId": "th-1", "turnId": "t1",
"tokenUsage": {"last": {"inputTokens": 10,
"outputTokens": 5,
"totalTokens": 15}}}},
{"method": "thread/tokenUsage/updated",
"params": {"threadId": "th-1", "turnId": "t1",
"tokenUsage": {"last": {"inputTokens": 3,
"outputTokens": 2,
"totalTokens": 5}}}},
{"method": "thread/tokenUsage/updated",
"params": {"threadId": "th-2", "turnId": "t1",
"tokenUsage": {"last": {"inputTokens": 99}}}},
{"method": "thread/tokenUsage/updated",
"params": {"threadId": "th-1", "turnId": "other",
"tokenUsage": {"last": {"inputTokens": 99}}}},
]
usage = host._sum_turn_usage(events, "th-1", "t1")
self.assertEqual(usage["input_tokens"], 13)
self.assertEqual(usage["output_tokens"], 7)
self.assertEqual(usage["total_tokens"], 20)
self.assertEqual(usage["completions"], 2)
self.assertIsNone(host._sum_turn_usage([], "th-1", "t1"))
self.assertIsNone(host._sum_turn_usage(events, "nope", "t1"))
# turn_id 为 None 时不过滤 turn。
usage_all = host._sum_turn_usage(events, "th-1", None)
self.assertEqual(usage_all["completions"], 3)
def test_partial_text_from_events(self):
events = [
{"params": {"item": {"type": "agentMessage", "text": "a"}}},
{"params": {"item": {"type": "agentMessage", "text": "a"}}},
{"params": {"item": {"type": "agentMessage", "text": "b"}}},
{"params": {"item": {"type": "exec", "text": "ignored"}}},
{"params": {}},
]
self.assertEqual(host._partial_text_from_events(events), "a\nb")
self.assertEqual(host._partial_text_from_events([]), "")
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})
self.assertEqual(host._add_usage({"a": 1, "s": "x"},
{"a": 2, "b": 3}),
{"a": 3, "s": "x", "b": 3})
def test_interrupt_request_path(self):
path = host._interrupt_request_path("/tmp/dir/state.json",
"a/b:c")
self.assertEqual(path, "/tmp/dir/interrupt-requests/a-b-c.json")
def test_server_command(self):
cmd = host._server_command("/usr/bin/codex")
self.assertEqual(cmd[-1], "app-server")
self.assertIn("-c", cmd)
with self.assertRaises(ValueError):
host._server_command("/usr/bin/codex", provider="weird")
with _Env(SUB2API_BASE_URL=None):
with self.assertRaises(RuntimeError):
host._server_command("/usr/bin/codex", provider="sub2api")
with _Env(SUB2API_BASE_URL="https://gw.example.invalid/"):
cmd = host._server_command("/usr/bin/codex",
provider="sub2api")
joined = " ".join(cmd)
self.assertIn("https://gw.example.invalid/v1", joined)
self.assertIn('model_provider="sub2api"', joined)
def test_default_codex_bin(self):
with _Env(CODEX_BIN="/tmp/fake-codex"):
self.assertEqual(host.default_codex_bin(), "/tmp/fake-codex")
def test_summarize_trace_item(self):
self.assertEqual(host._summarize_trace_item("x", {}), [])
self.assertEqual(host._summarize_trace_item("x", {"item": "nope"}),
[])
exec_entry = host._summarize_trace_item(
"m", {"item": {"type": "commandExecution", "id": "e1",
"command": "ls", "cwd": "/tmp", "exitCode": 0,
"durationMs": 5},
"completedAtMs": 123, "threadId": "th-1"})[0]
self.assertEqual(exec_entry["kind"], "exec")
self.assertEqual(exec_entry["ts"], 123)
file_entries = host._summarize_trace_item(
"m", {"item": {"type": "fileChange", "id": "f1",
"status": "done",
"changes": [{"path": "a.py", "kind": "edit"},
"bad",
{"path": "b.py", "kind": "add"}]},
"thread": {"id": "th-2"}})
self.assertEqual([e["path"] for e in file_entries],
["a.py", "b.py"])
self.assertEqual(file_entries[0]["thread"], "th-2")
long_text = "x" * 400
msg = host._summarize_trace_item(
"m", {"item": {"type": "agentMessage", "id": "m1",
"phase": "final", "text": long_text}})[0]
self.assertEqual(len(msg["text"]), 300)
self.assertTrue(msg["truncated"])
self.assertEqual(host._summarize_trace_item(
"m", {"item": {"type": "agentMessage", "id": "m2",
"text": ""}}), [])
reasoning = host._summarize_trace_item(
"m", {"item": {"type": "reasoning", "id": "r1",
"summary": ["hello", 42, "world"]}})[0]
self.assertEqual(reasoning["summary"], "hello world")
self.assertEqual(host._summarize_trace_item(
"m", {"item": {"type": "reasoning", "id": "r2",
"summary": []}}), [])
tool = host._summarize_trace_item(
"m", {"item": {"type": "mcpToolCall", "id": "t1",
"server": "s", "tool": "search",
"status": "ok"}})[0]
self.assertEqual(tool["kind"], "tool")
call = host._summarize_trace_item(
"m", {"item": {"type": "functionCallOutput", "id": "c1",
"name": "fn"}})[0]
self.assertEqual(call["name"], "fn")
plan = host._summarize_trace_item(
"m", {"item": {"type": "plan", "id": "p1",
"text": "do it"}})[0]
self.assertEqual(plan["kind"], "plan")
self.assertEqual(host._summarize_trace_item(
"m", {"item": {"type": "plan", "id": "p2", "text": ""}}), [])
self.assertEqual(host._summarize_trace_item(
"m", {"item": {"type": "userMessage", "id": "u1",
"text": "hi"}}), [])
def test_trace_file_and_marker(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
state_path = os.path.join(tmp.name, "state.json")
path = host._trace_file_path(state_path, "a/b")
self.assertTrue(path.endswith(
os.path.join("host-traces", "a-b.jsonl")))
host._append_trace_marker(state_path, "task1",
{"kind": "agent-start", "name": "n"})
with open(path.replace("a-b.jsonl", "task1.jsonl"),
encoding="utf-8") as f:
entry = json.loads(f.read().strip())
self.assertEqual(entry["name"], "n")
def test_wait_turn(self):
q = queue.Queue()
q.put({"done": True})
status, done = host._wait_turn(q, timeout=5)
self.assertEqual((status, done), ("completed", {"done": True}))
q2 = queue.Queue()
status, done = host._wait_turn(q2, timeout=0.3)
self.assertEqual((status, done), ("timeout", None))
q3 = queue.Queue()
status, done = host._wait_turn(
q3, timeout=5, interrupt_check=lambda: True)
self.assertEqual((status, done), ("interrupted-requested", None))
q4 = queue.Queue()
seen = []
status, _ = host._wait_turn(q4, timeout=0.35, progress_interval=0.1,
on_progress=lambda e: seen.append(e))
self.assertEqual(status, "timeout")
self.assertGreaterEqual(len(seen), 2)
self.assertEqual(seen, sorted(seen))
# 不限时 + 中断检查抛错时不中断,继续等到 done。
q5 = queue.Queue()
def _later():
time.sleep(0.2)
q5.put("done")
threading.Thread(target=_later, daemon=True).start()
status, done = host._wait_turn(
q5, timeout=None,
interrupt_check=lambda: (_ for _ in ()).throw(RuntimeError("x")))
self.assertEqual((status, done), ("completed", "done"))
def test_thread_cli_result(self):
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_effective_cfg_and_resolve_path(self):
cfg = {"max_daily_turns": 5, "model": "m"}
out = host._effective_cfg(cfg, True)
self.assertIsNone(out["max_daily_turns"])
self.assertEqual(cfg["max_daily_turns"], 5)
self.assertIs(host._effective_cfg(cfg, False), cfg)
self.assertEqual(host._resolve_path("/base", "rel"), "/base/rel")
self.assertEqual(host._resolve_path("/base", "/abs"), "/abs")
self.assertEqual(host._resolve_path("/base", ""), "")
def test_load_config_file(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.assertEqual(host._load_config_file(None), {})
self.assertEqual(host._load_config_file(
os.path.join(tmp.name, "missing.json")), {})
good = os.path.join(tmp.name, "good.json")
with open(good, "w", encoding="utf-8") as f:
json.dump({"a": 1}, f)
self.assertEqual(host._load_config_file(good), {"a": 1})
bad = os.path.join(tmp.name, "bad.json")
with open(bad, "w", encoding="utf-8") as f:
f.write("{bad")
with self.assertRaises(RuntimeError):
host._load_config_file(bad)
class ServerProtocolTestCase(FakeServerCase):
def test_initialize_and_thread_methods(self):
result = self.server.initialize()
self.assertEqual(result, {"server": "fake"})
self.assertTrue(self.server.is_alive())
thread_id = self.server.create_thread(self.tmp.name, model="m1",
instructions="be nice")
self.assertEqual(thread_id, "th-1")
listed = self.server.list_threads()
self.assertEqual(listed, {"threads": [{"id": "th-1"}]})
self.assertEqual(self.server.resume_thread("th-1"),
{"resumed": True})
self.assertEqual(self.server.fork_thread("th-1", cwd=self.tmp.name,
sandbox="read-only"),
"th-2")
resp = self.server.archive_thread("th-1")
self.assertIn("result", resp)
review = self.server.start_review("th-1", {"type": "uncommitted"})
self.assertEqual(review, {"review": "ok"})
self.assertIsNone(self.server.interrupt_turn("th-1", None))
self.assertIsNone(self.server.interrupt_turn("", "turn-1"))
resp = self.server.interrupt_turn("th-1", "turn-1")
self.assertEqual(resp["result"], {"interrupted": True})
# create_thread 的参数确经协议发出(cwd 取绝对路径)。
starts = self._requests("thread/start")
self.assertEqual(starts[0]["params"]["model"], "m1")
self.assertEqual(starts[0]["params"]["developerInstructions"],
"be nice")
self.assertTrue(os.path.isabs(starts[0]["params"]["cwd"]))
def test_run_turn_success_with_usage_and_params(self):
self.server.initialize()
result = self.server.run_turn(
"th-1", "hello", model="m1", effort="high",
sandbox="workspace-write", parent_turn_id="p1",
root_turn_id="r1", timeout=30, progress_interval=0)
self.assertEqual(result["status"], "completed")
self.assertEqual(result["text"], "done-text")
self.assertEqual(result["turn_id"], "turn-1")
self.assertEqual(result["root_turn_id"], "root-9")
self.assertIsNone(result["error"])
self.assertEqual(result["usage"]["input_tokens"], 10)
self.assertEqual(result["usage"]["output_tokens"], 5)
self.assertEqual(result["usage"]["total_tokens"], 15)
self.assertEqual(result["usage"]["completions"], 1)
starts = self._requests("turn/start")
self.assertEqual(len(starts), 1)
params = starts[0]["params"]
self.assertEqual(params["sandboxPolicy"],
{"type": "workspaceWrite"})
self.assertEqual(params["model"], "m1")
self.assertEqual(params["effort"], "high")
self.assertEqual(params["parentTurnId"], "p1")
self.assertEqual(params["rootTurnId"], "r1")
self.assertNotIn("th-1", self.server.turn_queues) # finally 清理
def test_run_turn_error_response(self):
self.server.initialize()
# 换一个 turn-error 模式的 server:turn/start 直接回 error。
self._close_server()
self.mode = "turn-error"
self.setUp()
self.server.initialize()
result = self.server.run_turn("th-1", "hi", timeout=10,
progress_interval=0)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["text"], "")
self.assertIn("error", result)
def test_append_trace(self):
self.server.initialize()
state_path = os.path.join(self.tmp.name, "state.json")
self.server.set_trace_file(state_path, "task1")
self.server._append_trace(
{"method": "x", "params": {"item": {
"type": "commandExecution", "id": "e1", "command": "ls",
"cwd": "/tmp", "exitCode": 0, "durationMs": 3}}})
trace_path = os.path.join(self.tmp.name, "host-traces",
"task1.jsonl")
with open(trace_path, encoding="utf-8") as f:
entry = json.loads(f.read().strip())
self.assertEqual(entry["kind"], "exec")
self.assertEqual(entry["command"], "ls")
before = self.server._trace_lines
self.server._append_trace({"method": "x", "params": {"item": {
"type": "userMessage", "id": "u1", "text": "hi"}}})
self.assertEqual(self.server._trace_lines, before) # 不进链路
class ServerTurnNeverTestCase(FakeServerCase):
mode = "turn-never"
def test_run_turn_timeout(self):
self.server.initialize()
result = self.server.run_turn("th-1", "hi", timeout=2,
progress_interval=0)
self.assertEqual(result["status"], "timeout")
self.assertEqual(result["text"], "")
self.assertEqual(result["turn_id"], "turn-1")
self.assertIsNone(result["usage"])
def test_run_turn_interrupted_via_file(self):
self.server.initialize()
interrupt_file = os.path.join(self.tmp.name, "irq.json")
with open(interrupt_file, "w", encoding="utf-8") as f:
f.write("{}")
result = self.server.run_turn("th-1", "hi", timeout=60,
progress_interval=0,
interrupt_file=interrupt_file)
self.assertEqual(result["status"], "interrupted")
self.assertFalse(os.path.exists(interrupt_file)) # 属主删请求文件
interrupts = self._requests("turn/interrupt")
self.assertEqual(len(interrupts), 1)
class ServerTurnFailedTestCase(FakeServerCase):
mode = "turn-failed"
def test_run_turn_failed_with_history_error(self):
db_path = os.path.join(self.codex_home,
"thread_history_1.sqlite")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE thread_turns(thread_id TEXT,"
" turn_id TEXT, error_json TEXT)")
conn.execute("INSERT INTO thread_turns VALUES('th-1', 'turn-1', ?)",
(json.dumps({"message": "history boom"}),))
conn.commit()
conn.close()
self.server.initialize()
result = self.server.run_turn("th-1", "hi", timeout=10,
progress_interval=0)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["error"], "history boom")
def test_turn_error_from_history_branches(self):
self.assertIsNone(self.server._turn_error_from_history("", "t"))
self.assertIsNone(self.server._turn_error_from_history("th", ""))
# 库不存在 -> None;库存在但无行 -> None;坏 JSON -> None。
self.assertIsNone(
self.server._turn_error_from_history("th-1", "turn-1"))
db_path = os.path.join(self.codex_home,
"thread_history_1.sqlite")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE thread_turns(thread_id TEXT,"
" turn_id TEXT, error_json TEXT)")
conn.execute("INSERT INTO thread_turns VALUES('th-1', 'turn-9', ?)",
("{bad",))
conn.commit()
conn.close()
self.assertIsNone(
self.server._turn_error_from_history("th-1", "turn-9"))
self.assertIsNone(
self.server._turn_error_from_history("th-1", "turn-missing"))
class ServerErrorTestCase(FakeServerCase):
mode = "init-error"
def test_initialize_error_raises(self):
with self.assertRaises(RuntimeError):
self.server.initialize()
class ServerListErrorTestCase(FakeServerCase):
mode = "list-error"
def test_list_threads_error_raises(self):
self.server.initialize()
with self.assertRaises(RuntimeError):
self.server.list_threads()
class ServerHangTestCase(FakeServerCase):
mode = "hang"
def test_request_timeout_raises(self):
self.server.initialize()
with self.assertRaises(queue.Empty):
self.server.request("thread/list", {}, timeout=1)
class ServerProbeTestCase(FakeServerCase):
probe = True
def test_read_loop_answers_and_records_events(self):
self.server.initialize()
# 探针回答确已发出:elicitation + 通用审批各一条。
self.assertTrue(_wait_for(
lambda: os.path.exists(self.resp_log)
and len(open(self.resp_log, encoding="utf-8").readlines()) >= 2,
timeout=10))
with open(self.resp_log, encoding="utf-8") as f:
answers = [json.loads(line) for line in f]
by_id = {a["id"]: a for a in answers}
# deny 审批:elicitation -> decline,通用 -> deny。
self.assertEqual(by_id[9001]["result"], {"action": "decline"})
self.assertEqual(by_id[9002]["result"], {"decision": "deny"})
# 启动时发的通知确已进 events(含 thread 字典形态那条)。
methods = [e.get("method") for e in self.server.events]
self.assertIn("thread/tokenUsage/updated", methods)
class ServerProbeAutoTestCase(FakeServerCase):
probe = True
approval = "auto"
def test_read_loop_auto_approval(self):
self.server.initialize()
self.assertTrue(_wait_for(
lambda: os.path.exists(self.resp_log)
and len(open(self.resp_log, encoding="utf-8").readlines()) >= 2,
timeout=10))
with open(self.resp_log, encoding="utf-8") as f:
answers = [json.loads(line) for line in f]
by_id = {a["id"]: a for a in answers}
self.assertEqual(by_id[9001]["result"],
{"action": "accept", "content": {}})
self.assertEqual(by_id[9002]["result"], {"decision": "approve"})
def _init_git_repo(path):
subprocess.run(["git", "init", "-q", path], check=True)
subprocess.run(["git", "-C", path, "config", "user.email", "t@t"],
check=True)
subprocess.run(["git", "-C", path, "config", "user.name", "t"],
check=True)
with open(os.path.join(path, "a.py"), "w", encoding="utf-8") as f:
f.write("x = 1\n")
subprocess.run(["git", "-C", path, "add", "."], check=True)
subprocess.run(["git", "-C", path, "commit", "-qm", "init"],
check=True)
class StateHelpersTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.state_path = os.path.join(self.tmp.name, "host-state.json")
def test_save_load_merge_and_version(self):
state = {"tasks": {"a": {"status": "running"}},
"automations": [{"name": "daily"}]}
host.save_state(self.state_path, state)
self.assertEqual(state["version"], 1)
loaded = host.load_state(self.state_path)
self.assertEqual(loaded["tasks"]["a"]["status"], "running")
self.assertEqual(loaded["_loaded_version"], 1)
# 第二进程的任务在磁盘上:保存时合并保留,不被覆盖。
with open(self.state_path, encoding="utf-8") as f:
disk = json.load(f)
disk["tasks"]["other"] = {"status": "completed"}
disk["version"] = 5
with open(self.state_path, "w", encoding="utf-8") as f:
json.dump(disk, f)
host.save_state(self.state_path, state)
self.assertIn("other", state["tasks"])
self.assertIn("a", state["tasks"])
self.assertTrue(os.path.exists(self.state_path + ".bak"))
def test_strict_read_and_restore(self):
self.assertEqual(host.load_state_strict(self.state_path),
({"tasks": {}, "automations": []}, None))
with open(self.state_path, "w", encoding="utf-8") as f:
f.write("{bad json")
data, err = host.load_state_strict(self.state_path)
self.assertIsNone(data)
self.assertIsNotNone(err)
ok, msg = host.restore_state(self.state_path)
self.assertFalse(ok) # 无备份时恢复失败,如实报告
self.assertIn("没有可用的备份", msg)
with open(self.state_path + ".bak", "w", encoding="utf-8") as f:
json.dump({"version": 3, "tasks": {}, "automations": []}, f)
with open(self.state_path, "w", encoding="utf-8") as f:
f.write("{bad json")
ok, msg = host.restore_state(self.state_path)
self.assertTrue(ok)
self.assertIn(".bak", msg)
with open(self.state_path, encoding="utf-8") as f:
restored = json.load(f)
self.assertEqual(restored["version"], 3)
ok, msg = host.restore_state(self.state_path)
self.assertTrue(ok)
self.assertIn("无需恢复", msg)
# 严格保存读:坏 JSON / 顶层非对象 / tasks 非对象 /
# automations 非列表一律拒绝覆盖。
with open(self.state_path, "w", encoding="utf-8") as f:
f.write("{bad")
with self.assertRaises(RuntimeError):
host._read_state_file_for_save(self.state_path)
for bad in ('[1, 2]',
'{"tasks": [], "automations": []}',
'{"tasks": {}, "automations": {}}'):
with open(self.state_path, "w", encoding="utf-8") as f:
f.write(bad)
with self.assertRaises(RuntimeError):
host._read_state_file_for_save(self.state_path)
self.assertEqual(host._read_state_file(
os.path.join(self.tmp.name, "missing.json")),
{"tasks": {}, "automations": []})
def test_state_tmp_path_unique(self):
a = host._state_tmp_path(self.state_path)
b = host._state_tmp_path(self.state_path)
self.assertNotEqual(a, b)
self.assertTrue(a.startswith(self.state_path + ".tmp."))
def test_recover_interrupted_and_log_and_check(self):
old = time.time() - 7200
fresh = time.time()
state = {"version": 1, "tasks": {
"old-run": {"status": "running", "last_turn_at": old,
"cwd": os.path.join(self.tmp.name, "nope")},
"fresh-run": {"status": "running", "last_turn_at": fresh},
"done": {"status": "failed", "cwd": self.tmp.name},
}}
recovered = host.recover_interrupted(state, self.state_path,
older_than=3600)
self.assertEqual(recovered, ["old-run"])
self.assertEqual(state["tasks"]["old-run"]["status"], "interrupted")
self.assertEqual(state["tasks"]["fresh-run"]["status"], "running")
log_path = os.path.join(self.tmp.name, "host-run.log")
self.assertIn("old-run", open(log_path, encoding="utf-8").read())
host.log_event(self.state_path, "hello-log")
self.assertIn("hello-log", open(log_path, encoding="utf-8").read())
checked = host.check_state(self.state_path, state)
self.assertEqual(checked["failed_tasks"], ["done"])
self.assertEqual(checked["missing_worktrees"], ["old-run"])
self.assertEqual(checked["version"], 1)
def test_review_usage_budget_notify(self):
task = {}
comment = host.add_review_comment(task, "a.py", 10, "fix this")
self.assertEqual(comment["status"], "open")
self.assertEqual(comment["line"], 10)
self.assertEqual(len(host.open_review_comments(task)), 1)
prompt = host.build_fix_prompt(task)
self.assertIn("a.py:10: fix this", prompt)
task["review_comments"][0]["status"] = "resolved"
self.assertEqual(host.open_review_comments(task), [])
self.assertNotIn("a.py:10", host.build_fix_prompt(task))
state = {}
usage = host.record_turn_usage(state)
self.assertEqual(usage["turns"], 1)
host.record_turn_usage(state, failed=True)
self.assertEqual(state["usage"]["failed_turns"], 1)
self.assertFalse(host.budget_exceeded(state, {}))
self.assertFalse(host.budget_exceeded(
state, {"max_daily_turns": "bad"}))
self.assertTrue(host.budget_exceeded(
state, {"max_daily_turns": 2}))
# notify:无命令时只写日志;坏模板时记 template error 且不跑。
msg = host.notify_failure(self.state_path, {}, "t1",
{"status": "failed", "text": "boom"})
self.assertIn("FAILED t1", msg)
msg2 = host.notify_failure(
self.state_path, {"notify_command": "echo {unknown}"}, "t1",
{"status": "failed", "text": "x"})
self.assertIn("FAILED t1", msg2)
log_text = open(os.path.join(self.tmp.name, "host-run.log"),
encoding="utf-8").read()
self.assertIn("template error", log_text)
# 正常命令:shell 元字符被 quote,不执行注入。注意:重定向
# 本身就会建文件,所以注入断言不用重定向探针,改用命令
# 替换探针——被 quote 后 $(touch ...) 只当普通文本回显。
marker = os.path.join(self.tmp.name, "pwned")
host.notify_failure(
self.state_path,
{"notify_command": "echo {text}"}, "t1",
{"status": "failed", "text": "$(touch " + marker + ")"})
self.assertFalse(os.path.exists(marker))
# 良性文本仍能正常走命令(写日志里有 rc 记录)。
out_file = os.path.join(self.tmp.name, "notified.txt")
host.notify_failure(
self.state_path,
{"notify_command": "echo {text} > " + out_file}, "t1",
{"status": "failed", "text": "hello"})
self.assertEqual(open(out_file, encoding="utf-8").read().strip(),
"hello")
class GitHelpersTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.repo = os.path.join(self.tmp.name, "repo")
os.makedirs(self.repo)
_init_git_repo(self.repo)
def test_topo_sort(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[0], "a")
self.assertEqual(ordered[1], "c") # 同层优先级降序
self.assertEqual(ordered[2], "b")
with self.assertRaises(ValueError):
host.topo_sort_jobs([{"name": "x", "depends_on": ["missing"]}])
with self.assertRaises(ValueError):
host.topo_sort_jobs([{"name": "x", "depends_on": ["x"]}])
with self.assertRaises(ValueError):
host.topo_sort_jobs([{"name": "x", "depends_on": ["y"]},
{"name": "y", "depends_on": ["x"]}])
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.assertEqual(host.detect_verify_command(self.repo),
"python3 -m unittest discover -s tests -v")
rc, out = host.run_verify("echo hello", self.repo)
self.assertEqual((rc, out), (0, "hello"))
rc, out = host.run_verify("", self.repo)
self.assertEqual((rc, out), (0, ""))
rc, out = host.run_verify("exit 3", self.repo)
self.assertEqual(rc, 3)
def test_task_diff(self):
with open(os.path.join(self.repo, "a.py"), "a",
encoding="utf-8") as f:
f.write("y = 2\n")
with open(os.path.join(self.repo, "new.py"), "w",
encoding="utf-8") as f:
f.write("print('new')\n")
diff = host.task_diff(self.repo)
self.assertIn("a.py", diff["status"])
self.assertIn("new.py", diff["untracked"])
self.assertEqual(diff["untracked_contents"]["new.py"],
"print('new')\n")
def test_merge_task_success_and_verify_fail(self):
# 成功路:分支 worktree 改文件,验测 true,通过后分支删除。
wt = os.path.join(self.tmp.name, "wt-ok")
subprocess.run(["git", "-C", self.repo, "worktree", "add", wt,
"-b", "host/ok"], check=True, capture_output=True)
with open(os.path.join(wt, "a.py"), "a", encoding="utf-8") as f:
f.write("z = 3\n")
ok, msg = host.merge_task(self.repo, wt, "host/ok", "ok",
verify_command="true")
self.assertTrue(ok, msg)
self.assertIn("z = 3", open(os.path.join(self.repo, "a.py"),
encoding="utf-8").read())
branches = subprocess.run(
["git", "-C", self.repo, "branch", "--list"],
capture_output=True, text=True).stdout
self.assertNotIn("host/ok", branches)
# 验测失败路:回滚到合并前 HEAD,分支与 worktree 保留。
wt2 = os.path.join(self.tmp.name, "wt-bad")
subprocess.run(["git", "-C", self.repo, "worktree", "add", wt2,
"-b", "host/bad"], check=True, capture_output=True)
with open(os.path.join(wt2, "a.py"), "a", encoding="utf-8") as f:
f.write("bad = 1\n")
ok, msg = host.merge_task(self.repo, wt2, "host/bad", "bad",
verify_command="false")
self.assertFalse(ok)
self.assertIn("验测失败", msg)
self.assertNotIn("bad = 1", open(os.path.join(self.repo, "a.py"),
encoding="utf-8").read())
branches = subprocess.run(
["git", "-C", self.repo, "branch", "--list"],
capture_output=True, text=True).stdout
self.assertIn("host/bad", branches)
def test_copy_tree_and_seed_worktree(self):
src = os.path.join(self.tmp.name, "src")
os.makedirs(os.path.join(src, "__pycache__"))
os.makedirs(os.path.join(src, ".git"))
with open(os.path.join(src, "keep.txt"), "w") as f:
f.write("keep")
with open(os.path.join(src, "__pycache__", "x.pyc"), "w") as f:
f.write("pyc")
with open(os.path.join(src, ".git", "config"), "w") as f:
f.write("git")
dst = os.path.join(self.tmp.name, "dst")
host.copy_tree_excluding_git(src, dst)
self.assertTrue(os.path.exists(os.path.join(dst, "keep.txt")))
self.assertFalse(os.path.exists(os.path.join(dst, ".git")))
self.assertFalse(os.path.exists(os.path.join(dst, "__pycache__")))
seed = os.path.join(self.tmp.name, "seed")
os.makedirs(os.path.join(seed, "sub"))
with open(os.path.join(seed, "f.txt"), "w") as f:
f.write("seeded")
with open(os.path.join(seed, "sub", "g.txt"), "w") as f:
f.write("g")
os.symlink(os.path.join(seed, "f.txt"),
os.path.join(seed, "link.txt"))
cwd = os.path.join(self.tmp.name, "cwd")
os.makedirs(cwd)
host._seed_worktree(cwd, seed)
self.assertEqual(open(os.path.join(cwd, "f.txt")).read(), "seeded")
self.assertEqual(open(os.path.join(cwd, "sub", "g.txt")).read(),
"g")
self.assertFalse(os.path.islink(os.path.join(cwd, "link.txt")))
with self.assertRaises(RuntimeError):
host._seed_worktree(cwd, os.path.join(self.tmp.name, "nope"))
def test_pr_helpers(self):
checks, all_pass = host.parse_pr_checks(
"build\tpass\ntest\tfail\n")
self.assertFalse(all_pass)
self.assertEqual(checks[0], {"name": "build", "state": "pass"})
checks, all_pass = host.parse_pr_checks("✓ build pass\n✓ lint pass\n")
self.assertTrue(all_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.assertFalse(all_pass)
task = {"name": "t1", "prompt": "do x", "pr_number": 7,
"pr_url": "https://example.invalid/pr/7"}
self.assertIn("do x", host.build_pr_body(task))
self.assertIn("无未解决评论", host.build_pr_body(task))
class Resp:
def __init__(self, rc, out, err=""):
self.returncode = rc
self.stdout = out
self.stderr = err
calls = []
def runner(args):
calls.append(args)
if args[:2] == ["pr", "checks"]:
return Resp(0, "build\tpass\n")
if args[:2] == ["pr", "merge"]:
return Resp(0, "merged")
return Resp(1, "", "nope")
info = host.get_pr_checks(task, gh_runner=runner)
self.assertTrue(info["all_pass"])
ok, msg = host.merge_pr(task, gh_runner=runner)
self.assertTrue(ok)
self.assertEqual(task["status"], "merged")
self.assertIn("pr", " ".join(calls[0]) if calls else "")
with self.assertRaises(ValueError):
host.get_pr_checks({"name": "t2"}, gh_runner=runner)
with self.assertRaises(RuntimeError):
host.get_pr_checks(task, gh_runner=lambda a: Resp(1, "", "bad"))
# checks 未全过时 merge_pr 拒绝且不调 merge。
calls.clear()
task2 = {"name": "t3", "pr_number": 8,
"pr_url": "https://example.invalid/pr/8"}
def failing_runner(args):
calls.append(args)
return Resp(0, "build\tfail\n")
ok, msg = host.merge_pr(task2, gh_runner=failing_runner)
self.assertFalse(ok)
self.assertNotIn("merge", [c[1] for c in calls])
# create_pr 前置校验:无分支 / 状态不对 / 无 remote。
with self.assertRaises(ValueError):
host.create_pr(self.repo, {"name": "t", "status": "completed"},
gh_runner=runner)
with self.assertRaises(ValueError):
host.create_pr(self.repo,
{"name": "t", "branch": "b", "status": "running"},
gh_runner=runner)
with self.assertRaises(RuntimeError):
host.create_pr(self.repo,
{"name": "t", "branch": "b",
"status": "completed"}, gh_runner=runner)
self.assertIsNone(host._github_remote_url(self.repo))
changed, path = host.generate_github_workflow(self.repo)
self.assertTrue(changed)
self.assertTrue(os.path.exists(path))
changed, _ = host.generate_github_workflow(self.repo)
self.assertFalse(changed)
def test_dispatch_no_worktree_and_run_task_turn(self):
# 用假 server(ok 模式)走 dispatch(不用 worktree,避免写
# HERE/work)+ run_task_turn 全程:状态机与用量计数真实变化。
tmp2 = tempfile.TemporaryDirectory()
self.addCleanup(tmp2.cleanup)
fake = os.path.join(tmp2.name, "fake.py")
with open(fake, "w", encoding="utf-8") as f:
f.write(FAKE_SCRIPT)
os.chmod(fake, 0o755)
env = {"FAKE_MODE": "ok",
"FAKE_REQ_LOG": os.path.join(tmp2.name, "req.jsonl"),
"FAKE_RESP_LOG": os.path.join(tmp2.name, "resp.jsonl"),
"FAKE_PROBE": ""}
old = {}
for k, v in env.items():
old[k] = os.environ.get(k)
os.environ[k] = v
server = Server(codex_bin=fake,
codex_home=os.path.join(tmp2.name, "home"))
try:
server.initialize()
state = {"tasks": {}, "automations": []}
task = host.dispatch(server, state, "t1", "do it", self.repo,
"m1", None, "workspace-write", "never",
use_worktree=False)
self.assertEqual(task["status"], "running")
self.assertEqual(task["thread_id"], "th-1")
self.assertEqual(task["cwd"], self.repo)
result = host.run_task_turn(server, state, task, "do it",
"m1", None)
self.assertEqual(result["status"], "completed")
self.assertEqual(task["status"], "completed")
self.assertEqual(task["last_text"], "done-text")
self.assertEqual(state["usage"]["turns"], 1)
with self.assertRaises(RuntimeError):
host.dispatch(server, state, "t1", "again", self.repo,
"m1", None, "workspace-write", "never",
use_worktree=False)
finally:
server.close()
for k, v in old.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
if __name__ == "__main__":
unittest.main()