diff --git a/README.md b/README.md index f2fb0ca..da81552 100644 --- a/README.md +++ b/README.md @@ -132,15 +132,39 @@ ACP bridge 支持**事件驱动**(真流式)和**轮询**(伪流式)两 #### 扩展方法(非标准 ACP) -ACP bridge 额外暴露了 ZCode 新版(0.14.8+)的协议方法,供编辑器/脚本调用: +ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用。按 ZCode 引入版本分组: + +**session 级**(`{sessionId}` 定位会话): + +| 扩展方法 | 作用 | 引入版本 | params | +|----------|------|:--------:|--------| +| `session/fork` | 从 checkpoint 分叉新会话 | 0.14.8 | `{sessionId, target?}` | +| `session/rewind` | 回退工作区文件到 checkpoint | 0.14.8 | `{sessionId, target?, expectedRevision?}` | +| `session/goal` | 读取/设置 session 目标 | 0.14.8 | `{sessionId, action: show\|set\|replace\|clear, objective?}` | +| `session/compact` | 压缩对话上下文 | 0.14.8 | `{sessionId}` | +| `session/steer` | turn 进行中追加指令 | 0.14.8 | `{sessionId, content}` | +| `session/setThoughtLevel` | ⭐ 设置思考强度(实测 GLM-5.2: max/high/nothink,按模型不同) | 0.15.0 | `{sessionId, thoughtLevel}` | +| `session/updateRuntimeModelConfig` | 运行时覆盖会话模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}` | +| `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | +| `session/rewindCascade` | 级联回退(与 rewind 同 schema) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | +| `session/setModel` | 切换会话模型 | 0.14.8 | `{sessionId, modelId}` | +| `session/setMode` | 切换会话权限模式 | 0.14.8 | `{sessionId, mode}` | + +**workspace 级**(按工作区 `{workspacePath, workspaceKey}` 定位,不依赖 sessionId): | 扩展方法 | 作用 | params | |----------|------|--------| -| `session/fork` | 从 checkpoint 分叉新会话 | `{sessionId, target?}` | -| `session/rewind` | 回退工作区文件到 checkpoint | `{sessionId, target?, expectedRevision?}` | -| `session/goal` | 读取/设置 session 目标 | `{sessionId, action: show\|set\|replace\|clear, objective?}` | -| `session/compact` | 压缩对话上下文 | `{sessionId}` | -| `session/steer` | turn 进行中追加指令 | `{sessionId, content}` | +| `workspace/readState` | 读工作区状态(settings/modelCatalog/slashCommands) | `{workspace, runtimeModel?}` | +| `workspace/generateText` | 一次性文本生成(不建会话) | `{workspace, modelRef, prompt, querySource, maxOutputTokens?, temperature?}` | +| `workspace/setDefaultModel` | 设工作区默认模型(持久化) | `{workspace, model, runtimeModel?, expectedWorkspaceRevision?}` | +| `workspace/setDefaultMode` | 设工作区默认权限模式(持久化) | `{workspace, mode, expectedWorkspaceRevision?}` | +| `workspace/setDefaultThoughtLevel` | 设工作区默认思考强度(持久化) | `{workspace, thoughtLevel, expectedWorkspaceRevision?}` | +| `workspace/upsertModelProvider` | 新增/更新模型供应商 | `{workspace, provider, expectedWorkspaceRevision?}` | +| `workspace/removeModelProvider` | 移除模型供应商 | `{workspace, providerId, expectedWorkspaceRevision?}` | +| `workspace/updateProviderRegistry` | 批量更新供应商注册表 | `{workspace, registry, includeWorkspaceState?}` | + +> workspace 参数可三种方式传入:`workspace`(完整 dict)、`workspacePath`/`cwd`(路径字符串),或缺省时用 bridge 进程的 `cwd`。 +> Provider 管理类方法(upsert/remove/updateRegistry)的 `provider`/`registry` 可能含 `apiKey`,bridge 仅透传、不读取/打印其明文。 ## 会话存储 @@ -154,13 +178,14 @@ ACP bridge 额外暴露了 ZCode 新版(0.14.8+)的协议方法,供编辑 | ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 | |:--------------:|:--------:|:---------------:|:--------:| -| **0.14.8+**(App 3.1.4) | ✅ 完整 | **真流式**(事件驱动) | ✅ fork/rewind/goal/compact/steer | +| **0.15.0+**(App 3.2.0) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel 等) | +| **0.14.8**(App 3.1.4) | ✅ 完整 | **真流式**(事件驱动) | ✅ fork/rewind/goal/compact/steer | | **0.14.5 ~ 0.14.7** | ✅ 兼容 | 伪流式(自动降级轮询) | ❌(旧版协议未实现) | | **< 0.14.5** | ⚠️ 未测 | — | — | **降级行为**(自动,无需手动配置): - ACP bridge 检测到 `session/subscribe` 不可用时,自动切换到轮询 `session/read`(伪流式) -- 扩展方法(fork/rewind/goal/compact/steer)在旧版 ZCode 上会透传后端错误(`-32603 zcode failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume) +- 扩展方法在旧版 ZCode 上会透传后端错误(`-32603 zcode failed: ...`),不影响标准 ACP 方法(new/prompt/cancel/list/resume)。例如在 0.14.8 上调用 `workspace/*` 或 `session/setThoughtLevel`(0.15.0 新增)会得到 `-32603`,调用方应据此做版本判断。 ## Skill(驱动说明书) @@ -168,7 +193,7 @@ ACP bridge 额外暴露了 ZCode 新版(0.14.8+)的协议方法,供编辑 - 三种接入模式(CLI / ACP / MCP)的完整使用方法 - 凭证配置、非交互 shell 坑 - 真流式 vs 伪流式双模式说明 -- 扩展协议方法(fork/rewind/goal/compact/steer) +- 扩展协议方法(session 级 + workspace 级,含 setThoughtLevel 思考强度控制) - 任务书模板、独立复核、版本兼容性 复制到 ZCode 的 skill 目录即可使用: @@ -187,6 +212,7 @@ cp -r skills/zcode-bridge-guide ~/.zcode/skills/ 5. **GLM-5.2 无推理输出**:思考过程(agent_thought_chunk)在 GLM-5.2 下不触发,需 GLM-5-Turbo。 6. **TUI 不可用**:`zcode` 无参数直接运行(TUI 模式)在独立终端报错(缺 `@zcode/tui` 模块),仅 headless 模式可用。 7. **⚠️ ACP bridge 默认 `mode=yolo`(权限风险)**:为避免工具调用 turn 卡在权限确认,ACP bridge 的 `session/new` 强制以 `mode=yolo` 创建会话(见 `zcode-acp-bridge` 的 `_on_session_new`)。这意味着任意 prompt 都可能触发**无确认的文件修改和命令执行**。作为编辑器集成时请知悉此风险;如需更安全的 `build` 模式(带权限确认),需自行修改并实现 ACP↔ZCode 的 permission 转发(本项目 P4b 未实现)。 +8. **⚠️ Provider 管理方法涉及 apiKey**:`workspace/upsertModelProvider`、`workspace/updateProviderRegistry` 的 `provider`/`registry` 参数会携带 `apiKey`(可能为 `{source:"inline", value:"sk-..."}` 明文)。ACP bridge 仅整体透传给 ZCode 后端、不读取也不在日志打印其明文;但调用方应自行确保传输通道(stdio)可信,并避免在日志中回显原始参数。 ## 项目结构 diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index aa7ac79..798f178 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -45,7 +45,11 @@ zcode-acp-bridge — 把 headless ZCode 桥接为 ACP (Agent Client Protocol) Ag ✅ 流式文本 (model.streaming → agent_message_chunk 逐段) ✅ 工具调用展示 (tool.updated → tool_call/tool_call_update 实时) ✅ session/cancel → session/stop - ✅ 扩展方法: fork/rewind/goal/compact/steer (0.14.8+) + ✅ 扩展方法 (session 级, 0.14.8+): fork/rewind/goal/compact/steer + ✅ 扩展方法 (session 级, 0.15.0+): setThoughtLevel/updateRuntimeModelConfig/ + cancelBackgroundTask/rewindCascade/setModel/setMode + ✅ workspace/* 方法 (0.15.0+): readState/generateText/setDefault{Model,Mode,ThoughtLevel}/ + upsertModelProvider/removeModelProvider/updateProviderRegistry 协议: ACP (JSON-RPC 2.0 over stdio) 对外; ZCode Protocol (无 jsonrpc 字段) 对内 依赖: 仅 Python3 标准库 @@ -767,6 +771,38 @@ class ACPBridge: if method == "session/steer": return self._on_session_steer(msg_id, req.get("params", {})) + # ----- session 级新方法 (0.15.0+, 纯透传) ----- + if method == "session/setThoughtLevel": + return self._on_session_set_thought_level(msg_id, req.get("params", {})) + if method == "session/updateRuntimeModelConfig": + return self._on_session_update_runtime_model_config(msg_id, req.get("params", {})) + if method == "session/cancelBackgroundTask": + return self._on_session_cancel_background_task(msg_id, req.get("params", {})) + if method == "session/rewindCascade": + return self._on_session_rewind_cascade(msg_id, req.get("params", {})) + if method == "session/setModel": + return self._on_session_set_model(msg_id, req.get("params", {})) + if method == "session/setMode": + return self._on_session_set_mode(msg_id, req.get("params", {})) + + # ----- workspace/* 方法 (0.15.0+, 按 workspace 选择器定位) ----- + if method == "workspace/readState": + return self._on_workspace_read_state(msg_id, req.get("params", {})) + if method == "workspace/generateText": + return self._on_workspace_generate_text(msg_id, req.get("params", {})) + if method == "workspace/setDefaultModel": + return self._on_workspace_set_default_model(msg_id, req.get("params", {})) + if method == "workspace/setDefaultMode": + return self._on_workspace_set_default_mode(msg_id, req.get("params", {})) + if method == "workspace/setDefaultThoughtLevel": + return self._on_workspace_set_default_thought_level(msg_id, req.get("params", {})) + if method == "workspace/upsertModelProvider": + return self._on_workspace_upsert_model_provider(msg_id, req.get("params", {})) + if method == "workspace/removeModelProvider": + return self._on_workspace_remove_model_provider(msg_id, req.get("params", {})) + if method == "workspace/updateProviderRegistry": + return self._on_workspace_update_provider_registry(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}") @@ -1005,6 +1041,432 @@ class ACPBridge: log(f"session/steer → kind={result.get('kind', '?')}") return {"jsonrpc": "2.0", "id": msg_id, "result": result} + # ---------- workspace/* 方法 (非标准 ACP, 透传 zcode 0.15.0+ 协议) ---------- + # 这些方法作用于 workspace (工作区), 通过 {workspacePath, workspaceKey} 选择器定位, + # 与 session 级方法 (按 sessionId) 不同。ZCode 3.2.0 (CLI 0.15.0) 新增。 + + def _resolve_workspace(self, msg_id, params): + """从 ACP params 解析 workspace 选择器。 + + zcode 的 workspace 选择器形如 {workspacePath, workspaceKey, workspaceIdentity?}, + 其中 workspacePath/workspaceKey 都是路径字符串 (现有 session/create 也这样用, + 见 _on_session_new)。ACP client 可能以下面任一方式传入工作区: + + 1. params["workspace"] 已是合法 dict (含 workspacePath) → 直接透传 + 2. params["workspacePath"] 或 params["cwd"] → 构造 {workspacePath, workspaceKey} + 3. 都没有 → 用 os.getcwd() 兜底 + + Args: + msg_id: 预留 (当前四级解析总能成功, 不会返回错误响应; 保留参数以与其它 + handler 签名一致, 且未来若加 workspace 路径校验可直接返回错误)。 + Returns: + (workspace_dict, None) 成功; 或 (None, error_response) 失败 (返回给 client)。 + """ + ws = params.get("workspace") + if isinstance(ws, dict) and ws.get("workspacePath"): + return ws, None + path = params.get("workspacePath") or params.get("cwd") or os.getcwd() + return {"workspacePath": path, "workspaceKey": path}, None + + @staticmethod + def _redact_secret(text): + """对后端错误消息做最小脱敏, 防止 apiKey 泄漏到 ACP client。 + + provider/registry 相关方法 (upsert/remove/updateProviderRegistry) 的入参会 + 携带 apiKey。虽然 bridge 透传时不读取其值, 但若 zcode 后端把入参片段回显在 + error.message 里, 原样返回会泄漏。这里用正则遮蔽常见密钥形态: + - sk- 开头的 OpenAI/Anthropic 风格 key 前缀 + - JSON 里 "value":"..." / "apiKey":{...} 的敏感值 + 保留非敏感部分以便排查。 + """ + import re + if not text: + return text + s = re.sub(r'sk-[A-Za-z0-9_-]{6,}', 'sk-***', text) + s = re.sub(r'("value"\s*:\s*")([^"]{4,})(")', r'\1***\3', s) + s = re.sub(r'("apiKey"\s*:\s*)("?\{[^}]*\}"?|"[^"]{4,}")', + r'\1***', s) + return s + + # ---------- session 级新方法 (0.15.0+) ---------- + # 纯透传, 遵循与 fork/rewind/goal 相同的模板。均不启动内部 AI turn (区别于 goal set)。 + + def _on_session_set_thought_level(self, msg_id, params): + """扩展 session/setThoughtLevel → zcode session/setThoughtLevel: 设置思考强度。 + + params: {sessionId, thoughtLevel} + thoughtLevel 是动态值 (非固定 enum, schema 仅校验非空字符串, 运行时由 + 当前模型的 reasoning 能力决定。实测 GLM-5.2 可选值: max/high/nothink; + 其他模型可能不同)。故 bridge 不做 enum 硬校验, 透传给 zcode 由其按模型 + 能力判定 (非法值由 zcode 返回 -32602)。 + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + thought_level = params.get("thoughtLevel") + if not thought_level: + return self._error(msg_id, -32602, "setThoughtLevel 需要 thoughtLevel") + zc_params = {"sessionId": zcode_sid, "thoughtLevel": thought_level} + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/setThoughtLevel", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setThoughtLevel failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("session/setThoughtLevel → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_session_update_runtime_model_config(self, msg_id, params): + """扩展 session/updateRuntimeModelConfig → 同名: 运行时覆盖会话模型配置。 + + params: {sessionId, runtimeModel, applyModelSelection?} + runtimeModel 是复杂嵌套对象 ({revision, generatedAt, model, provider, thoughtLevel?}), + 原样透传不篡改。 + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + runtime_model = params.get("runtimeModel") + if not runtime_model: + return self._error(msg_id, -32602, "updateRuntimeModelConfig 需要 runtimeModel") + zc_params = {"sessionId": zcode_sid, "runtimeModel": runtime_model} + if params.get("applyModelSelection") is not None: + zc_params["applyModelSelection"] = params["applyModelSelection"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/updateRuntimeModelConfig", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode updateRuntimeModelConfig failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("session/updateRuntimeModelConfig → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_session_cancel_background_task(self, msg_id, params): + """扩展 session/cancelBackgroundTask → 同名: 取消后台 Bash 任务。 + + params: {sessionId, taskId} + 返回: {cancelled, reason?, snapshot?, status, taskId} + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + task_id = params.get("taskId") + if not task_id: + return self._error(msg_id, -32602, "cancelBackgroundTask 需要 taskId") + zc_params = {"sessionId": zcode_sid, "taskId": task_id} + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/cancelBackgroundTask", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode cancelBackgroundTask failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"session/cancelBackgroundTask → cancelled={result.get('cancelled')}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_session_rewind_cascade(self, msg_id, params): + """扩展 session/rewindCascade → zcode session/rewindCascade: 级联回退。 + + 与 session/rewind 参数 schema 完全一致 (zcode 内部 G2e=lZ 同一引用), + 仅 method 名不同, zcode 内部构建 /rewind cascade 。 + params: {sessionId, target?, scope?, expectedRevision?} + target: 默认 {kind:"latestCheckpoint"}; 或 {kind:"checkpoint", checkpointId} + scope: conversation(默认)|workspace|both + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + zc_params = {"sessionId": zcode_sid} + if params.get("checkpointId"): + zc_params["target"] = {"kind": "checkpoint", "checkpointId": params["checkpointId"]} + else: + zc_params["target"] = params.get("target", {"kind": "latestCheckpoint"}) + if params.get("scope"): + zc_params["scope"] = params["scope"] + if params.get("expectedRevision") is not None: + zc_params["expectedRevision"] = params["expectedRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/rewindCascade", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode rewindCascade failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("session/rewindCascade → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_session_set_model(self, msg_id, params): + """扩展 session/setModel → zcode session/setModel: 切换会话模型。 + + (0.14.8 旧方法, bridge 之前未暴露, 本次补齐) + params: {sessionId, modelId} + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + model_id = params.get("modelId") + if not model_id: + return self._error(msg_id, -32602, "setModel 需要 modelId") + zc_params = {"sessionId": zcode_sid, "modelId": model_id} + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/setModel", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setModel failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"session/setModel → {model_id}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_session_set_mode(self, msg_id, params): + """扩展 session/setMode → zcode session/setMode: 切换会话权限模式。 + + (0.14.8 旧方法, bridge 之前未暴露, 本次补齐) + params: {sessionId, mode} mode: plan|build|edit|yolo|auto + """ + self.ensure_backend() + acp_sid = params.get("sessionId") + zcode_sid = self.session_map.get(acp_sid) or acp_sid + mode = params.get("mode") + if not mode: + return self._error(msg_id, -32602, "setMode 需要 mode") + zc_params = {"sessionId": zcode_sid, "mode": mode} + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/setMode", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setMode failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"session/setMode → {mode}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + # ---------- workspace 查询/生成/设置 (0.15.0+) ---------- + + def _on_workspace_read_state(self, msg_id, params): + """workspace/readState → 同名: 一次性读取工作区状态。 + + params: {workspace, runtimeModel?} + 返回: {workspace, settings, modelCatalog?, slashCommands?} + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + zc_params = {"workspace": ws} + if params.get("runtimeModel") is not None: + zc_params["runtimeModel"] = params["runtimeModel"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/readState", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode readState failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("workspace/readState → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_generate_text(self, msg_id, params): + """workspace/generateText → 同名: 一次性文本生成 (不创建会话)。 + + params: {workspace, modelRef:{providerId,modelId,variant?}, prompt, querySource, + maxOutputTokens?, temperature?} + 返回: {text, modelRef} + 注意: 涉及模型调用, timeout 设 60s。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + model_ref = params.get("modelRef") + prompt = params.get("prompt") + if not model_ref: + return self._error(msg_id, -32602, "generateText 需要 modelRef") + if not prompt: + return self._error(msg_id, -32602, "generateText 需要 prompt") + zc_params = { + "workspace": ws, + "modelRef": model_ref, + "prompt": prompt, + "querySource": params.get("querySource", "bridge"), + } + if params.get("maxOutputTokens") is not None: + zc_params["maxOutputTokens"] = params["maxOutputTokens"] + if params.get("temperature") is not None: + zc_params["temperature"] = params["temperature"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/generateText", zc_params, timeout=60) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode generateText failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"workspace/generateText → ok (text {len(result.get('text', ''))} chars)") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_set_default_model(self, msg_id, params): + """workspace/setDefaultModel → 同名: 设置工作区默认模型 (持久化)。 + + params: {workspace, model:{providerId,modelId,variant?}, runtimeModel?, + expectedWorkspaceRevision?} + 返回: workspace 状态 {workspace, settings, modelCatalog?, slashCommands?} + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + model = params.get("model") + if not model: + return self._error(msg_id, -32602, "setDefaultModel 需要 model") + zc_params = {"workspace": ws, "model": model} + if params.get("runtimeModel") is not None: + zc_params["runtimeModel"] = params["runtimeModel"] + if params.get("expectedWorkspaceRevision") is not None: + zc_params["expectedWorkspaceRevision"] = params["expectedWorkspaceRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/setDefaultModel", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setDefaultModel failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("workspace/setDefaultModel → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_set_default_mode(self, msg_id, params): + """workspace/setDefaultMode → 同名: 设置工作区默认权限模式 (持久化)。 + + params: {workspace, mode, expectedWorkspaceRevision?} + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + mode = params.get("mode") + if not mode: + return self._error(msg_id, -32602, "setDefaultMode 需要 mode") + zc_params = {"workspace": ws, "mode": mode} + if params.get("expectedWorkspaceRevision") is not None: + zc_params["expectedWorkspaceRevision"] = params["expectedWorkspaceRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/setDefaultMode", zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setDefaultMode failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"workspace/setDefaultMode → {mode}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_set_default_thought_level(self, msg_id, params): + """workspace/setDefaultThoughtLevel → 同名: 设置工作区默认思考强度 (持久化)。 + + params: {workspace, thoughtLevel, expectedWorkspaceRevision?} + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + thought_level = params.get("thoughtLevel") + if not thought_level: + return self._error(msg_id, -32602, "setDefaultThoughtLevel 需要 thoughtLevel") + zc_params = {"workspace": ws, "thoughtLevel": thought_level} + if params.get("expectedWorkspaceRevision") is not None: + zc_params["expectedWorkspaceRevision"] = params["expectedWorkspaceRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/setDefaultThoughtLevel", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode setDefaultThoughtLevel failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log("workspace/setDefaultThoughtLevel → ok") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + # ---------- workspace Provider 管理 (0.15.0+, 含 apiKey, 注意脱敏) ---------- + + def _on_workspace_upsert_model_provider(self, msg_id, params): + """workspace/upsertModelProvider → 同名: 新增/更新模型供应商。 + + params: {workspace, provider:{providerId, kind, baseURL?, apiKey?, models:[...], ...}, + expectedWorkspaceRevision?} + 返回: workspace 状态。 + ⚠ provider 对象可能含 apiKey (inline value 或 credential key), log 绝不打印其原文, + 只打印 providerId; 透传对象整体交给 zcode, 不读取/篡改 apiKey。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + provider = params.get("provider") + if not provider: + return self._error(msg_id, -32602, "upsertModelProvider 需要 provider") + zc_params = {"workspace": ws, "provider": provider} + if params.get("expectedWorkspaceRevision") is not None: + zc_params["expectedWorkspaceRevision"] = params["expectedWorkspaceRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/upsertModelProvider", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode upsertModelProvider failed: " + f"{self._redact_secret(resp['error'].get('message', ''))}") + result = resp.get("result", {}) + # 只打 providerId, 不打 provider 原文 (含 apiKey) + log(f"workspace/upsertModelProvider → ok ({provider.get('providerId', '?')})") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_remove_model_provider(self, msg_id, params): + """workspace/removeModelProvider → 同名: 移除模型供应商。 + + params: {workspace, providerId, expectedWorkspaceRevision?} + 返回: workspace 状态。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + provider_id = params.get("providerId") + if not provider_id: + return self._error(msg_id, -32602, "removeModelProvider 需要 providerId") + zc_params = {"workspace": ws, "providerId": provider_id} + if params.get("expectedWorkspaceRevision") is not None: + zc_params["expectedWorkspaceRevision"] = params["expectedWorkspaceRevision"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/removeModelProvider", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode removeModelProvider failed: {resp['error'].get('message', '')}") + result = resp.get("result", {}) + log(f"workspace/removeModelProvider → ok ({provider_id})") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + def _on_workspace_update_provider_registry(self, msg_id, params): + """workspace/updateProviderRegistry → 同名: 批量更新供应商注册表。 + + params: {workspace, registry:{revision, generatedAt, providers:[...]}, + includeWorkspaceState?} + 返回: {workspace, appliedProviderRevision, status, providerCount, workspaceState?} + ⚠ registry.providers 内可能含 apiKey, log 只打数量, 不打 registry 原文。 + """ + self.ensure_backend() + ws, err = self._resolve_workspace(msg_id, params) + if err: + return err + registry = params.get("registry") + if not registry: + return self._error(msg_id, -32602, "updateProviderRegistry 需要 registry") + zc_params = {"workspace": ws, "registry": registry} + if params.get("includeWorkspaceState") is not None: + zc_params["includeWorkspaceState"] = params["includeWorkspaceState"] + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "workspace/updateProviderRegistry", + zc_params, timeout=15) + if "error" in resp: + return self._error(msg_id, -32603, + f"zcode updateProviderRegistry failed: " + f"{self._redact_secret(resp['error'].get('message', ''))}") + result = resp.get("result", {}) + provider_count = len(registry.get("providers", [])) + log(f"workspace/updateProviderRegistry → {result.get('status', '?')} " + f"({provider_count} providers)") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + @staticmethod def _to_iso(ts): """zcode 的毫秒时间戳 → ISO 8601 字符串 (ACP SessionInfo.updatedAt 期望)""" diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index b1009c6..8049bc2 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -34,7 +34,7 @@ ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode") # ============================================================ OVERVIEW = { "tool": "zcode", - "version_described": "0.14.8 (App 3.1.4)", + "version_described": "0.15.0 (App 3.2.0)", "what_it_is": ( "ZCode 是智谱(Z.AI)的 Agentic Coding CLI, 由 GLM 系列模型驱动。" "它能自主完成代码编写/审查/调试/分析等任务, 可调用工具(读写文件、" @@ -224,7 +224,34 @@ APP_SERVER = { {"method": "session/goal", "params": "sessionId, goal?", "note": "0.14.8+: 读/设 session 目标 (新)"}, {"method": "session/compact", "params": "sessionId", "note": "0.14.8+: 压缩对话上下文 (新)"}, {"method": "session/setModel", "params": "sessionId, modelId", "note": "0.14.8+: 切换模型 (新)"}, - {"method": "session/cancelBackgroundTask", "params": "sessionId", "note": "0.14.8+: 取消后台任务 (新)"}, + {"method": "session/cancelBackgroundTask", "params": "sessionId, taskId", + "note": "0.14.8+: 取消后台 Bash 任务 (新)"}, + # ----- 0.15.0 (App 3.2.0) 新增 session 级方法 ----- + {"method": "session/setThoughtLevel", "params": "sessionId, thoughtLevel", + "note": "0.15.0+: 设置思考强度 (动态值, 按模型 reasoning 能力; 实测 GLM-5.2: max/high/nothink)"}, + {"method": "session/updateRuntimeModelConfig", "params": "sessionId, runtimeModel, applyModelSelection?", + "note": "0.15.0+: 运行时覆盖模型配置 (含 provider/providerOptions/thoughtLevel)"}, + {"method": "session/rewindCascade", "params": "sessionId, target?, scope?, expectedRevision?", + "note": "0.15.0+: 级联回退 (与 rewind 同 schema, 内部 /rewind cascade)"}, + {"method": "session/setMode", "params": "sessionId, mode", + "note": "0.15.0+ 暴露: 切换会话权限模式 (mode: plan|build|edit|yolo|auto)"}, + # ----- 0.15.0 (App 3.2.0) 新增 workspace/* 方法 (按工作区定位, 非 session) ----- + {"method": "workspace/readState", "params": "workspace:{workspacePath,workspaceKey}, runtimeModel?", + "note": "0.15.0+: 读工作区状态 (settings/modelCatalog/slashCommands)"}, + {"method": "workspace/generateText", "params": "workspace, modelRef, prompt, querySource, maxOutputTokens?, temperature?", + "note": "0.15.0+: 一次性文本生成 (不建会话, 无 messages/tools)"}, + {"method": "workspace/setDefaultModel", "params": "workspace, model, runtimeModel?, expectedWorkspaceRevision?", + "note": "0.15.0+: 设工作区默认模型 (持久化, 乐观锁)"}, + {"method": "workspace/setDefaultMode", "params": "workspace, mode, expectedWorkspaceRevision?", + "note": "0.15.0+: 设工作区默认权限模式 (持久化)"}, + {"method": "workspace/setDefaultThoughtLevel", "params": "workspace, thoughtLevel, expectedWorkspaceRevision?", + "note": "0.15.0+: 设工作区默认思考强度 (持久化)"}, + {"method": "workspace/upsertModelProvider", "params": "workspace, provider, expectedWorkspaceRevision?", + "note": "0.15.0+: 新增/更新模型供应商 (provider 含 apiKey, 敏感)"}, + {"method": "workspace/removeModelProvider", "params": "workspace, providerId, expectedWorkspaceRevision?", + "note": "0.15.0+: 移除模型供应商"}, + {"method": "workspace/updateProviderRegistry", "params": "workspace, registry, includeWorkspaceState?", + "note": "0.15.0+: 批量更新供应商注册表 (registry.providers 含 apiKey, 敏感)"}, ], "turn_model": { "description": "异步模型: send 立即返回, turn 完成需轮询 session/read", @@ -278,6 +305,16 @@ ECOSYSTEM = { # 扩展方法 (非标准 ACP, 透传 zcode 0.14.8+ 协议): "session/fork", "session/rewind", "session/goal", "session/compact", "session/steer", + # 扩展方法 (透传 zcode 0.15.0+ 协议, session 级): + "session/setThoughtLevel", "session/updateRuntimeModelConfig", + "session/cancelBackgroundTask", "session/rewindCascade", + "session/setModel", "session/setMode", + # 扩展方法 (透传 zcode 0.15.0+ 协议, workspace 级): + "workspace/readState", "workspace/generateText", + "workspace/setDefaultModel", "workspace/setDefaultMode", + "workspace/setDefaultThoughtLevel", + "workspace/upsertModelProvider", "workspace/removeModelProvider", + "workspace/updateProviderRegistry", ], "acp_events_emitted": [ {"event": "tool_call/tool_call_update", "status": "✅ 实时 (事件驱动)"}, diff --git a/skills/zcode-bridge-guide/SKILL.md b/skills/zcode-bridge-guide/SKILL.md index 6a2ffc6..616d36a 100644 --- a/skills/zcode-bridge-guide/SKILL.md +++ b/skills/zcode-bridge-guide/SKILL.md @@ -1,14 +1,14 @@ --- name: zcode-bridge-guide -version: 1.0.0 -description: 驱动 ZCode(智谱 GLM 系列 coding agent)的通用说明书。覆盖三种接入模式(CLI --prompt / ACP bridge / MCP tools)、凭证配置、真流式/伪流式双模式、扩展协议方法、任务书模板、已知坑。需要把 ZCode 当子代理编排、跑编码/审查任务、或集成进编辑器时用。兼容 ZCode CLI 0.14.5 ~ 0.14.8+。 +version: 1.1.0 +description: 驱动 ZCode(智谱 GLM 系列 coding agent)的通用说明书。覆盖三种接入模式(CLI --prompt / ACP bridge / MCP tools)、凭证配置、真流式/伪流式双模式、扩展协议方法(session 级 + workspace 级)、思考强度控制、任务书模板、已知坑。需要把 ZCode 当子代理编排、跑编码/审查任务、或集成进编辑器时用。兼容 ZCode CLI 0.14.5 ~ 0.15.0+。 user-invocable: true --- # 驱动 ZCode(三模式通用说明书) > 本 skill 是 [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) 项目的配套说明书。 -> 兼容 ZCode CLI **0.14.5 ~ 0.14.8+**(App 3.1.4)。新版功能(事件驱动真流式、fork/rewind/goal/compact/steer)在旧版上自动降级。 +> 兼容 ZCode CLI **0.14.5 ~ 0.15.0+**(App 3.2.0)。新版功能(事件驱动真流式、fork/rewind/goal/compact/steer、workspace/*、setThoughtLevel 思考强度控制)在旧版上自动降级或返回 `-32603`。 ## 前置条件 @@ -228,17 +228,39 @@ ACP bridge 根据运行时检测自动选择模式: **无需手动选择**——bridge 先尝试 `session/subscribe`,失败则自动降级轮询。 -### 扩展方法(非标准 ACP,0.14.8+ 新增) +### 扩展方法(非标准 ACP) + +ACP bridge 暴露的 ZCode 新版协议方法,按定位维度分组。**session 级**用 `{sessionId}` 定位会话;**workspace 级**用 `{workspace, workspaceKey}`(或 `workspacePath`/`cwd`)定位工作区,不依赖会话。 + +**session 级扩展方法**: + +| 方法 | 作用 | 引入版本 | params | +|------|------|:--------:|--------| +| `session/fork` | 从 checkpoint 分叉新会话 | 0.14.8 | `{sessionId, target?}` | +| `session/rewind` | 回退工作区文件到 checkpoint | 0.14.8 | `{sessionId, target?, expectedRevision?}` | +| `session/goal` | 读取/设置 session 目标 | 0.14.8 | `{sessionId, action: show\|set\|replace\|clear, objective?}` | +| `session/compact` | 压缩对话上下文 | 0.14.8 | `{sessionId}` | +| `session/steer` | turn 进行中追加指令 | 0.14.8 | `{sessionId, content}` | +| `session/setThoughtLevel` | ⭐ 设置思考强度 | 0.15.0 | `{sessionId, thoughtLevel}` | +| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, modelId}` / `{sessionId, mode}` | +| `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | +| `session/rewindCascade` | 级联回退(同 rewind schema) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | +| `session/updateRuntimeModelConfig` | 运行时覆盖模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}` | + +**workspace 级扩展方法**(0.15.0+): | 方法 | 作用 | params | |------|------|--------| -| `session/fork` | 从 checkpoint 分叉新会话 | `{sessionId, target?}` | -| `session/rewind` | 回退工作区文件到 checkpoint | `{sessionId, target?, expectedRevision?}` | -| `session/goal` | 读取/设置 session 目标 | `{sessionId, action: show\|set\|replace\|clear, objective?}` | -| `session/compact` | 压缩对话上下文 | `{sessionId}` | -| `session/steer` | turn 进行中追加指令 | `{sessionId, content}` | +| `workspace/readState` | 读工作区状态(模型目录/设置) | `{workspace, runtimeModel?}` | +| `workspace/generateText` | 一次性文本生成(不建会话) | `{workspace, modelRef, prompt, querySource, ...}` | +| `workspace/setDefaultModel` / `setDefaultMode` / `setDefaultThoughtLevel` | 设工作区默认值(持久化) | `{workspace, model\|mode\|thoughtLevel, expectedWorkspaceRevision?}` | +| `workspace/upsertModelProvider` / `removeModelProvider` / `updateProviderRegistry` | 管理模型供应商(含 apiKey,敏感) | `{workspace, provider\|providerId\|registry, ...}` | > ⚠️ `session/goal action=set` 会启动内部 AI turn(异步),耗时 10~45s。bridge 会自动等待 prompt lock 释放后返回,但调用方需预期较长延迟。 +> +> 💡 **思考强度控制**(0.15.0+):`session/setThoughtLevel` 可在跑任务前调高(如 `high`/`max`)让模型多推理,跑完后调回(如 `nothink`)。`thoughtLevel` 是动态值,按当前模型的 reasoning 能力决定可选值(实测 GLM-5.2:`max`/`high`/`nothink`,其他模型可能不同)。这与 drive-claude 的 `--effort`、drive-codex 的 reasoning effort 形成对称能力。设置后会话内的后续 turn 都生效。 +> +> ⚠️ **apiKey 透传**:Provider 管理类方法的参数会携带 `apiKey`,bridge 仅透传不打印明文;调用方应确保 stdio 通道可信。 ### ⚠️ ACP 模式已知限制 1. **默认 `mode=yolo`**:所有 prompt 可能触发无确认的文件修改和命令执行。信任环境要隔离(worktree / 受信目录) @@ -334,7 +356,8 @@ npm test # 全量,看实际数字 | ZCode CLI 版本 | 支持情况 | 差异 | |:--------------:|:--------:|------| -| **0.14.8+** (App 3.1.4) | ✅ 完整 | ACP bridge 真流式;fork/rewind/goal/compact/steer 可用 | +| **0.15.0+** (App 3.2.0) | ✅ 完整 | ACP bridge 真流式;全部扩展方法可用(含 workspace/*、setThoughtLevel) | +| **0.14.8** (App 3.1.4) | ✅ 完整 | ACP bridge 真流式;fork/rewind/goal/compact/steer 可用;workspace/* 与 setThoughtLevel 返回 -32603 | | **0.14.5 ~ 0.14.7** | ✅ 兼容 | ACP bridge 自动降级伪流式;扩展方法不可用(协议未实现) | | **< 0.14.5** | ⚠️ 未测 | CLI `--prompt` 基本可用;ACP bridge 未验证 | diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py new file mode 100644 index 0000000..fb6df01 --- /dev/null +++ b/tests/test_app_server_methods.py @@ -0,0 +1,585 @@ +""" +test_app_server_methods.py — app-server 新协议方法 (0.15.0+) 单测 + +用 FakeBackend 替换真实 zcode 子进程, 验证 ACPBridge.handle_acp() 能把 +新协议方法 (session 级 0.15.0+ 与 workspace/* 0.15.0+) 正确路由、转换参数、 +透传复杂对象、回显错误, 并覆盖旧 handler (fork/rewind/goal 等) 此前的测试盲区。 + + M0 FakeBackend 基建 (路由 + 默认响应) + M1 session/setThoughtLevel (参数透传 + 缺参错误 + 错误回显) + M2 session/updateRuntimeModelConfig (复杂嵌套对象原样透传) + M3 session/cancelBackgroundTask (taskId 透传) + M4 session/rewindCascade (target/scope 构造, 与 rewind 对比) + M5 session/setModel + session/setMode (0.14.8 旧方法补齐) + M6 workspace/_resolve_workspace 选择器 (workspace dict / workspacePath / cwd / 默认) + M7 workspace/readState + workspace/generateText (含 timeout 60s) + M8 workspace/setDefault* 三件套 (乐观锁透传) + M9 workspace Provider 管理 (apiKey 嵌套对象透传保真 + 日志/错误回显双脱敏) + M10 旧 handler 回归 (fork/rewind/goal/compact/steer 路由, 补盲区) + +运行: python3 tests/test_app_server_methods.py +依赖: 仅 Python 标准库 + 本项目的 acp-bridge 模块 +""" + +import contextlib +import io +import os +import types +import unittest + +# 把 acp-bridge 模块加载进来 (单文件, 用 exec 导入类定义, 跳过 main) +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 FakeBackend: + """替代真实 ZCodeBackend, 记录所有 request() 调用, 按预设返回响应。 + + - self.calls: 每次调用的 {"id", "method", "params", "timeout"} 列表 + - self.next_response: 下一次 request() 返回的响应 dict (默认成功空结果) + """ + + def __init__(self): + self.calls = [] + self.next_response = {"result": {"ok": True}} + + def request(self, msg_id, method, params=None, timeout=30): + self.calls.append({ + "id": msg_id, "method": method, + "params": params or {}, "timeout": timeout, + }) + return self.next_response, [] + + def send(self, msg): + # 仅用于兼容 (本次测试不覆盖 fire-and-forget 路径) + pass + + +class TestAppServerMethods(unittest.TestCase): + """app-server 新协议方法 (0.15.0+) 的路由/参数转换/透传/错误 单测""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_bridge_module() + cls.Bridge = cls.mod.ACPBridge + + def _new_bridge(self): + """构造一个注入了 FakeBackend 的 bridge (跳过真实子进程)。""" + b = self.Bridge() + fake = FakeBackend() + b.backend = fake + return b, fake + + def _call(self, bridge, method, params=None, msg_id=1): + """封装一次 handle_acp 调用。""" + req = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params or {}} + return bridge.handle_acp(req) + + def _assert_ok(self, resp, msg=None): + """断言响应是成功 (含 result, 无 error)。""" + self.assertNotIn("error", resp, f"期望成功, 实际报错: {resp.get('error')} ({msg})") + self.assertIn("result", resp) + + def _assert_error_code(self, resp, code, msg=None): + self.assertEqual(resp.get("error", {}).get("code"), code, + f"期望错误码 {code}, 实际: {resp} ({msg})") + + # ---------- M0: FakeBackend 基建 ---------- + def test_m0_basic_routing(self): + """M0: 已有方法 (initialize) 正常路由, 证明 FakeBackend 注入生效""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "initialize") + self._assert_ok(resp) + self.assertEqual(fake.calls, [], "initialize 不应调 backend") + + # ---------- M1: session/setThoughtLevel ---------- + def test_m1_set_thought_level_passthrough(self): + """M1: setThoughtLevel 透传 thoughtLevel (动态值, 不做 enum 硬校验)""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/setThoughtLevel", + {"sessionId": "sess_x", "thoughtLevel": "high"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/setThoughtLevel") + self.assertEqual(fake.calls[0]["params"]["thoughtLevel"], "high") + self.assertEqual(fake.calls[0]["params"]["sessionId"], "sess_x") + + def test_m1_set_thought_level_missing_param(self): + """M1a: 缺 thoughtLevel → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/setThoughtLevel", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32602) + + def test_m1_set_thought_level_backend_error(self): + """M1b: backend 返回 error → -32603""" + bridge, fake = self._new_bridge() + fake.next_response = {"error": {"message": "model has no reasoning levels"}} + resp = self._call(bridge, "session/setThoughtLevel", + {"sessionId": "sess_x", "thoughtLevel": "high"}) + self._assert_error_code(resp, -32603) + + # ---------- M2: session/updateRuntimeModelConfig ---------- + def test_m2_update_runtime_model_config_passthrough(self): + """M2: 复杂嵌套 runtimeModel 原样透传不被篡改""" + bridge, fake = self._new_bridge() + runtime_model = { + "revision": "r1", "generatedAt": 1700000000000, + "model": {"providerId": "zai", "modelId": "glm-5.2", "variant": "v1"}, + "provider": {"providerId": "zai", "kind": "openai-compatible", + "providerOptions": {"baseURL": "https://x", "temperature": 0.7}}, + "thoughtLevel": "standard", + } + resp = self._call(bridge, "session/updateRuntimeModelConfig", + {"sessionId": "sess_x", "runtimeModel": runtime_model, + "applyModelSelection": False}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["params"]["runtimeModel"], runtime_model, + "runtimeModel 必须原样透传") + self.assertEqual(fake.calls[0]["params"]["applyModelSelection"], False) + + def test_m2_update_runtime_model_config_missing(self): + """M2a: 缺 runtimeModel → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/updateRuntimeModelConfig", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32602) + + # ---------- M3: session/cancelBackgroundTask ---------- + def test_m3_cancel_background_task_passthrough(self): + """M3: cancelBackgroundTask 透传 taskId""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/cancelBackgroundTask", + {"sessionId": "sess_x", "taskId": "task_42"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/cancelBackgroundTask") + self.assertEqual(fake.calls[0]["params"]["taskId"], "task_42") + + def test_m3_cancel_background_task_missing(self): + """M3a: 缺 taskId → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/cancelBackgroundTask", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32602) + + # ---------- M4: session/rewindCascade ---------- + def test_m4_rewind_cascade_default_target(self): + """M4: rewindCascade 默认 target = {kind: latestCheckpoint}""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/rewindCascade", {"sessionId": "sess_x"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/rewindCascade") + self.assertEqual(fake.calls[0]["params"]["target"], {"kind": "latestCheckpoint"}) + + def test_m4_rewind_cascade_checkpoint_and_scope(self): + """M4a: rewindCascade 显式 checkpointId → {kind: checkpoint}, 且 scope 透传""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/rewindCascade", + {"sessionId": "sess_x", "checkpointId": "cp1", "scope": "both"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["params"]["target"], + {"kind": "checkpoint", "checkpointId": "cp1"}) + self.assertEqual(fake.calls[0]["params"]["scope"], "both") + + def test_m4_rewind_cascade_differs_from_rewind(self): + """M4b: rewindCascade 与 rewind 调用不同 method 名 (确认 dispatch 不混淆)""" + bridge1, fake1 = self._new_bridge() + bridge2, fake2 = self._new_bridge() + self._call(bridge1, "session/rewindCascade", {"sessionId": "sess_x"}) + self._call(bridge2, "session/rewind", {"sessionId": "sess_x"}) + self.assertEqual(fake1.calls[0]["method"], "session/rewindCascade") + self.assertEqual(fake2.calls[0]["method"], "session/rewind") + + # ---------- M5: session/setModel + session/setMode (补齐) ---------- + def test_m5_set_model_passthrough(self): + """M5: setModel 透传 modelId""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/setModel", + {"sessionId": "sess_x", "modelId": "glm-5.2"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/setModel") + self.assertEqual(fake.calls[0]["params"]["modelId"], "glm-5.2") + + def test_m5_set_model_missing(self): + """M5a: 缺 modelId → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/setModel", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32602) + + def test_m5_set_mode_passthrough(self): + """M5b: setMode 透传 mode""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/setMode", + {"sessionId": "sess_x", "mode": "plan"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/setMode") + self.assertEqual(fake.calls[0]["params"]["mode"], "plan") + + def test_m5_set_mode_missing(self): + """M5c: 缺 mode → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/setMode", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32602) + + # ---------- M6: _resolve_workspace 选择器 ---------- + def test_m6_workspace_from_dict(self): + """M6: params["workspace"] 是合法 dict → 直接透传""" + bridge, fake = self._new_bridge() + ws = {"workspacePath": "/p/a", "workspaceKey": "/p/a", "workspaceIdentity": "/p/a"} + self._call(bridge, "workspace/readState", {"workspace": ws}) + self.assertEqual(fake.calls[0]["params"]["workspace"], ws) + + def test_m6_workspace_from_workspace_path(self): + """M6a: params["workspacePath"] → 构造 {workspacePath, workspaceKey}""" + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/readState", {"workspacePath": "/p/b"}) + self.assertEqual(fake.calls[0]["params"]["workspace"], + {"workspacePath": "/p/b", "workspaceKey": "/p/b"}) + + def test_m6_workspace_from_cwd(self): + """M6b: params["cwd"] → 构造选择器 (与 session/new 一致的 key)""" + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/readState", {"cwd": "/p/c"}) + self.assertEqual(fake.calls[0]["params"]["workspace"], + {"workspacePath": "/p/c", "workspaceKey": "/p/c"}) + + def test_m6_workspace_default_getcwd(self): + """M6c: 无 workspace 相关参数 → 用 os.getcwd() 兜底""" + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/readState", {}) + ws = fake.calls[0]["params"]["workspace"] + self.assertEqual(ws["workspacePath"], os.getcwd()) + self.assertEqual(ws["workspaceKey"], os.getcwd()) + + def test_m6_workspace_dict_without_path_falls_back(self): + """M6d: workspace dict 但缺 workspacePath → 回退到 cwd 解析""" + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/readState", {"workspace": {"foo": "bar"}}) + ws = fake.calls[0]["params"]["workspace"] + self.assertIn("workspacePath", ws, "非法 workspace dict 应回退到路径构造") + + # ---------- M7: workspace/readState + workspace/generateText ---------- + def test_m7_read_state_passthrough(self): + """M7: readState 透传 workspace + 可选 runtimeModel""" + bridge, fake = self._new_bridge() + rm = {"revision": "r1", "model": {"providerId": "zai", "modelId": "glm-5.2"}} + resp = self._call(bridge, "workspace/readState", + {"workspacePath": "/p", "runtimeModel": rm}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "workspace/readState") + self.assertEqual(fake.calls[0]["params"]["runtimeModel"], rm) + + def test_m7_generate_text_passthrough(self): + """M7a: generateText 透传 modelRef/prompt/querySource/maxOutputTokens/temperature""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "workspace/generateText", { + "workspacePath": "/p", + "modelRef": {"providerId": "zai", "modelId": "glm-5.2"}, + "prompt": "say hi", "querySource": "editor", + "maxOutputTokens": 512, "temperature": 0.5, + }) + self._assert_ok(resp) + p = fake.calls[0]["params"] + self.assertEqual(fake.calls[0]["method"], "workspace/generateText") + self.assertEqual(p["modelRef"], {"providerId": "zai", "modelId": "glm-5.2"}) + self.assertEqual(p["prompt"], "say hi") + self.assertEqual(p["querySource"], "editor") + self.assertEqual(p["maxOutputTokens"], 512) + self.assertEqual(p["temperature"], 0.5) + + def test_m7_generate_text_timeout_60(self): + """M7b: generateText timeout=60 (涉及模型调用)""" + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/generateText", { + "workspacePath": "/p", + "modelRef": {"providerId": "zai", "modelId": "glm-5.2"}, "prompt": "x", + }) + self.assertEqual(fake.calls[0]["timeout"], 60) + + def test_m7_generate_text_default_query_source(self): + """M7c: generateText 缺 querySource → 默认 "bridge" """ + bridge, fake = self._new_bridge() + self._call(bridge, "workspace/generateText", { + "workspacePath": "/p", + "modelRef": {"providerId": "zai", "modelId": "glm-5.2"}, "prompt": "x", + }) + self.assertEqual(fake.calls[0]["params"]["querySource"], "bridge") + + def test_m7_generate_text_missing_model_ref(self): + """M7d: 缺 modelRef → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/generateText", + {"workspacePath": "/p", "prompt": "x"}) + self._assert_error_code(resp, -32602) + + def test_m7_generate_text_missing_prompt(self): + """M7e: 缺 prompt → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/generateText", + {"workspacePath": "/p", + "modelRef": {"providerId": "zai", "modelId": "glm-5.2"}}) + self._assert_error_code(resp, -32602) + + # ---------- M8: workspace/setDefault* 三件套 ---------- + def test_m8_set_default_model(self): + """M8: setDefaultModel 透传 model + 乐观锁 expectedWorkspaceRevision""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultModel", { + "workspacePath": "/p", + "model": {"providerId": "zai", "modelId": "glm-5.2"}, + "expectedWorkspaceRevision": 7, + }) + self._assert_ok(resp) + p = fake.calls[0]["params"] + self.assertEqual(fake.calls[0]["method"], "workspace/setDefaultModel") + self.assertEqual(p["model"], {"providerId": "zai", "modelId": "glm-5.2"}) + self.assertEqual(p["expectedWorkspaceRevision"], 7) + + def test_m8_set_default_model_missing(self): + """M8a: 缺 model → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultModel", {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_m8_set_default_mode(self): + """M8b: setDefaultMode 透传 mode + 乐观锁""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultMode", { + "workspacePath": "/p", "mode": "build", "expectedWorkspaceRevision": 3, + }) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "workspace/setDefaultMode") + self.assertEqual(fake.calls[0]["params"]["mode"], "build") + self.assertEqual(fake.calls[0]["params"]["expectedWorkspaceRevision"], 3) + + def test_m8_set_default_mode_missing(self): + """M8c: 缺 mode → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultMode", {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_m8_set_default_thought_level(self): + """M8d: setDefaultThoughtLevel 透传 thoughtLevel""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultThoughtLevel", { + "workspacePath": "/p", "thoughtLevel": "concise", + }) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "workspace/setDefaultThoughtLevel") + self.assertEqual(fake.calls[0]["params"]["thoughtLevel"], "concise") + + def test_m8_set_default_thought_level_missing(self): + """M8e: 缺 thoughtLevel → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/setDefaultThoughtLevel", + {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + # ---------- M9: workspace Provider 管理 (apiKey 透传 + 脱敏) ---------- + def test_m9_upsert_provider_passthrough_with_apikey(self): + """M9: upsertModelProvider 透传含 apiKey 的 provider 对象 (保真)""" + bridge, fake = self._new_bridge() + provider = { + "providerId": "custom", "kind": "openai-compatible", "baseURL": "https://x", + "apiKey": {"source": "inline", "value": "sk-secret-do-not-leak-123"}, + "models": [{"modelId": "m1"}], + } + resp = self._call(bridge, "workspace/upsertModelProvider", + {"workspacePath": "/p", "provider": provider}) + self._assert_ok(resp) + # 透传必须保真: provider 整体原样到达 backend + self.assertEqual(fake.calls[0]["params"]["provider"], provider) + self.assertEqual(fake.calls[0]["method"], "workspace/upsertModelProvider") + + def test_m9_upsert_provider_missing(self): + """M9a: 缺 provider → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/upsertModelProvider", + {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_m9_upsert_provider_log_no_apikey(self): + """M9b: upsertModelProvider 的 log 不得包含 apiKey 明文 (脱敏验证)""" + bridge, fake = self._new_bridge() + # 捕获 stderr 日志 (模块级 log() 写 sys.stderr) + captured = io.StringIO() + provider = { + "providerId": "custom", + "apiKey": {"source": "inline", "value": "sk-LEAK-MARKER-xyz"}, + "models": [{"modelId": "m1"}], + } + with contextlib.redirect_stderr(captured): + bridge2 = self.Bridge() + bridge2.backend = FakeBackend() + bridge2.handle_acp({"jsonrpc": "2.0", "id": 1, + "method": "workspace/upsertModelProvider", + "params": {"workspacePath": "/p", "provider": provider}}) + log_out = captured.getvalue() + self.assertNotIn("sk-LEAK-MARKER-xyz", log_out, + "log 不得泄露 apiKey 明文") + self.assertIn("custom", log_out, "log 应打 providerId 便于排查") + + def test_m9_remove_provider(self): + """M9c: removeModelProvider 透传 providerId""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "workspace/removeModelProvider", + {"workspacePath": "/p", "providerId": "old"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "workspace/removeModelProvider") + self.assertEqual(fake.calls[0]["params"]["providerId"], "old") + + def test_m9_remove_provider_missing(self): + """M9d: 缺 providerId → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/removeModelProvider", + {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_m9_update_registry_passthrough_with_apikey(self): + """M9e: updateProviderRegistry 透传含 apiKey 的 providers (保真), 且 log 只打数量""" + bridge, fake = self._new_bridge() + registry = { + "revision": "reg1", "generatedAt": 1700000000000, + "providers": [ + {"providerId": "p1", "models": [{"modelId": "m1"}]}, + {"providerId": "p2", "apiKey": {"source": "inline", "value": "sk-reg-LEAK"}, + "models": [{"modelId": "m2"}]}, + ], + } + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + resp = self._call(bridge, "workspace/updateProviderRegistry", + {"workspacePath": "/p", "registry": registry}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["params"]["registry"], registry, "registry 须原样透传") + log_out = captured.getvalue() + self.assertNotIn("sk-reg-LEAK", log_out, "registry log 不得泄露 apiKey") + + def test_m9_update_registry_missing(self): + """M9f: 缺 registry → -32602""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "workspace/updateProviderRegistry", + {"workspacePath": "/p"}) + self._assert_error_code(resp, -32602) + + def test_m9_upsert_provider_error_no_apikey(self): + """M9g: upsertModelProvider 后端 error 回显脱敏 (Codex P1: error 路径不能泄露 apiKey)""" + bridge, fake = self._new_bridge() + # 后端错误消息里夹带了入参的 inline apiKey 明文 + fake.next_response = {"error": {"message": + "validation failed: apiKey invalid sk-LEAK-IN-ERR-9876 in provider custom"}} + resp = self._call(bridge, "workspace/upsertModelProvider", + {"workspacePath": "/p", + "provider": {"providerId": "custom", + "apiKey": {"source": "inline", + "value": "sk-LEAK-IN-ERR-9876"}, + "models": [{"modelId": "m1"}]}}) + self._assert_error_code(resp, -32603) + err_msg = resp["error"]["message"] + self.assertNotIn("sk-LEAK-IN-ERR-9876", err_msg, + "error 回显不得泄露 apiKey 明文") + self.assertIn("sk-***", err_msg, "sk- 前缀应被遮蔽为 sk-***") + + def test_m9_update_registry_error_no_apikey(self): + """M9h: updateProviderRegistry 后端 error 回显脱敏 (registry 含多个 provider apiKey)""" + bridge, fake = self._new_bridge() + fake.next_response = {"error": {"message": + 'provider[1] invalid: {"value":"sk-REG-ERR-5555"} rejected'}} + resp = self._call(bridge, "workspace/updateProviderRegistry", + {"workspacePath": "/p", + "registry": {"providers": [ + {"providerId": "p1", "models": [{"modelId": "m1"}]}, + {"providerId": "p2", + "apiKey": {"source": "inline", "value": "sk-REG-ERR-5555"}, + "models": [{"modelId": "m2"}]}]}}) + self._assert_error_code(resp, -32603) + err_msg = resp["error"]["message"] + self.assertNotIn("sk-REG-ERR-5555", err_msg, + "registry error 回显不得泄露 apiKey 明文") + + def test_m9_remove_provider_error_not_redacted(self): + """M9i: removeModelProvider error 不需脱敏 (入参无 apiKey, 保留原文利于排查)""" + bridge, fake = self._new_bridge() + fake.next_response = {"error": {"message": "provider old not found"}} + resp = self._call(bridge, "workspace/removeModelProvider", + {"workspacePath": "/p", "providerId": "old"}) + self._assert_error_code(resp, -32603) + # remove 入参不含 apiKey, 后端错误原样回显 (便于排查) + self.assertIn("not found", resp["error"]["message"]) + + # ---------- M10: 旧 handler 回归 (补盲区) ---------- + def test_m10_fork_routes(self): + """M10: session/fork 仍正确路由到 session/fork""" + bridge, fake = self._new_bridge() + resp = self._call(bridge, "session/fork", {"sessionId": "sess_x"}) + self._assert_ok(resp) + self.assertEqual(fake.calls[0]["method"], "session/fork") + + def test_m10_rewind_routes(self): + """M10a: session/rewind 路由""" + bridge, fake = self._new_bridge() + self._call(bridge, "session/rewind", {"sessionId": "sess_x"}) + self.assertEqual(fake.calls[0]["method"], "session/rewind") + + def test_m10_goal_show_routes(self): + """M10b: session/goal show 路由 (不触发 turn 等待)""" + bridge, fake = self._new_bridge() + self._call(bridge, "session/goal", {"sessionId": "sess_x", "action": "show"}) + self.assertEqual(fake.calls[0]["method"], "session/goal") + self.assertEqual(fake.calls[0]["params"]["action"], "show") + + def test_m10_compact_routes(self): + """M10c: session/compact 路由""" + bridge, fake = self._new_bridge() + self._call(bridge, "session/compact", {"sessionId": "sess_x"}) + self.assertEqual(fake.calls[0]["method"], "session/compact") + + def test_m10_steer_routes(self): + """M10d: session/steer 路由 + content 透传""" + bridge, fake = self._new_bridge() + self._call(bridge, "session/steer", {"sessionId": "sess_x", "content": "hi"}) + self.assertEqual(fake.calls[0]["method"], "session/steer") + self.assertEqual(fake.calls[0]["params"]["content"], "hi") + + def test_m10_unknown_method_32601(self): + """M10e: 未知方法 → -32601 (dispatch 兜底未受新方法影响)""" + bridge, _ = self._new_bridge() + resp = self._call(bridge, "session/nonexistent", {"sessionId": "sess_x"}) + self._assert_error_code(resp, -32601) + + def test_m10_dispatch_registry_complete(self): + """M10f: 所有 14 个新方法名都已在 dispatch 注册 (无遗漏)""" + bridge, _ = self._new_bridge() + new_methods = [ + "session/setThoughtLevel", "session/updateRuntimeModelConfig", + "session/cancelBackgroundTask", "session/rewindCascade", + "session/setModel", "session/setMode", + "workspace/readState", "workspace/generateText", + "workspace/setDefaultModel", "workspace/setDefaultMode", + "workspace/setDefaultThoughtLevel", + "workspace/upsertModelProvider", "workspace/removeModelProvider", + "workspace/updateProviderRegistry", + ] + for m in new_methods: + resp = self._call(bridge, m, {"sessionId": "sess_x", "workspacePath": "/p", + "thoughtLevel": "x", "modelId": "m", + "model": {"modelId": "m"}, "mode": "yolo", + "taskId": "t", "provider": {"models": [{"modelId": "m"}]}, + "providerId": "p", "registry": {"providers": []}, + "prompt": "x", "modelRef": {"modelId": "m"}}) + # 关键: 不能是 -32601 (未注册)。各方法要么成功, 要么因缺参报 -32602, + # 但绝不应该是 "Method not supported" + if "error" in resp: + self.assertNotEqual(resp["error"]["code"], -32601, + f"{m} 未注册到 dispatch (返回 -32601)") + + +if __name__ == "__main__": + unittest.main(verbosity=2)