refactor: remaining longfunc small extractions after #231 (no module split)

Only pure computation/validation sub-blocks of _handle_github_webhook,
_create_followup, _deliver_to_thread and _task_chat_history are extracted;
all DB writes, audits, external calls and side-effect blocks stay in the
callers, error strings/codes and response shapes verbatim. _deliver_to_thread
uses the shared goal classifier only for /goal input: its non-goal terminal
path still goes to CAS revive (not 409) and its merged path keeps its own
wording. New tests: test_longfunc_remaining.py (15).
This commit is contained in:
host committed 2026-11-03 02:48:55 +00:00
1 parent 1accfac844
commit b4a69e1e71
2 files changed
+460 -171

No files matched your search

+252 -171
View File
@@ -2226,6 +2226,177 @@ def _summarize_metrics_tasks(rows, member_ids, now):
running_over_300s += 1
return by_status, durations, failures_last_24h, running_over_300s
def _parse_github_comment_with_review_fallback(payload):
"""GitHub 评论解析:issue_comment 直解,解不出且是
pull_request_review_comment 时把 pull_request.number 归一化
成 parser 要的 issue.number 再解一次。纯解析,无 DB、
无审计、无外部调用;_handle_github_webhook 的原样抽出。"""
result = parse_github_pr_comment(payload)
if result is None and payload.get("pull_request") and \
payload.get("comment"):
normalized = dict(payload)
normalized["issue"] = {
"number": (payload["pull_request"] or {}).get("number")}
result = parse_github_pr_comment(normalized)
return result
def _webhook_fix_review_prompt(result):
"""webhook /fix-review 建任务 prompt:固定模板 + 截断后的
评论摘录(上限 2000 字),不原样贴评论全文。纯构造。"""
return (
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
% (result.get("repo"), result.get("pr_number"),
result.get("author"),
(result.get("comment") or "")[:2000]))
def _validate_followup_body(body):
"""后续消息请求体校验(纯计算):返回 (text, mode, error),
error 为 None 表示通过,否则为 (http_code, payload),调用方
按原样发响应并返回。文案与状态码逐字取自 _create_followup
原代码,不含 DB、审计与建任务。"""
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
return None, None, (400, {"error": "body required"})
text = text.strip()
if len(text) > 20000:
return None, None, (400, {"error": "body too long"})
mode = body.get("mode", "queued")
if mode not in ("queued", "direct"):
return None, None, (400, {
"error": "mode must be queued or direct"})
return text, mode, None
def _classify_goal_followup(task, goal_target):
"""后续消息的 /goal 前置判定(纯计算:_create_followup 全量
用它;_deliver_to_thread 只在 goal_target 非空时用它——
两者的 goal 前置判定逐字相同,且 queued 与终态互斥、merged
已先于终态排除,判定顺序差异不可观测;非 goal 路两者语义
不同,thread 的非 goal 路不进本函数,见调用方注释)。
返回 (action, code, payload):
- ("error", code, payload):调用方按原样发响应/返回;
- ("convert_queued", None, None):调用方走 queued 原地转 goal
的副作用路(建任务/审计仍在调用方);
- ("revive_terminal", None, None):调用方走终态复活转 goal
的副作用路;
- ("continue", None, None):落到普通 followup 插入路
(running 的 /goal 即此路,响应里再标 goal_queued)。
本函数只读 task 字典与 goal_target,绝不调 DB、审计与
建任务,调用一次无副作用重复风险。"""
if goal_target is not None:
if not goal_target:
return "error", 400, {"error": "goal 目标不能为空"}
if task.get("goal_mode") or task.get("verify_command"):
return "error", 409, {
"error": "主线已有 goal,/goal 不会重复创建",
"status": task["status"],
"goal": task.get("goal") or task.get("prompt"),
}
if task.get("plan"):
return "error", 400, {
"error": "plan 模式与 goal 模式冲突"}
if task["status"] == "merged":
return "error", 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"],
}
if task["status"] == "queued":
return "convert_queued", None, None
if task["status"] in TERMINAL_STATUSES:
return "revive_terminal", None, None
if task["status"] not in ("queued", "running"):
return "error", 409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
}
return "continue", None, None
if task["status"] not in ("queued", "running"):
return "error", 409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
}
return "continue", None, None
def _seconds_to_ms(seconds):
"""秒 -> 毫秒整数时间戳,非法/缺失返回 None(不伪造时间戳)。
_task_chat_history 内嵌 _ms 的原样抽出,纯计算。"""
try:
return int(float(seconds) * 1000)
except (TypeError, ValueError):
return None
def _chat_history_user_entries(task, cont_rows, fu_rows):
"""交替历史的用户侧条目(纯构造):首条 prompt、继续消息、
后续消息,各标 (0, ts_ms, dict)——用户项在同一毫秒排在链路
项之前靠排序键第一元组 0 保证。行转字典用原有
_continue_message_to_dict / _followup_to_dict。"""
merged = []
if task.get("prompt"):
merged.append((0, _seconds_to_ms(task.get("created_at")), {
"id": "task-prompt", "role": "user", "source": "prompt",
"label": "用户", "text": task["prompt"],
"ts_ms": _seconds_to_ms(task.get("created_at"))}))
for r in cont_rows:
d = _continue_message_to_dict(r)
merged.append((0, _seconds_to_ms(d["created_at"]), {
"id": "continue-" + d["id"], "role": "user",
"source": "continue", "label": "用户",
"text": d["body"],
"ts_ms": _seconds_to_ms(d["created_at"])}))
for r in fu_rows:
d = _followup_to_dict(r)
merged.append((0, _seconds_to_ms(d["created_at"]), {
"id": "followup-" + d["id"], "role": "user",
"source": "followup", "label": "后续消息",
"text": d["body"], "ts_ms": _seconds_to_ms(d["created_at"])}))
return merged
def _chat_history_trace_entries(entries, start_ms):
"""交替历史的链路侧条目(纯构造):跳过直接发送后续消息在
链路里留的 fu- 回声(followups 表里同一条已作用户消息返回,
否则同一条会先用户、再助手各出现一次);有 ts 的条目按 ts
排序,无 ts 的按链路顺序贴在上一条有 ts 的条目之后、输出
ts_ms 为 null,不伪造时间戳。返回 (1, sort_ms, dict) 列表。"""
merged = []
last_trace_ms = start_ms
for e in entries:
if not isinstance(e, dict):
continue
if str(e.get("id") or "").startswith("fu-"):
continue
msg = _chat_message_from_trace(e)
ts = e.get("ts")
if isinstance(ts, (int, float)):
last_trace_ms = int(ts)
sort_ms = int(ts)
else:
sort_ms = last_trace_ms
msg["ts_ms"] = int(ts) if isinstance(ts, (int, float)) else None
merged.append((1, sort_ms, msg))
return merged
def _thread_non_goal_guard(task):
"""_deliver_to_thread 在 /goal 判定落空后的非 goal 门禁
(纯计算):goal 任务与 merged 各 409,返回 (code, payload);
通过返回 None。注意 merged 在前置判定里已先于终态排除,
这里是普通(非 goal)路的第二次 merged 门禁,原文顺序
是 goal 判定 -> 本门禁 -> 终态 CAS 复活,调用方保持该顺序。"""
if task.get("verify_command") or task.get("goal_mode"):
return 409, {"error": "goal 任务暂不支持后续消息"}
if task["status"] == "merged":
return 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"]}
return None
class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -5234,90 +5405,56 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
if self._reject_if_over_quota_for_task(user_id, task):
return
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
return
text = text.strip()
if len(text) > 20000:
self._send_json(400, {"error": "body too long"})
return
mode = body.get("mode", "queued")
if mode not in ("queued", "direct"):
self._send_json(400, {
"error": "mode must be queued or direct"})
text, mode, body_err = _validate_followup_body(body)
if body_err is not None:
self._send_json(body_err[0], body_err[1])
return
goal_target = _parse_goal_command(text)
if goal_target is not None:
if not goal_target:
self._send_json(400, {"error": "goal 目标不能为空"})
return
if task.get("goal_mode") or task.get("verify_command"):
# /goal 前置判定见 _classify_goal_followup(纯计算);
# 下面只剩各判定对应的副作用路,顺序与原文一致:
# error 先返回,queued 原地转 goal,终态复活转 goal,
# running 落到普通 followup 插入路(不打断本轮,只排队;
# worker 在本轮完成、排空到这条时把主线转成 goal,见
# worker 排空段;本轮若失败则这条留在 pending,等下次
# 成功轮次再转),响应里标明是 goal。
goal_action, goal_code, goal_payload = _classify_goal_followup(
task, goal_target)
if goal_action == "error":
self._send_json(goal_code, goal_payload)
return
if goal_action == "convert_queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "主线已有 goal,/goal 不会重复创建",
"status": task["status"],
"goal": task.get("goal") or task.get("prompt"),
})
return
if task.get("plan"):
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突"})
return
if task["status"] == "merged":
self._send_json(409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"error": "任务状态已变化,请刷新后重试",
"status": task["status"],
})
return
if task["status"] == "queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"],
})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal=%s" % (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_converted",
goal=goal_target))
return
if task["status"] in TERMINAL_STATUSES:
new_task = self._goal_revive_terminal(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "任务已有待续跑消息或状态已变化,"
"请刷新后重试",
"status": task["status"],
})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal_revived=%s"
% (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_revived",
goal=goal_target))
return
# running:不打断本轮,只排队;worker 在本轮完成、
# 排空到这条时把主线转成 goal(见 worker 排空段),
# 本轮若失败则这条留在 pending,等下次成功轮次再转。
# 下面落到普通 followup 插入路,但响应里标明是 goal。
if task["status"] not in ("queued", "running"):
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal=%s" % (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_converted",
goal=goal_target))
return
if goal_action == "revive_terminal":
new_task = self._goal_revive_terminal(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"error": "任务已有待续跑消息或状态已变化,"
"请刷新后重试",
"status": task["status"],
})
return
elif task["status"] not in ("queued", "running"):
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
})
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal_revived=%s"
% (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_revived",
goal=goal_target))
return
if task.get("verify_command") and goal_target is None:
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
@@ -5859,51 +5996,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
finally:
conn.close()
merged = []
def _ms(seconds):
try:
return int(float(seconds) * 1000)
except (TypeError, ValueError):
return None
if task.get("prompt"):
merged.append((0, _ms(task.get("created_at")), {
"id": "task-prompt", "role": "user", "source": "prompt",
"label": "用户", "text": task["prompt"],
"ts_ms": _ms(task.get("created_at"))}))
for r in cont_rows:
d = _continue_message_to_dict(r)
merged.append((0, _ms(d["created_at"]), {
"id": "continue-" + d["id"], "role": "user",
"source": "continue", "label": "用户",
"text": d["body"], "ts_ms": _ms(d["created_at"])}))
for r in fu_rows:
d = _followup_to_dict(r)
merged.append((0, _ms(d["created_at"]), {
"id": "followup-" + d["id"], "role": "user",
"source": "followup", "label": "后续消息",
"text": d["body"], "ts_ms": _ms(d["created_at"])}))
last_trace_ms = _ms(task.get("created_at"))
for e in entries:
if not isinstance(e, dict):
continue
if str(e.get("id") or "").startswith("fu-"):
# 直接发送的后续消息已在链路里留了一条回声
# (_append_direct_trace 写 kind=message),
# followups 表里同一条已作用户消息返回;这里
# 跳过回声,否则同一条消息会先以用户、再以
# 助手各出现一次。
continue
msg = _chat_message_from_trace(e)
ts = e.get("ts")
if isinstance(ts, (int, float)):
last_trace_ms = int(ts)
sort_ms = int(ts)
else:
sort_ms = last_trace_ms
msg["ts_ms"] = int(ts) if isinstance(ts, (int, float)) else None
merged.append((1, sort_ms, msg))
# 用户侧与链路侧条目构造见 _chat_history_user_entries /
# _chat_history_trace_entries(纯构造);DB 读、权限、
# legacy 计数与下面的排序/响应仍在本函数。
merged = _chat_history_user_entries(task, cont_rows, fu_rows)
merged += _chat_history_trace_entries(
entries, _seconds_to_ms(task.get("created_at")))
# 排序键:(时间, 用户优先, 原顺序)。merged 里用户项的
# 第一元组是 0、链路项是 1;Python 排序稳定,同一
# (时间, 优先级) 内保持插入顺序。
@@ -6775,20 +6873,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
由调用方在调本方法前做。"""
task_id = task["id"]
goal_target = _parse_goal_command(text)
# /goal 前置判定见 _classify_goal_followup(纯计算,与
# _create_followup 共用,但只用于 goal_target 非空时:
# 非 goal 路的终态与 merged 语义与 followup 不同——终态
# 走下面 CAS 复活而非 409,merged 走门禁文案而非
# “仅支持进行中任务”,故非 goal 绝不能进分类器)。
# 顺序与原文一致:goal error -> queued 原地转 goal ->
# 终态复活转 goal -> running 落到普通 followup 插入路
# (响应里标 delivery=goal_queued,见本函数末尾)->
# 非 goal 门禁 -> 终态 CAS 复活。
if goal_target is not None:
if not goal_target:
return 400, {"error": "goal 目标不能为空"}
if task.get("verify_command") or task.get("goal_mode"):
return 409, {"error": "主线已有 goal,/goal 不会重复创建",
"status": task["status"],
"goal": task.get("goal") or task.get("prompt")}
if task.get("plan"):
return 400, {"error": "plan 模式与 goal 模式冲突"}
if task["status"] == "merged":
return 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"]}
if task["status"] in TERMINAL_STATUSES:
goal_action, goal_code, goal_payload = _classify_goal_followup(
task, goal_target)
if goal_action == "error":
return goal_code, goal_payload
if goal_action == "convert_queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
return 409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"]}
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], audit_action,
"%s:goal_converted" % task_id)
return 201, {
"task_id": task_id, "delivery": "goal_converted",
"followup_id": None, "status": "queued",
"mode": mode, "goal": goal_target}
if goal_action == "revive_terminal":
new_task = self._goal_revive_terminal(task_id, goal_target)
if new_task is None:
return 409, {
@@ -6802,31 +6914,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"task_id": task_id, "delivery": "goal_revived",
"followup_id": None, "status": "queued",
"mode": mode, "goal": goal_target}
if task["status"] == "queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
return 409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"]}
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], audit_action,
"%s:goal_converted" % task_id)
return 201, {
"task_id": task_id, "delivery": "goal_converted",
"followup_id": None, "status": "queued",
"mode": mode, "goal": goal_target}
if task["status"] != "running":
return 409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"]}
# running:落到下面的普通 followup 插入路,响应里
# 标 delivery=goal_queued(见本函数末尾)。
if task.get("verify_command") or task.get("goal_mode"):
return 409, {"error": "goal 任务暂不支持后续消息"}
if task["status"] == "merged":
return 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"]}
guard = _thread_non_goal_guard(task)
if guard is not None:
return guard
if task["status"] in TERMINAL_STATUSES:
# 复活路:与 /continue 同款终态集(merged 已在上
# 面排除),CAS 防“已有待续跑消息”时重复复活。
@@ -10608,14 +10698,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, check)
return
# issue_comment 与 pull_request_review_comment:后者把
# pull_request.number 归一化成 parser 要的 issue.number。
result = parse_github_pr_comment(payload)
if result is None and payload.get("pull_request") and \
payload.get("comment"):
normalized = dict(payload)
normalized["issue"] = {
"number": (payload["pull_request"] or {}).get("number")}
result = parse_github_pr_comment(normalized)
# pull_request.number 归一化成 parser 要的 issue.number
# (见 _parse_github_comment_with_review_fallback)。
result = _parse_github_comment_with_review_fallback(payload)
if result is None:
self._send_json(200, {"result": None})
return
@@ -10675,12 +10760,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
now = time.time()
# prompt 不再原样贴评论全文:固定模板 + 截断后的
# 评论摘录(上限 2000 字),防超长评论撑爆 prompt
# 与指令注入面无限放大。
prompt = (
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
% (result.get("repo"), result.get("pr_number"),
result.get("author"),
(result.get("comment") or "")[:2000]))
# 与指令注入面无限放大(见 _webhook_fix_review_prompt)。
prompt = _webhook_fix_review_prompt(result)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
+208
View File
@@ -0,0 +1,208 @@
"""剩余长函数小提取单测:只钉住新抽出的纯块,不跑真实模型回合、
不联网、不连生产密钥。
覆盖(均取自原函数逐字逻辑):
- webhook:评论解析的 review 回退、fix-review prompt 模板与 2000 字截断;
- followup:请求体校验的成功/错误串、/goal 前置判定的各分支;
- deliver:非 goal 门禁(goal 任务 409、merged 409),以及关键语义——
门禁绝不拦截终态任务(终态走下面的 CAS 复活,不是 409);
- chat_history:秒->毫秒换算、用户条目、链路条目(fu- 回声跳过、
无 ts 贴上一条有 ts 的条目之后且 ts_ms 为 null)。
原路由行为不变由已有的 webhook / followup / thread / chat_history
套件覆盖,本文件不重复测路由,只测抽出子块本身。
"""
import os
import sys
import unittest
from pathlib import Path
try:
from cloud.server import (
_chat_history_trace_entries,
_chat_history_user_entries,
_classify_goal_followup,
_parse_github_comment_with_review_fallback,
_seconds_to_ms,
_thread_non_goal_guard,
_validate_followup_body,
_webhook_fix_review_prompt,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.server import (
_chat_history_trace_entries,
_chat_history_user_entries,
_classify_goal_followup,
_parse_github_comment_with_review_fallback,
_seconds_to_ms,
_thread_non_goal_guard,
_validate_followup_body,
_webhook_fix_review_prompt,
)
def _task(**over):
task = {"status": "running", "goal_mode": False,
"verify_command": None, "plan": None,
"goal": None, "prompt": "p"}
task.update(over)
return task
class WebhookExtractionTestCase(unittest.TestCase):
def test_review_payload_verbatim_semantics(self):
# 原样语义(注意,不是“修”成 7):现行 parser 里 issue 缺失
# 并不使首解返回 None(只使 pr_number 为 None),故回退块
# 在当前 parser 下不可达;抽出原样保留该块,不擅自改行为。
payload = {
"action": "created",
"comment": {"body": "/fix-review 请修", "user": {"login": "a"}},
"pull_request": {"number": 7},
"repository": {"full_name": "o/r"},
}
result = _parse_github_comment_with_review_fallback(payload)
self.assertEqual(result["pr_number"], None)
self.assertEqual(result["comment"], "/fix-review 请修")
self.assertIsNone(_parse_github_comment_with_review_fallback(
{"action": "edited",
"comment": {"body": "/fix-review"},
"pull_request": {"number": 7}}))
def test_issue_comment_direct_and_non_trigger(self):
direct = {
"action": "created",
"comment": {"body": "/fix-review", "user": {"login": "a"}},
"issue": {"number": 3},
"repository": {"full_name": "o/r"},
}
self.assertEqual(
_parse_github_comment_with_review_fallback(direct)["pr_number"],
3)
self.assertIsNone(_parse_github_comment_with_review_fallback(
{"action": "created", "comment": {"body": "hello"}}))
def test_fix_review_prompt_truncates_comment(self):
result = {"repo": "o/r", "pr_number": 9, "author": "a",
"comment": "x" * 2500}
prompt = _webhook_fix_review_prompt(result)
self.assertTrue(prompt.startswith(
"[webhook /fix-review] repo=o/r pr=9 author=a\n"))
self.assertEqual(len(prompt.split("\n", 1)[1]), 2000)
class FollowupBodyTestCase(unittest.TestCase):
def test_ok_strips_and_defaults_queued(self):
text, mode, err = _validate_followup_body({"body": " hi "})
self.assertEqual((text, mode, err), ("hi", "queued", None))
def test_direct_mode_kept(self):
text, mode, err = _validate_followup_body(
{"body": "hi", "mode": "direct"})
self.assertEqual((text, mode, err), ("hi", "direct", None))
def test_body_required_variants(self):
for body in ({}, {"body": ""}, {"body": " "}, {"body": 123}):
_t, _m, err = _validate_followup_body(body)
self.assertEqual(err, (400, {"error": "body required"}))
def test_too_long_and_bad_mode(self):
_t, _m, err = _validate_followup_body({"body": "x" * 20001})
self.assertEqual(err, (400, {"error": "body too long"}))
_t, _m, err = _validate_followup_body(
{"body": "hi", "mode": "weird"})
self.assertEqual(err, (400, {
"error": "mode must be queued or direct"}))
class GoalClassifyTestCase(unittest.TestCase):
def test_goal_empty_and_mainline_and_plan(self):
self.assertEqual(
_classify_goal_followup(_task(), ""),
("error", 400, {"error": "goal 目标不能为空"}))
action, code, payload = _classify_goal_followup(
_task(goal_mode=True, goal="g"), "新目标")
self.assertEqual((action, code), ("error", 409))
self.assertEqual(payload["error"], "主线已有 goal,/goal 不会重复创建")
self.assertEqual(payload["goal"], "g")
self.assertEqual(
_classify_goal_followup(_task(plan="p"), "新目标"),
("error", 400, {"error": "plan 模式与 goal 模式冲突"}))
def test_goal_merged_queued_terminal_running(self):
self.assertEqual(
_classify_goal_followup(_task(status="merged"), "新目标")[0],
"error")
self.assertEqual(
_classify_goal_followup(_task(status="queued"), "新目标")[0],
"convert_queued")
self.assertEqual(
_classify_goal_followup(_task(status="completed"), "新目标")[0],
"revive_terminal")
self.assertEqual(
_classify_goal_followup(_task(status="running"), "新目标")[0],
"continue")
def test_non_goal_only_queued_or_running_continues(self):
self.assertEqual(
_classify_goal_followup(_task(status="queued"), None)[0],
"continue")
action, code, payload = _classify_goal_followup(
_task(status="completed"), None)
self.assertEqual((action, code), ("error", 409))
self.assertEqual(payload["error"], "后续消息仅支持进行中任务")
class ThreadGuardTestCase(unittest.TestCase):
def test_goal_task_and_merged_blocked(self):
self.assertEqual(
_thread_non_goal_guard(_task(verify_command="v")),
(409, {"error": "goal 任务暂不支持后续消息"}))
self.assertEqual(
_thread_non_goal_guard(_task(status="merged"))[0], 409)
def test_terminal_not_blocked_by_guard(self):
# 关键:门禁只拦 goal/merged,终态任务必须放行到 CAS 复活。
self.assertIsNone(_thread_non_goal_guard(_task(status="completed")))
self.assertIsNone(_thread_non_goal_guard(_task(status="running")))
class ChatHistoryExtractionTestCase(unittest.TestCase):
def test_seconds_to_ms(self):
self.assertEqual(_seconds_to_ms(1.5), 1500)
self.assertEqual(_seconds_to_ms(0), 0)
self.assertIsNone(_seconds_to_ms(None))
self.assertIsNone(_seconds_to_ms("bad"))
def test_user_entries(self):
task = {"prompt": "做一件事", "created_at": 10.0}
cont = [{"id": "c1", "task_id": "t", "project_id": "p",
"body": "继续", "created_by": "u", "created_at": 11.0}]
fu = [{"id": "f1", "task_id": "t", "project_id": "p",
"body": "后续", "mode": "queued", "status": "pending",
"created_by": "u", "sender_task_id": None,
"created_at": 12.0, "updated_at": 12.0}]
entries = _chat_history_user_entries(task, cont, fu)
self.assertEqual(
[e[2]["id"] for e in entries],
["task-prompt", "continue-c1", "followup-f1"])
self.assertEqual(entries[0][2]["ts_ms"], 10000)
self.assertEqual(entries[2][2]["label"], "后续消息")
def test_trace_entries_skip_echo_and_inherit_ts(self):
entries = _chat_history_trace_entries([
{"id": "fu-abc", "kind": "message", "text": "回声"},
"not-a-dict",
{"id": "m1", "kind": "message", "text": "你好", "ts": 2000},
{"id": "m2", "kind": "message", "text": "无 ts"},
], start_ms=1000)
self.assertEqual([e[2]["id"] for e in entries], ["m1", "m2"])
self.assertEqual(entries[0][1], 2000)
self.assertEqual(entries[0][2]["ts_ms"], 2000)
self.assertEqual(entries[1][1], 2000) # 贴在上一条有 ts 之后
self.assertIsNone(entries[1][2]["ts_ms"])
if __name__ == "__main__":
unittest.main()