From b4a69e1e71463dd210e19fe03f4b18f59eaf4cf3 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 02:05:03 +0000 Subject: [PATCH] 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). --- cloud/server.py | 423 +++++++++++++++---------- cloud/tests/test_longfunc_remaining.py | 208 ++++++++++++ 2 files changed, 460 insertions(+), 171 deletions(-) create mode 100644 cloud/tests/test_longfunc_remaining.py diff --git a/cloud/server.py b/cloud/server.py index 28bb811..bf9ae32 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -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," diff --git a/cloud/tests/test_longfunc_remaining.py b/cloud/tests/test_longfunc_remaining.py new file mode 100644 index 0000000..babaf68 --- /dev/null +++ b/cloud/tests/test_longfunc_remaining.py @@ -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()