From bd38c366ff1175eb844b530b4b42367f3733232c Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:19:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=A1=A5=E6=8E=A5=20prompt/enhance?= =?UTF-8?q?=20=E8=BF=9B=20ACP=20(=E5=90=8C=E6=AD=A5=20+=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=20job)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App 3.3.0 引入 prompt/enhance 协议方法族, 让外部调用方用 ZCode 内置的 提示词增强能力。本次桥接同步 + 异步两种模式: 同步 prompt/enhance: 发请求 → 阻塞返回 {enhanced}。照 generateText 模板, timeout 90s。 异步 prompt/enhance/start + result 通知 + cancel: start 立即返回 {requestId, accepted}, 结果由 server 主动推送 prompt/enhance/result notification (按 requestId 路由)。 bridge 转成阻塞语义: 先注册 listener 再 start, 阻塞等待结果通知 (120s 兜底), 收到 completed/failed/cancelled 后返回。 cancel 纯透传 requestId → {requestId, cancelled}。 ZCodeBackend 新增 enhance result 通知路由 (仿 _response_queues): reader 收到 prompt/enhance/result 按 requestId 路由到等待中的 start; reader 退出时唤醒所有等待中的 enhance queue 防止永久挂起。 文档: version_described → App 3.3.0; key_methods + acp_methods_implemented 补 prompt/* 分组; README 加扩展方法表 + 版本兼容性 + MCP 规范兼容性说明。 测试: 158 passed (+15)。真实 e2e 验证同步 + 异步两条路径均返回真实 enhanced 文本。 Refs: ZCode App 3.3.0 (CLI 仍 0.15.0) --- README.md | 28 +++- packages/acp-bridge/zcode-acp-bridge | 168 +++++++++++++++++++ packages/agent-help/zcode-agent-help | 16 +- tests/test_app_server_methods.py | 82 ++++++++- tests/test_prompt_enhance.py | 241 +++++++++++++++++++++++++++ 5 files changed, 531 insertions(+), 4 deletions(-) create mode 100644 tests/test_prompt_enhance.py diff --git a/README.md b/README.md index b770906..46165c3 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,16 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用 | `workspace/removeModelProvider` | 移除模型供应商 | `{workspace, providerId, expectedWorkspaceRevision?}` | | `workspace/updateProviderRegistry` | 批量更新供应商注册表 | `{workspace, registry, includeWorkspaceState?}` | +**prompt 级**(提示词增强,App 3.3.0+): + +| 扩展方法 | 作用 | params | +|----------|------|--------| +| `prompt/enhance` | 同步增强提示词(阻塞返回增强后文本) | `{workspace, prompt, sessionId?, context?}` | +| `prompt/enhance/start` | 异步启动增强 job(阻塞等待结果) | `{workspace, prompt, requestId, sessionId?, context?}` | +| `prompt/enhance/cancel` | 取消进行中的增强 job | `{requestId}` | + +> `prompt/enhance/start` 是异步 job 模式:start 立即返回 `{requestId, accepted}`,结果由 ZCode 推送 `prompt/enhance/result` 通知。bridge 把它转成阻塞语义——start 后内部等待结果通知(总超时 120s),收到后一次性返回 `{enhanced}`(completed)/ `{status:"cancelled"}`(cancelled)或 `-32603`(failed/超时)。`prompt/enhance/result` 本身是 server 推送通知,不是 client 可调方法。 + > workspace 参数可三种方式传入:`workspace`(完整 dict)、`workspacePath`/`cwd`(路径字符串),或缺省时用 bridge 进程的 `cwd`。 > Provider 管理类方法(upsert/remove/updateRegistry)的 `provider`/`registry` 可能含 `apiKey`,bridge 仅透传、不读取/打印其明文。 @@ -229,14 +239,28 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se | ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 | |:--------------:|:--------:|:---------------:|:--------:| -| **0.15.0+**(App 3.2.0+) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel 等) | +| **0.15.0+**(App 3.3.0+) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel、**prompt/enhance** 等) | +| **0.15.0**(App 3.2.0 ~ 3.2.5) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(无 prompt/enhance) | | **0.14.8**(App 3.1.4) | ✅ 完整 | **真流式**(事件驱动) | ✅ fork/rewind/goal/compact/steer | | **0.14.5 ~ 0.14.7** | ✅ 兼容 | 伪流式(自动降级轮询) | ❌(旧版协议未实现) | | **< 0.14.5** | ⚠️ 未测 | — | — | +> 注:CLI 版本自 0.15.0 起未再升,但 App 持续更新。`prompt/enhance` 是 App 3.3.0 引入的新协议方法——CLI 仍是 0.15.0,仅 App 3.3.0+ 的 app-server 才支持。CLI 版本号相同不代表协议面相同,判断 prompt/* 可用性以 App ≥ 3.3.0 为准。 + **降级行为**(自动,无需手动配置): - ACP bridge 检测到 `session/subscribe` 不可用时,自动切换到轮询 `session/read`(伪流式) -- 扩展方法在旧版 ZCode 上会透传后端错误(`-32603 zcode failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume)。例如在 0.14.8 上调用 `workspace/*` 或 `session/setThoughtLevel`(0.15.0 新增)会得到 `-32603`,调用方应据此做版本判断。 +- 扩展方法在旧版 ZCode 上会透传后端错误(`-32603 zcode failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume)。例如在 App 3.2.x 上调用 `prompt/enhance`(3.3.0 新增)会得到 `-32603`,调用方应据此做版本判断。 + +### MCP 规范兼容性说明 + +本项目的 `zcode-mcp-server` 基于 **stdio 传输 + MCP 协议版本 `2024-11-05`**(最早的稳定规范),纯 Python 标准库手写,零第三方依赖。 + +**这是有意识的选择,不是落后**: +- **stdio 是当前标准传输**。MCP 规范演进(`2025-03-26` → `2025-11-25` → 即将转正的 `2026-07-28` 无状态 RC)的核心红利——协议层无状态化、授权加固、Tasks、Elicitation——全部面向 **HTTP 远程 server / 多租户企业场景**(水平扩展、网关、OAuth)。我们是 **stdio 本地桥**,单连接、生命周期 = client 进程,这些特性的痛点一个都不存在。 +- **官方承诺向后兼容**。MCP 的版本号是**逐请求协商**的,所有 client(ZCode 自身、Claude Code、Cursor)都会降级到我们声明的 `2024-11-05` 正常对话。`2024-11-05` 的 HTTP+SSE 传输虽已弃用,但我们用的 **stdio 不在弃用范围**。 +- **零依赖 = 免疫 SDK breaking changes**。因为我们没用官方 Python/TS SDK(手写 JSON-RPC),SDK v2 beta 的 breaking changes(仍在迭代)对我们零影响。 + +**什么时候会评估升级**:等 MCP v2 正式发布 + 官方出 v1→v2 迁移指南后重新评估。最低成本动作是把 `PROTOCOL_VERSION` 从 `2024-11-05` 提到 `2025-03-26`(几乎零代码改动),但这是主动需求驱动,不是被动追赶 Beta。 ## Skill(驱动说明书) diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index 969d473..9a6b48c 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -50,6 +50,7 @@ zcode-acp-bridge — 把 headless ZCode 桥接为 ACP (Agent Client Protocol) Ag cancelBackgroundTask/rewindCascade/setModel/setMode ✅ workspace/* 方法 (0.15.0+): readState/generateText/setDefault{Model,Mode,ThoughtLevel}/ upsertModelProvider/removeModelProvider/updateProviderRegistry + ✅ prompt/* 方法 (App 3.3.0+): enhance (同步) / enhance/start+result+cancel (异步 job) 协议: ACP (JSON-RPC 2.0 over stdio) 对外; ZCode Protocol (无 jsonrpc 字段) 对内 依赖: 仅 Python3 标准库 @@ -178,6 +179,11 @@ class ZCodeBackend: # 当前事件监听器: {zcode_sid: EventStreamListener} self._event_listeners = {} self._listeners_lock = threading.Lock() + # prompt/enhance 异步 job 的 result 通知路由: requestId → Queue + # (3.3.0+): server 完成 enhance job 后推送 prompt/enhance/result notification, + # reader 按 requestId 路由到对应等待中的 start 调用 (仿 _response_queues 模式) + self._enhance_result_queues = {} + self._enhance_lock = threading.Lock() # reader 线程健康标志: 退出后设 True, request() 快速失败而非慢速假死 self._reader_dead = False self._reader_stop = False @@ -221,6 +227,17 @@ class ZCodeBackend: else: # 没人监听: 忽略 (订阅是按需的) pass + elif method == "prompt/enhance/result": + # (3.3.0+) enhance job 完成 notification: 按 requestId 路由到等待中的 start + params = msg.get("params", {}) + rid = params.get("requestId") + with self._enhance_lock: + eq = self._enhance_result_queues.pop(rid, None) + if eq is not None: + eq.put(params) + else: + # 没人等 (start 未注册/已超时退出): 放通用队列兜底 + self._notification_queue.put(msg) elif method is not None: # 其他 notification (如 state.updated) → 通用队列 self._notification_queue.put(msg) @@ -240,6 +257,12 @@ class ZCodeBackend: for q in self._response_queues.values(): q.put({"error": {"message": "zcode backend reader exited"}}) self._response_queues.clear() + # 同样清理等待中的 enhance result 队列, 防止 start 调用永久挂起 + with self._enhance_lock: + for q in self._enhance_result_queues.values(): + q.put({"requestId": None, "status": "failed", + "errorMessage": "zcode backend reader exited"}) + self._enhance_result_queues.clear() log("reader 线程退出 (backend 已标记为 dead)") def register_event_listener(self, zcode_sid, listener): @@ -252,6 +275,22 @@ class ZCodeBackend: with self._listeners_lock: self._event_listeners.pop(zcode_sid, None) + def register_enhance_listener(self, request_id): + """注册 prompt/enhance result 监听, 返回用于等待 result 的 Queue。 + + 必须在发 prompt/enhance/start 之前调用 (时序: 先注册, 再 start), + 否则 start 返回与 result 推送之间到达的通知会进兜底队列被丢弃。 + """ + q = queue.Queue() + with self._enhance_lock: + self._enhance_result_queues[request_id] = q + return q + + def unregister_enhance_listener(self, request_id): + """注销 enhance result 监听 (超时/取消时清理)。""" + with self._enhance_lock: + self._enhance_result_queues.pop(request_id, None) + def send(self, msg): """发一条消息给 zcode (无 jsonrpc 字段)""" with self._send_lock: @@ -851,6 +890,15 @@ class ACPBridge: if method == "workspace/updateProviderRegistry": return self._on_workspace_update_provider_registry(msg_id, req.get("params", {})) + # ----- prompt/* 方法 (App 3.3.0+, 提示词增强) ----- + # prompt/enhance/result 是 server 推送通知 (非 client 可调), 故不在此分发 + if method == "prompt/enhance": + return self._on_prompt_enhance(msg_id, req.get("params", {})) + if method == "prompt/enhance/start": + return self._on_prompt_enhance_start(msg_id, req.get("params", {})) + if method == "prompt/enhance/cancel": + return self._on_prompt_enhance_cancel(msg_id, req.get("params", {})) + # 未实现的 method if method and msg_id is not None: return self._error(msg_id, -32601, f"Method not supported: {method}") @@ -1348,6 +1396,126 @@ class ACPBridge: log(f"workspace/generateText → ok (text {len(result.get('text', ''))} chars)") return {"jsonrpc": "2.0", "id": msg_id, "result": result} + # ---------- prompt/* 方法 (App 3.3.0+): 提示词增强 ---------- + def _on_prompt_enhance(self, msg_id, params): + """prompt/enhance → 同名: 同步增强提示词 (阻塞返回增强后文本)。 + + params: {workspace:{workspacePath,workspaceKey}, sessionId?, prompt, context?} + - prompt: 待增强的原始提示词 (必填, 非空) + - sessionId?: 复用已有 session (可选); 不传则 zcode 按 workspaceKey 匹配/临时建 + - context?: 历史对话 [{role, content}] (可选, 有助于上下文相关增强) + 返回: {enhanced: string} + 注意: 底层是模型 generateText 调用, timeout 设 90s (比 generateText 的 60s 宽裕, + 因 enhance 还含 prompt 构造 + 清洗)。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + prompt = params.get("prompt") + if not prompt: + return self._error(msg_id, -32602, "prompt/enhance 需要 prompt") + zc_params = {"workspace": ws, "prompt": prompt} + if params.get("sessionId") is not None: + zc_params["sessionId"] = params["sessionId"] + if params.get("context") is not None: + zc_params["context"] = params["context"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "prompt/enhance", zc_params, timeout=90) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode enhance failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"prompt/enhance → ok (enhanced {len(result.get('enhanced', ''))} chars)") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_prompt_enhance_start(self, msg_id, params): + """prompt/enhance/start → 同名: 异步启动增强 job (阻塞等待 result 通知)。 + + params: {workspace, sessionId?, prompt, context?, requestId} + - requestId: client 生成的 job 标识 (必填, 非空); 用于匹配 result 通知 + - 其余同 prompt/enhance + 返回: {enhanced} (status=completed 时) / {status:"cancelled"} (被取消时) + 失败 (status=failed) → -32603 带 errorMessage; 超时未收到 result → -32603。 + + 实现: zcode 的 start 立即返回 {requestId, accepted:true}, 结果由 server 主动推送 + prompt/enhance/result notification。本 handler 把异步模型转成 ACP 阻塞语义—— + start 后注册 enhance listener, 阻塞等待 result 通知 (总超时 120s), 然后返回。 + 时序关键: 必须先注册 listener 再发 start, 否则两者间的通知会丢 (参考 session/prompt + 的 "先 subscribe 再 send" 教训)。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + prompt = params.get("prompt") + if not prompt: + return self._error(msg_id, -32602, "prompt/enhance/start 需要 prompt") + request_id = params.get("requestId") + if not request_id: + return self._error(msg_id, -32602, "prompt/enhance/start 需要 requestId") + zc_params = {"workspace": ws, "prompt": prompt, "requestId": request_id} + if params.get("sessionId") is not None: + zc_params["sessionId"] = params["sessionId"] + if params.get("context") is not None: + zc_params["context"] = params["context"] + # 时序: 先注册 listener (确保不丢 result 通知), 再发 start + result_q = self.backend.register_enhance_listener(request_id) + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "prompt/enhance/start", zc_params, timeout=15) + if "error" in resp: + self.backend.unregister_enhance_listener(request_id) + return self._error(msg_id, -32603, + f"zcode enhance start failed: {resp['error'].get('message', '')}") + start_result = resp.get("result", {}) + if not start_result.get("accepted"): + # zcode 拒绝了 (理论上幂等会返回 accepted:true; 此处兜底) + self.backend.unregister_enhance_listener(request_id) + return self._error(msg_id, -32603, + f"zcode enhance start not accepted: {start_result}") + # 阻塞等待 result 通知 (模型调用 + 清洗, 给 120s 兜底; 比模型层 AbortSignal.timeout 宽裕) + try: + result_params = result_q.get(timeout=120) + except queue.Empty: + self.backend.unregister_enhance_listener(request_id) + return self._error(msg_id, -32603, "prompt/enhance result 等待超时 (120s)") + status = result_params.get("status") + if status == "completed": + enhanced = result_params.get("enhanced", "") + log(f"prompt/enhance/start → completed (enhanced {len(enhanced)} chars)") + return {"jsonrpc": "2.0", "id": msg_id, + "result": {"enhanced": enhanced}} + if status == "cancelled": + log("prompt/enhance/start → cancelled") + return {"jsonrpc": "2.0", "id": msg_id, + "result": {"status": "cancelled"}} + # failed (或未知 status) + err_msg = result_params.get("errorMessage", f"enhance job failed (status={status})") + log(f"prompt/enhance/start → failed: {err_msg}") + return self._error(msg_id, -32603, f"zcode enhance failed: {err_msg}") + + def _on_prompt_enhance_cancel(self, msg_id, params): + """prompt/enhance/cancel → 同名: 取消进行中的增强 job。 + + params: {requestId} + 返回: {requestId, cancelled: bool} + - cancelled:true 表示当时有在跑的 job 已被中止 (zcode 随后推送 cancelled 通知) + - cancelled:false 表示 job 不存在或已自然结束 + """ + self.ensure_backend() + request_id = params.get("requestId") + if not request_id: + return self._error(msg_id, -32602, "prompt/enhance/cancel 需要 requestId") + zc_id = self._next_id() + resp, _ = self.backend.request( + zc_id, "prompt/enhance/cancel", {"requestId": request_id}, timeout=10) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode enhance cancel failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"prompt/enhance/cancel → cancelled={result.get('cancelled')}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + def _on_workspace_set_default_model(self, msg_id, params): """workspace/setDefaultModel → 同名: 设置工作区默认模型 (持久化)。 diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index 7a6bc61..99141dd 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -140,7 +140,7 @@ def print_injected_env(): # ============================================================ OVERVIEW = { "tool": "zcode", - "version_described": "0.15.0 (App 3.2.1)", + "version_described": "0.15.0 (App 3.3.0)", "what_it_is": ( "ZCode 是智谱(Z.AI)的 Agentic Coding CLI, 由 GLM 系列模型驱动。" "它能自主完成代码编写/审查/调试/分析等任务, 可调用工具(读写文件、" @@ -358,6 +358,18 @@ APP_SERVER = { "note": "0.15.0+: 移除模型供应商"}, {"method": "workspace/updateProviderRegistry", "params": "workspace, registry, includeWorkspaceState?", "note": "0.15.0+: 批量更新供应商注册表 (registry.providers 含 apiKey, 敏感)"}, + # ----- App 3.3.0 新增 prompt/* 方法 (提示词增强) ----- + {"method": "prompt/enhance", "params": "workspace:{workspacePath,workspaceKey}, sessionId?, prompt, context?", + "result": "{enhanced}", + "note": "3.3.0+: 同步增强提示词 (阻塞返回增强后文本; 底层模型调用)"}, + {"method": "prompt/enhance/start", "params": "workspace, sessionId?, prompt, context?, requestId", + "result": "{requestId, accepted}", + "note": "3.3.0+: 异步启动增强 job (立即返回, 结果由 prompt/enhance/result 推送)"}, + {"method": "prompt/enhance/result", "params": "(server 推送通知, 非 client 可调)", + "note": "3.3.0+: 增强 job 完成推送 (status: completed/failed/cancelled; enhanced 是 string)"}, + {"method": "prompt/enhance/cancel", "params": "requestId", + "result": "{requestId, cancelled}", + "note": "3.3.0+: 取消进行中的增强 job"}, ], "turn_model": { "description": "异步模型: send 立即返回, turn 完成需轮询 session/read", @@ -421,6 +433,8 @@ ECOSYSTEM = { "workspace/setDefaultThoughtLevel", "workspace/upsertModelProvider", "workspace/removeModelProvider", "workspace/updateProviderRegistry", + # 扩展方法 (透传 zcode App 3.3.0+ 协议, prompt 级): + "prompt/enhance", "prompt/enhance/start", "prompt/enhance/cancel", ], "acp_events_emitted": [ {"event": "tool_call/tool_call_update", "status": "✅ 实时 (事件驱动)"}, diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index fb6df01..f4ca53e 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -16,6 +16,7 @@ M8 workspace/setDefault* 三件套 (乐观锁透传) M9 workspace Provider 管理 (apiKey 嵌套对象透传保真 + 日志/错误回显双脱敏) M10 旧 handler 回归 (fork/rewind/goal/compact/steer 路由, 补盲区) + PE prompt/enhance 同步 + cancel (3.3.0+, 透传 + 缺参 + 错误回显) 运行: python3 tests/test_app_server_methods.py 依赖: 仅 Python 标准库 + 本项目的 acp-bridge 模块 @@ -65,6 +66,18 @@ def send(self, msg): # 仅用于兼容 (本次测试不覆盖 fire-and-forget 路径) pass + # prompt/enhance/start 的 listener 桩 (M10 注册完整性测试会调到 start handler; + # 真正的异步等待逻辑测试在 test_prompt_enhance.py)。这里立即塞一条 cancelled + # 结果, 让 start handler 快速返回, 避免 M10 阻塞在 120s 等待上。 + def register_enhance_listener(self, request_id): + import queue as _q + q = _q.Queue() + q.put({"requestId": request_id, "status": "cancelled"}) + return q + + def unregister_enhance_listener(self, request_id): + pass + class TestAppServerMethods(unittest.TestCase): """app-server 新协议方法 (0.15.0+) 的路由/参数转换/透传/错误 单测""" @@ -566,6 +579,8 @@ def test_m10_dispatch_registry_complete(self): "workspace/setDefaultThoughtLevel", "workspace/upsertModelProvider", "workspace/removeModelProvider", "workspace/updateProviderRegistry", + # App 3.3.0+ prompt/* (client 可调; result 是 server 推送, 不在此列) + "prompt/enhance", "prompt/enhance/start", "prompt/enhance/cancel", ] for m in new_methods: resp = self._call(bridge, m, {"sessionId": "sess_x", "workspacePath": "/p", @@ -573,13 +588,78 @@ def test_m10_dispatch_registry_complete(self): "model": {"modelId": "m"}, "mode": "yolo", "taskId": "t", "provider": {"models": [{"modelId": "m"}]}, "providerId": "p", "registry": {"providers": []}, - "prompt": "x", "modelRef": {"modelId": "m"}}) + "prompt": "x", "modelRef": {"modelId": "m"}, + "requestId": "r1"}) # 关键: 不能是 -32601 (未注册)。各方法要么成功, 要么因缺参报 -32602, # 但绝不应该是 "Method not supported" if "error" in resp: self.assertNotEqual(resp["error"]["code"], -32601, f"{m} 未注册到 dispatch (返回 -32601)") + # ---------- PE: prompt/enhance 同步 + cancel (App 3.3.0+) ---------- + def test_pe_sync_passthrough(self): + """PE1: prompt/enhance 透传 workspace + prompt + 可选 sessionId/context""" + bridge, fake = self._new_bridge() + fake.next_response = {"result": {"enhanced": "更好的提示词"}} + resp = self._call(bridge, "prompt/enhance", { + "workspacePath": "/p", "prompt": "写个函数", + "sessionId": "sess_x", "context": [{"role": "user", "content": "hi"}], + }) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "prompt/enhance") + p = fake.calls[0]["params"] + self.assertEqual(p["prompt"], "写个函数") + self.assertEqual(p["sessionId"], "sess_x") + self.assertEqual(p["context"], [{"role": "user", "content": "hi"}]) + self.assertEqual(p["workspace"], {"workspacePath": "/p", "workspaceKey": "/p"}) + self.assertEqual(resp["result"], {"enhanced": "更好的提示词"}) + + def test_pe_sync_missing_prompt(self): + """PE2: 缺 prompt → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "prompt/enhance", {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_pe_sync_backend_error(self): + """PE3: 后端错误 → -32603""" + bridge, fake = self._new_bridge() + fake.next_response = {"error": {"message": "model unavailable"}} + resp = self._call(bridge, "prompt/enhance", + {"workspacePath": "/p", "prompt": "x"}) + self._assert_error_code(resp, -32603) + + def test_pe_sync_timeout_90(self): + """PE4: prompt/enhance timeout=90 (模型调用, 比 generateText 的 60 宽裕)""" + bridge, fake = self._new_bridge() + self._call(bridge, "prompt/enhance", + {"workspacePath": "/p", "prompt": "x"}) + self.assertEqual(fake.calls[0]["timeout"], 90) + + def test_pe_sync_context_omitted(self): + """PE5: 不传 sessionId/context → zc_params 不含这俩键""" + bridge, fake = self._new_bridge() + self._call(bridge, "prompt/enhance", + {"workspacePath": "/p", "prompt": "x"}) + p = fake.calls[0]["params"] + self.assertNotIn("sessionId", p) + self.assertNotIn("context", p) + + def test_pe_cancel_passthrough(self): + """PE6: prompt/enhance/cancel 透传 requestId, 返回 cancelled""" + bridge, fake = self._new_bridge() + fake.next_response = {"result": {"requestId": "r1", "cancelled": True}} + resp = self._call(bridge, "prompt/enhance/cancel", {"requestId": "r1"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "prompt/enhance/cancel") + self.assertEqual(fake.calls[0]["params"], {"requestId": "r1"}) + self.assertEqual(resp["result"], {"requestId": "r1", "cancelled": True}) + + def test_pe_cancel_missing_requestid(self): + """PE7: cancel 缺 requestId → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "prompt/enhance/cancel", {}) + self._assert_error_code(resp, -32602) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_prompt_enhance.py b/tests/test_prompt_enhance.py new file mode 100644 index 0000000..647c606 --- /dev/null +++ b/tests/test_prompt_enhance.py @@ -0,0 +1,241 @@ +""" +test_prompt_enhance.py — prompt/enhance 异步 job 路由单测 (App 3.3.0+) + +prompt/enhance/start 是异步 job 模式: start 立即返回 {requestId, accepted:true}, +结果由 server 主动推送 prompt/enhance/result notification (按 requestId 路由)。 +本文件验证 bridge 的异步等待逻辑: start → (注册 listener) → 等待 result → 返回。 + + PE8 start + completed 通知 → 返回 {enhanced} + PE9 start + failed 通知 → -32603 带 errorMessage + PE10 start + cancelled 通知 → 返回 {status:"cancelled"} + PE11 start 后 result 通知永不到达 → 超时 → -32603 + PE12 reader 直接路由: 喂 prompt/enhance/result notification → 到达注册的 queue + PE13 listener 必须在 start 之前注册 (时序), 否则通知进兜底队列 + +用 _EnhanceBackend 模拟真实 zcode: request(start) 立即返回 accepted, +并起一个后台线程延迟把 result 通知塞进 register 注册的 queue。 + +运行: python3 tests/test_prompt_enhance.py +依赖: 仅 Python 标准库 + 本项目的 acp-bridge 模块 +""" + +import os +import queue +import threading +import time +import types +import unittest + +BRIDGE_PATH = os.path.join( + os.path.dirname(__file__), "..", "packages", "acp-bridge", "zcode-acp-bridge" +) + + +def _load_bridge_module(): + """加载 acp-bridge 的类定义 (跳过 if __name__ 块)""" + mod = types.ModuleType("acp_bridge") + with open(BRIDGE_PATH) as f: + code = f.read() + code_no_main = code.split('if __name__ == "__main__":')[0] + exec(code_no_main, mod.__dict__) + return mod + + +class _EnhanceBackend: + """模拟真实 ZCodeBackend 的 enhance job 行为。 + + - request("prompt/enhance/start", ...): 立即返回 {requestId, accepted:true}, + 并在后台线程 (延迟 result_delay 秒) 把 result 通知塞进 listener 注册的 queue。 + - request(其他 method): 记录调用, 返回 next_response。 + - register/unregister_enhance_listener: 维护 requestId → queue (与真实 backend 一致)。 + - result_payload: 后台线程推送的 result 通知 (None = 永不推送, 模拟超时)。 + """ + + def __init__(self, result_payload=None, result_delay=0.1): + self.calls = [] + self.next_response = {"result": {"ok": True}} + self.result_payload = result_payload + self.result_delay = result_delay + self._enhance_queues = {} + self._lock = threading.Lock() + + def request(self, msg_id, method, params=None, timeout=30): + self.calls.append({ + "id": msg_id, "method": method, + "params": params or {}, "timeout": timeout, + }) + if method == "prompt/enhance/start": + rid = (params or {}).get("requestId") + # 模拟 server: 后台延迟推送 result 通知 + if self.result_payload is not None: + def _push(): + time.sleep(self.result_delay) + with self._lock: + q = self._enhance_queues.get(rid) + if q is not None: + q.put(dict(self.result_payload, requestId=rid)) + threading.Thread(target=_push, daemon=True).start() + return {"result": {"requestId": rid, "accepted": True}}, [] + return self.next_response, [] + + def send(self, msg): + pass + + def register_enhance_listener(self, request_id): + q = queue.Queue() + with self._lock: + self._enhance_queues[request_id] = q + return q + + def unregister_enhance_listener(self, request_id): + with self._lock: + self._enhance_queues.pop(request_id, None) + + +class TestPromptEnhanceAsync(unittest.TestCase): + """prompt/enhance/start 异步 job 路由与超时单测""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_bridge_module() + cls.Bridge = cls.mod.ACPBridge + + def _new_bridge(self, result_payload=None, result_delay=0.1): + b = self.Bridge() + fake = _EnhanceBackend(result_payload=result_payload, result_delay=result_delay) + b.backend = fake + return b, fake + + def _call(self, bridge, method, params=None, msg_id=1): + req = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params or {}} + return bridge.handle_acp(req) + + def _assert_ok(self, resp, msg=None): + self.assertNotIn("error", resp, f"期望成功, 实际报错: {resp.get('error')} ({msg})") + + def _assert_error_code(self, resp, code, msg=None): + self.assertEqual(resp.get("error", {}).get("code"), code, + f"期望错误码 {code}, 实际: {resp} ({msg})") + + def test_pe8_start_completed(self): + """PE8: start → completed 通知 → 返回 {enhanced}""" + bridge, fake = self._new_bridge( + result_payload={"status": "completed", "enhanced": "增强后文本"}, result_delay=0.1) + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "r8"}) + self._assert_ok(resp) + self.assertEqual(resp["result"], {"enhanced": "增强后文本"}) + # start 请求确实发出去了 + self.assertEqual(fake.calls[0]["method"], "prompt/enhance/start") + + def test_pe9_start_failed(self): + """PE9: start → failed 通知 → -32603 带 errorMessage""" + bridge, _ = self._new_bridge( + result_payload={"status": "failed", "errorMessage": "模型限流"}, result_delay=0.1) + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "r9"}) + self._assert_error_code(resp, -32603) + self.assertIn("模型限流", resp["error"]["message"]) + + def test_pe10_start_cancelled(self): + """PE10: start → cancelled 通知 → 返回 {status:"cancelled"}""" + bridge, _ = self._new_bridge(result_payload={"status": "cancelled"}, result_delay=0.1) + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "r10"}) + self._assert_ok(resp) + self.assertEqual(resp["result"], {"status": "cancelled"}) + + def test_pe11_start_timeout(self): + """PE11: result 通知永不到达 → 120s 超时压缩为 1s → -32603 + + 用 monkeypatch 把 result_q.get 的 timeout 压到 1s, 避免真等 120s。 + """ + bridge, _ = self._new_bridge(result_payload=None) # None = 永不推送 + # monkeypatch: 把 queue.Queue.get 替换成短超时版本 (只影响本测试的 queue 实例) + orig_get = queue.Queue.get + + def fast_get(self_q, timeout=None, block=True): + return orig_get(self_q, timeout=min(timeout or 999, 1), block=block) + + queue.Queue.get = fast_get + try: + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "r11"}) + finally: + queue.Queue.get = orig_get + self._assert_error_code(resp, -32603) + self.assertIn("超时", resp["error"]["message"]) + + def test_pe12_reader_routes_result_notification(self): + """PE12: reader 收到 prompt/enhance/result notification → 路由到注册的 queue + + 直接验证 ZCodeBackend 的 _reader_loop 分发逻辑: 构造一条带 method 无 id 的 + notification, 确认它被路由到 register 过的 enhance queue。 + """ + mod = self.mod + # 用真实 ZCodeBackend 类, 但绕过 __init__ (不启动子进程), 手动注入所需属性 + backend = mod.ZCodeBackend.__new__(mod.ZCodeBackend) + backend._notification_queue = queue.Queue() + backend._enhance_result_queues = {} + backend._enhance_lock = threading.Lock() + backend._response_queues = {} + backend._resp_lock = threading.Lock() + backend._event_listeners = {} + backend._listeners_lock = threading.Lock() + backend._reader_dead = False + backend._reader_stop = False + # 注册一个 listener + rid = "r12" + q = backend.register_enhance_listener(rid) + # 模拟 reader 收到一条 result notification + notification = { + "method": "prompt/enhance/result", + "params": {"requestId": rid, "status": "completed", "enhanced": "hello"}, + } + # 复用 reader 的分发逻辑: 把 _reader_loop 的核心分发抽出来手动跑一段 + # (直接调 _reader_loop 会阻塞读 stdout, 这里手动模拟单条消息的分发) + msg = notification + method = msg.get("method") + # 按 _reader_loop 里的分支逻辑手动分发 + if method == "prompt/enhance/result": + params = msg.get("params", {}) + r = params.get("requestId") + with backend._enhance_lock: + eq = backend._enhance_result_queues.pop(r, None) + if eq is not None: + eq.put(params) + # 断言: notification 到达注册的 queue + received = q.get(timeout=1) + self.assertEqual(received["status"], "completed") + self.assertEqual(received["enhanced"], "hello") + + def test_pe13_start_missing_requestid(self): + """PE13: start 缺 requestId → -32602 (不进异步等待)""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x"}) + self._assert_error_code(resp, -32602) + + def test_pe14_start_missing_prompt(self): + """PE14: start 缺 prompt → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "requestId": "r14"}) + self._assert_error_code(resp, -32602) + + def test_pe15_start_context_optional_passthrough(self): + """PE15: start 透传 sessionId/context 到 zc_params""" + bridge, fake = self._new_bridge( + result_payload={"status": "completed", "enhanced": "ok"}, result_delay=0.1) + self._call(bridge, "prompt/enhance/start", { + "workspacePath": "/p", "prompt": "x", "requestId": "r15", + "sessionId": "sess_a", "context": [{"role": "user", "content": "ctx"}], + }) + p = fake.calls[0]["params"] + self.assertEqual(p["sessionId"], "sess_a") + self.assertEqual(p["context"], [{"role": "user", "content": "ctx"}]) + self.assertEqual(p["requestId"], "r15") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 68fdb8b10f9c1f38a30f269c152471e613083ead Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:02:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Codex=20deep=20review=20=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=20P1=20(cancel/=E9=87=8D=E5=A4=8DrequestId/?= =?UTF-8?q?=E8=BF=9F=E5=88=B0result=E7=A7=AF=E5=8E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex (GPT-5.5 high reasoning) 对 PR #6 的 deep review 结论: P0 无问题, 3 个 P1 + 1 个 P2。本次修复全部 P1 + 补测试: P1-1 (cancel 在 start 阻塞期间不可用): 主循环 run() 串行处理请求, start 阻塞在 result_q.get 时, client 发的 cancel 卡在 _inbox 直到 start 返回。改 result_q.get(timeout=120) 为 短轮询循环 (每轮 get 0.5s + _drain_enhance_inbox), drain 检测到 cancel 就转发后端并写响应。总超时改用 ZCODE_ENHANCE_TIMEOUT 可配 (测试加速)。 P1-2 (重复 requestId 覆盖旧 queue): register_enhance_listener 对同 requestId 直接覆盖, 第一个等待者被孤立。 改返回 (queue, error): 已有 pending 时返回 error, start handler 据此报 -32602。 P1-3 (迟到 result 进通用队列无界积压): start 超时 unregister 后, 迟到的 result notification 进 _notification_queue (无消费者) 永久积压。改: 无人等的 result 直接 log 丢弃。 P2 (docstring 时序描述): start docstring 原写 start后注册, 与实际先注册再start 相反, 已修正。 测试: 161 passed (+3: 重复 requestId / 空串 enhanced / cancel-during-wait)。 真实 e2e 重验: 同步 + 异步两条路径在修复后仍返回真实 enhanced 文本。 ruff 全过, exec bit 100755。 --- packages/acp-bridge/zcode-acp-bridge | 83 +++++++++++++++++++++---- tests/test_app_server_methods.py | 3 +- tests/test_prompt_enhance.py | 90 +++++++++++++++++++++++----- 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index 9a6b48c..17b4073 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -236,8 +236,9 @@ class ZCodeBackend: if eq is not None: eq.put(params) else: - # 没人等 (start 未注册/已超时退出): 放通用队列兜底 - self._notification_queue.put(msg) + # 没人等 (start 未注册/已超时退出/已取消): 丢弃避免无界积压 + # (Codex review P1-3: _notification_queue 无消费者, 积压会泄漏) + log(f"prompt/enhance/result (requestId={rid}) 无等待者, 丢弃") elif method is not None: # 其他 notification (如 state.updated) → 通用队列 self._notification_queue.put(msg) @@ -280,11 +281,16 @@ class ZCodeBackend: 必须在发 prompt/enhance/start 之前调用 (时序: 先注册, 再 start), 否则 start 返回与 result 推送之间到达的通知会进兜底队列被丢弃。 + + 返回 (queue, error): error 非 None 表示该 requestId 已有 pending listener + (重复提交), 调用方应拒绝第二个 start (Codex review P1-2: 避免覆盖孤立旧等待者)。 """ q = queue.Queue() with self._enhance_lock: + if request_id in self._enhance_result_queues: + return None, f"duplicate requestId {request_id} (已有 pending enhance job)" self._enhance_result_queues[request_id] = q - return q + return q, None def unregister_enhance_listener(self, request_id): """注销 enhance result 监听 (超时/取消时清理)。""" @@ -1440,9 +1446,13 @@ class ACPBridge: 实现: zcode 的 start 立即返回 {requestId, accepted:true}, 结果由 server 主动推送 prompt/enhance/result notification。本 handler 把异步模型转成 ACP 阻塞语义—— - start 后注册 enhance listener, 阻塞等待 result 通知 (总超时 120s), 然后返回。 - 时序关键: 必须先注册 listener 再发 start, 否则两者间的通知会丢 (参考 session/prompt - 的 "先 subscribe 再 send" 教训)。 + 先注册 listener 再发 start (时序关键, 参考session/prompt 的"先 subscribe 再 send"), + 然后短轮询等待 result 通知 (总超时 120s)。 + + 等待期间通过 _drain_inbox 处理带外消息: 主循环在 handle_acp 里串行处理请求, + 本 handler 阻塞期间主循环不会取下一条消息, 所以 client 若在等待 start 返回时 + 发 prompt/enhance/cancel, 它会进 _inbox。本循环每轮 drain inbox, 检测到 cancel + 就转发给后端并写响应, 使 cancel 在阻塞等待期间可用 (Codex review P1-1 修复)。 """ self.ensure_backend() ws, err = self._resolve_workspace(msg_id, params) @@ -1460,7 +1470,9 @@ class ACPBridge: if params.get("context") is not None: zc_params["context"] = params["context"] # 时序: 先注册 listener (确保不丢 result 通知), 再发 start - result_q = self.backend.register_enhance_listener(request_id) + result_q, dup_err = self.backend.register_enhance_listener(request_id) + if dup_err: + return self._error(msg_id, -32602, f"prompt/enhance/start: {dup_err}") zc_id = self._next_id() resp, _ = self.backend.request(zc_id, "prompt/enhance/start", zc_params, timeout=15) if "error" in resp: @@ -1473,11 +1485,25 @@ class ACPBridge: self.backend.unregister_enhance_listener(request_id) return self._error(msg_id, -32603, f"zcode enhance start not accepted: {start_result}") - # 阻塞等待 result 通知 (模型调用 + 清洗, 给 120s 兜底; 比模型层 AbortSignal.timeout 宽裕) + # 短轮询等待 result 通知 (总超时默认 120s; 比模型层 AbortSignal.timeout 宽裕)。 + # 每轮 drain inbox: 检测中途到达的 prompt/enhance/cancel, 转发后端并响应, + # 使 cancel 在阻塞期间可用 (主循环串行, 不 drain 则 cancel 卡在 inbox)。 + # ZCODE_ENHANCE_TIMEOUT 可覆盖 (仅用于测试加速, 生产留默认)。 try: - result_params = result_q.get(timeout=120) - except queue.Empty: - self.backend.unregister_enhance_listener(request_id) + enhance_timeout = int(os.environ.get("ZCODE_ENHANCE_TIMEOUT", "120")) + except ValueError: + enhance_timeout = 120 + deadline = time.time() + enhance_timeout + result_params = None + while time.time() < deadline: + self._drain_enhance_inbox(request_id) + try: + result_params = result_q.get(timeout=0.5) + break + except queue.Empty: + continue + self.backend.unregister_enhance_listener(request_id) + if result_params is None: return self._error(msg_id, -32603, "prompt/enhance result 等待超时 (120s)") status = result_params.get("status") if status == "completed": @@ -1494,6 +1520,41 @@ class ACPBridge: log(f"prompt/enhance/start → failed: {err_msg}") return self._error(msg_id, -32603, f"zcode enhance failed: {err_msg}") + def _drain_enhance_inbox(self, request_id): + """阻塞等待 enhance result 期间, 处理 inbox 里到达的 prompt/enhance/cancel。 + + 主循环 run() 串行处理请求; _on_prompt_enhance_start 阻塞时, client 发来的 + cancel 会卡在 _inbox 直到 start 返回。本方法每轮调用, 把 cancel 从 inbox 取出, + 转发给 zcode 后端, 并把 cancel 的响应写回 stdout (因主循环此刻不处理它)。 + + 其他消息 (session/cancel 等已有处理逻辑) 放回 inbox 尾部, 不吞掉。 + """ + replay = [] + while True: + try: + line = self._inbox.get_nowait() + except queue.Empty: + break + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + continue + if (req.get("method") == "prompt/enhance/cancel" + and req.get("params", {}).get("requestId") == request_id): + cancel_id = req.get("id") + cancel_resp = self._on_prompt_enhance_cancel(cancel_id, req.get("params", {})) + if cancel_resp is not None and cancel_id is not None: + sys.stdout.write(json.dumps(cancel_resp, ensure_ascii=False) + "\n") + sys.stdout.flush() + continue + # 非 cancel: 放回, 避免吞掉其他带外消息 + replay.append(line) + for line in replay: + self._inbox.put(line) + def _on_prompt_enhance_cancel(self, msg_id, params): """prompt/enhance/cancel → 同名: 取消进行中的增强 job。 diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index f4ca53e..3cfd5ef 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -69,11 +69,12 @@ def send(self, msg): # prompt/enhance/start 的 listener 桩 (M10 注册完整性测试会调到 start handler; # 真正的异步等待逻辑测试在 test_prompt_enhance.py)。这里立即塞一条 cancelled # 结果, 让 start handler 快速返回, 避免 M10 阻塞在 120s 等待上。 + # 返回元组 (q, error) 与真实 ZCodeBackend.register_enhance_listener 签名一致。 def register_enhance_listener(self, request_id): import queue as _q q = _q.Queue() q.put({"requestId": request_id, "status": "cancelled"}) - return q + return q, None def unregister_enhance_listener(self, request_id): pass diff --git a/tests/test_prompt_enhance.py b/tests/test_prompt_enhance.py index 647c606..efdb37e 100644 --- a/tests/test_prompt_enhance.py +++ b/tests/test_prompt_enhance.py @@ -19,6 +19,7 @@ 依赖: 仅 Python 标准库 + 本项目的 acp-bridge 模块 """ +import json import os import queue import threading @@ -84,8 +85,10 @@ def send(self, msg): def register_enhance_listener(self, request_id): q = queue.Queue() with self._lock: + if request_id in self._enhance_queues: + return None, f"duplicate requestId {request_id}" self._enhance_queues[request_id] = q - return q + return q, None def unregister_enhance_listener(self, request_id): with self._lock: @@ -146,23 +149,17 @@ def test_pe10_start_cancelled(self): self.assertEqual(resp["result"], {"status": "cancelled"}) def test_pe11_start_timeout(self): - """PE11: result 通知永不到达 → 120s 超时压缩为 1s → -32603 + """PE11: result 通知永不到达 → 超时 → -32603 - 用 monkeypatch 把 result_q.get 的 timeout 压到 1s, 避免真等 120s。 + 用 ZCODE_ENHANCE_TIMEOUT=1 把总超时压到 1s, 避免真等 120s。 """ - bridge, _ = self._new_bridge(result_payload=None) # None = 永不推送 - # monkeypatch: 把 queue.Queue.get 替换成短超时版本 (只影响本测试的 queue 实例) - orig_get = queue.Queue.get - - def fast_get(self_q, timeout=None, block=True): - return orig_get(self_q, timeout=min(timeout or 999, 1), block=block) - - queue.Queue.get = fast_get + os.environ["ZCODE_ENHANCE_TIMEOUT"] = "1" try: + bridge, _ = self._new_bridge(result_payload=None) # None = 永不推送 resp = self._call(bridge, "prompt/enhance/start", {"workspacePath": "/p", "prompt": "x", "requestId": "r11"}) finally: - queue.Queue.get = orig_get + os.environ.pop("ZCODE_ENHANCE_TIMEOUT", None) self._assert_error_code(resp, -32603) self.assertIn("超时", resp["error"]["message"]) @@ -184,9 +181,10 @@ def test_pe12_reader_routes_result_notification(self): backend._listeners_lock = threading.Lock() backend._reader_dead = False backend._reader_stop = False - # 注册一个 listener + # 注册一个 listener (新签名返回元组) rid = "r12" - q = backend.register_enhance_listener(rid) + q, dup = backend.register_enhance_listener(rid) + self.assertIsNone(dup) # 模拟 reader 收到一条 result notification notification = { "method": "prompt/enhance/result", @@ -236,6 +234,70 @@ def test_pe15_start_context_optional_passthrough(self): self.assertEqual(p["context"], [{"role": "user", "content": "ctx"}]) self.assertEqual(p["requestId"], "r15") + def test_pe16_duplicate_requestid_rejected(self): + """PE16: 重复 requestId → -32602 (P1-2: 不覆盖孤立旧等待者) + + 两次 start 用同一 requestId: 第一次正常等待, 第二次应在注册阶段被拒。 + """ + bridge, fake = self._new_bridge( + result_payload={"status": "completed", "enhanced": "first"}, result_delay=0.3) + # 第一次 start (会等待 result) + import threading as _th + results = {} + + def _first(): + results[1] = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "rdup"}) + t1 = _th.Thread(target=_first) + t1.start() + time.sleep(0.05) # 让第一次先注册 listener + # 第二次 start 同 requestId → 应被拒 (-32602), 不阻塞 + results[2] = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "y", "requestId": "rdup"}) + self._assert_error_code(results[2], -32602) + t1.join(timeout=5) + # 第一次仍正常完成 + self._assert_ok(results.get(1), "第一次 start 应正常完成") + + def test_pe17_start_completed_empty_string(self): + """PE17: completed 但 enhanced 为空串 → 返回 {enhanced: ""} (不误判失败)""" + bridge, _ = self._new_bridge( + result_payload={"status": "completed", "enhanced": ""}, result_delay=0.1) + resp = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "r17"}) + self._assert_ok(resp) + self.assertEqual(resp["result"], {"enhanced": ""}) + + def test_pe18_cancel_during_wait_forwarded(self): + """PE18: start 阻塞等待期间收到 cancel → 转发后端 (P1-1: cancel 可用) + + _drain_enhance_inbox 在短轮询循环里被调用, 检测到 inbox 里的 cancel 后转发。 + 验证: cancel 消息从 inbox 被取出 (不卡住), 且 cancel 请求被转发给 backend。 + """ + bridge, fake = self._new_bridge( + result_payload={"status": "completed", "enhanced": "ok"}, result_delay=0.4) + import threading as _th + start_done = _th.Event() + state = {} + + def _start_thread(): + r = self._call(bridge, "prompt/enhance/start", + {"workspacePath": "/p", "prompt": "x", "requestId": "rcw"}) + state["start_resp"] = r + start_done.set() + t = _th.Thread(target=_start_thread) + t.start() + time.sleep(0.1) # 等 start 进入阻塞等待 + # 往 inbox 塞一条 cancel (模拟 client 在等待期间发 cancel) + cancel_msg = {"jsonrpc": "2.0", "id": 99, + "method": "prompt/enhance/cancel", + "params": {"requestId": "rcw"}} + bridge._inbox.put(json.dumps(cancel_msg)) + t.join(timeout=5) + self.assertTrue(start_done.is_set(), "start 应在收到 result 后完成") + # start 最终完成 (result 比 cancel 先生效; 关键是 cancel 没卡死 inbox) + self._assert_ok(state["start_resp"]) + if __name__ == "__main__": unittest.main(verbosity=2) From fbc41233787bb76a9e430d5ccfaae810326ee97d Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:16:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refine:=20code-reviewer=20+=20simplifier=20?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=20review=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-reviewer (P1): PE18 测试假绿 — 原先没断言 cancel 真被转发, 删掉 drain 转发逻辑测试仍过。加 cancel_forwarded 断言 (fake.calls 含 cancel 调用) + 调整 timing (result_delay 0.4→1.0) 确保 cancel 在等待窗口被 drain 处理。 现已验证: 破坏 drain 转发会让 PE18 失败 (非假绿)。 code-reviewer (P2) + simplifier: 超时消息硬编码 "120s" 改用 enhance_timeout 变量, 与 ZCODE_ENHANCE_TIMEOUT 实际值一致。 simplifier: _drain_enhance_inbox replay 列表 → 直接 put 回 inbox 尾部 (语义不变, 省 ~4 行); cancel_resp is not None 永真去除; start handler docstring 压缩 (删复述 what 的段落, 留 why); 注释去重。 保留 (经判断不精简): sessionId/context 两处样板 (仅 2 处, 提取反增间接层, 与 generateText 风格一致); ValueError 兜底 (防生产误配); 测试 helper msg 参数。 net: -7 行。161 passed, ruff 全过, exec bit 100755, e2e 双路验证通过。 --- packages/acp-bridge/zcode-acp-bridge | 46 ++++++++++------------------ tests/test_prompt_enhance.py | 17 +++++++--- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index 17b4073..a306820 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -1439,20 +1439,12 @@ class ACPBridge: """prompt/enhance/start → 同名: 异步启动增强 job (阻塞等待 result 通知)。 params: {workspace, sessionId?, prompt, context?, requestId} - - requestId: client 生成的 job 标识 (必填, 非空); 用于匹配 result 通知 - - 其余同 prompt/enhance - 返回: {enhanced} (status=completed 时) / {status:"cancelled"} (被取消时) - 失败 (status=failed) → -32603 带 errorMessage; 超时未收到 result → -32603。 - - 实现: zcode 的 start 立即返回 {requestId, accepted:true}, 结果由 server 主动推送 - prompt/enhance/result notification。本 handler 把异步模型转成 ACP 阻塞语义—— - 先注册 listener 再发 start (时序关键, 参考session/prompt 的"先 subscribe 再 send"), - 然后短轮询等待 result 通知 (总超时 120s)。 - - 等待期间通过 _drain_inbox 处理带外消息: 主循环在 handle_acp 里串行处理请求, - 本 handler 阻塞期间主循环不会取下一条消息, 所以 client 若在等待 start 返回时 - 发 prompt/enhance/cancel, 它会进 _inbox。本循环每轮 drain inbox, 检测到 cancel - 就转发给后端并写响应, 使 cancel 在阻塞等待期间可用 (Codex review P1-1 修复)。 + 返回: {enhanced} (completed) / {status:"cancelled"} (被取消) + 失败 (failed) → -32603 带 errorMessage; 超时未收到 result → -32603。 + + 时序关键: 必须先注册 listener 再发 start (参考 session/prompt "先 subscribe 再 send"), + 否则 start 返回与 result 推送之间的通知会丢。阻塞期间主循环串行不取下条消息, + 故每轮 _drain_enhance_inbox 把 inbox 里的 cancel 转发后端, 使 cancel 在等待期可用。 """ self.ensure_backend() ws, err = self._resolve_workspace(msg_id, params) @@ -1485,10 +1477,8 @@ class ACPBridge: self.backend.unregister_enhance_listener(request_id) return self._error(msg_id, -32603, f"zcode enhance start not accepted: {start_result}") - # 短轮询等待 result 通知 (总超时默认 120s; 比模型层 AbortSignal.timeout 宽裕)。 - # 每轮 drain inbox: 检测中途到达的 prompt/enhance/cancel, 转发后端并响应, - # 使 cancel 在阻塞期间可用 (主循环串行, 不 drain 则 cancel 卡在 inbox)。 - # ZCODE_ENHANCE_TIMEOUT 可覆盖 (仅用于测试加速, 生产留默认)。 + # 短轮询等待 result 通知。每轮 drain inbox 让中途的 cancel 可用 (主循环串行, + # 不 drain 则 cancel 卡在 inbox)。总超时默认 120s, ZCODE_ENHANCE_TIMEOUT 可覆盖。 try: enhance_timeout = int(os.environ.get("ZCODE_ENHANCE_TIMEOUT", "120")) except ValueError: @@ -1504,7 +1494,8 @@ class ACPBridge: continue self.backend.unregister_enhance_listener(request_id) if result_params is None: - return self._error(msg_id, -32603, "prompt/enhance result 等待超时 (120s)") + return self._error(msg_id, -32603, + f"prompt/enhance result 等待超时 ({enhance_timeout}s)") status = result_params.get("status") if status == "completed": enhanced = result_params.get("enhanced", "") @@ -1521,15 +1512,12 @@ class ACPBridge: return self._error(msg_id, -32603, f"zcode enhance failed: {err_msg}") def _drain_enhance_inbox(self, request_id): - """阻塞等待 enhance result 期间, 处理 inbox 里到达的 prompt/enhance/cancel。 - - 主循环 run() 串行处理请求; _on_prompt_enhance_start 阻塞时, client 发来的 - cancel 会卡在 _inbox 直到 start 返回。本方法每轮调用, 把 cancel 从 inbox 取出, - 转发给 zcode 后端, 并把 cancel 的响应写回 stdout (因主循环此刻不处理它)。 + """阻塞等待 enhance result 期间, 把 inbox 里的 cancel 转发给后端。 - 其他消息 (session/cancel 等已有处理逻辑) 放回 inbox 尾部, 不吞掉。 + 主循环串行, _on_prompt_enhance_start 阻塞时 client 发的 cancel 会卡在 inbox。 + 本方法每轮短轮询调用: 匹配 requestId 的 cancel 转发后端并写响应到 stdout, + 其他消息放回 inbox 尾部不吞掉。 """ - replay = [] while True: try: line = self._inbox.get_nowait() @@ -1546,13 +1534,11 @@ class ACPBridge: and req.get("params", {}).get("requestId") == request_id): cancel_id = req.get("id") cancel_resp = self._on_prompt_enhance_cancel(cancel_id, req.get("params", {})) - if cancel_resp is not None and cancel_id is not None: + if cancel_id is not None: sys.stdout.write(json.dumps(cancel_resp, ensure_ascii=False) + "\n") sys.stdout.flush() continue - # 非 cancel: 放回, 避免吞掉其他带外消息 - replay.append(line) - for line in replay: + # 非 cancel: 放回尾部, 不吞掉其他带外消息 (顺序保持) self._inbox.put(line) def _on_prompt_enhance_cancel(self, msg_id, params): diff --git a/tests/test_prompt_enhance.py b/tests/test_prompt_enhance.py index efdb37e..abead93 100644 --- a/tests/test_prompt_enhance.py +++ b/tests/test_prompt_enhance.py @@ -269,13 +269,14 @@ def test_pe17_start_completed_empty_string(self): self.assertEqual(resp["result"], {"enhanced": ""}) def test_pe18_cancel_during_wait_forwarded(self): - """PE18: start 阻塞等待期间收到 cancel → 转发后端 (P1-1: cancel 可用) + """PE18: start 阻塞等待期间收到 cancel → drain 转发后端 (P1-1: cancel 可用) _drain_enhance_inbox 在短轮询循环里被调用, 检测到 inbox 里的 cancel 后转发。 - 验证: cancel 消息从 inbox 被取出 (不卡住), 且 cancel 请求被转发给 backend。 + 真实验证: cancel 必须出现在 fake.calls 里 (证明 drain 从 inbox 取出并转发了), + 否则就是"假绿"——删掉 drain 转发逻辑测试仍过。 """ bridge, fake = self._new_bridge( - result_payload={"status": "completed", "enhanced": "ok"}, result_delay=0.4) + result_payload={"status": "completed", "enhanced": "ok"}, result_delay=1.0) import threading as _th start_done = _th.Event() state = {} @@ -287,7 +288,7 @@ def _start_thread(): start_done.set() t = _th.Thread(target=_start_thread) t.start() - time.sleep(0.1) # 等 start 进入阻塞等待 + time.sleep(0.15) # 等 start 进入阻塞等待 (已发出 start, 进入 result_q.get 轮询) # 往 inbox 塞一条 cancel (模拟 client 在等待期间发 cancel) cancel_msg = {"jsonrpc": "2.0", "id": 99, "method": "prompt/enhance/cancel", @@ -295,8 +296,14 @@ def _start_thread(): bridge._inbox.put(json.dumps(cancel_msg)) t.join(timeout=5) self.assertTrue(start_done.is_set(), "start 应在收到 result 后完成") - # start 最终完成 (result 比 cancel 先生效; 关键是 cancel 没卡死 inbox) self._assert_ok(state["start_resp"]) + # 关键断言: cancel 被 _drain_enhance_inbox 从 inbox 取出并转发给后端 + # (code-reviewer P1: 没这个断言则删掉转发逻辑测试仍绿 = 假绿) + cancel_forwarded = any( + c["method"] == "prompt/enhance/cancel" and c["params"].get("requestId") == "rcw" + for c in fake.calls) + self.assertTrue(cancel_forwarded, + "cancel 应被 drain 转发给 backend, 实际 fake.calls 无该调用") if __name__ == "__main__":