新增 run_tests.py:三套件统一入口,只加仓库根(deploy 套件 再加 deploy/ 目录在根之前),各套件走独立子进程;verify 作 业改走运行器,coverage 作业因子进程测不到覆盖率保持原 discover 命令。tests/ 13 个文件的 sys.path 前置 import 全部 改回正常 import(含 test_host_trace 的 try/except 回落); deploy 的 deploy 目录插入与 verify-multiuser 的 spec 加载按 陷阱 2 如实保留(仅删 verify-multiuser 冗余的根插入)。 .coveragerc omit 加 */run_tests.py,使新运行器文件不计入 生产覆盖率。门槛未动(仍 75%),未补新测试。
1111 lines
48 KiB
Python
1111 lines
48 KiB
Python
"""覆盖率 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 tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
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()
|