From 7855e19ab61358f751f4bf414eb22fd45b1dbee1 Mon Sep 17 00:00:00 2001 From: jason <991262382@qq.com> Date: Sun, 20 Sep 2026 13:56:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(acp-bridge):=20session/new=20=E5=8F=AF?= =?UTF-8?q?=E9=80=89=20model=20=E9=94=81=E5=AE=9A=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20+=20setModel=20=E5=AF=B9=E8=B1=A1=E5=BD=A2?= =?UTF-8?q?=E6=80=81=E4=BF=AE=E5=A4=8D=20(0.16=20=E5=AE=9E=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 (0.16.1-0.16.9 实测): 1. setModel 透传形态与 0.16 后端 schema 不匹配: 桥发 {sessionId, modelId} 字符串, 后端要 model 对象 → 必报 -32602 "expected object, received undefined; Unrecognized key: modelId"。即 0.16 时代 setModel 从未真正 可用, ACP client 无法切换会话模型。 2. headless 场景模型解析跟随 personal provider registry / App「上次使用」, client 无法按会话指定模型: App 里选了什么, spawn 出的引擎就默认用什么 (provider 目录不含 config.json 静态 provider, env 三件套在 personal registry 存在时被无视)。 修复/新增: - setModel: 改为 {sessionId, model} 原样透传 ModelSelection 对象 {providerId, modelId, options?: {reasoningLevel}}。旧 modelId 形态移除 (在 0.16 后端从未通过 schema, 保留只会让调用方误以为已生效)。 - session/new 新增可选 model: * 字符串形态 ("GLM-5.3"): 按 create 快照 settings.model.available 的 catalog 解析, 补 reasoningLevel=reasoning.defaultLevel; * 对象形态: 原样透传; * create 成功后经 session/setModel 应用, 失败则 session/new 整体报错。 刻意不透传给 create 本身: 0.16.9 实测 create 的初始 model 形参接受对象 但丢 options, turn 阶段报 ModelProtocolError "Reasoning level is required for /"; setModel 路径无此问题。 实测 (ZCode App 3.14.0 / CLI 0.16.9, personal provider bigmodel-api): - session/new {model: "GLM-5.3"} → session/prompt → stopReason=end_turn; - setModel {model: {providerId, modelId, options:{reasoningLevel:max}}} → ok → prompt → end_turn; - setModel 缺 options (无 reasoning 的模型除外) → turn 失败, 与 catalog defaultLevel 补齐后通过。 测试: M5 改写 (对象透传/缺参 -32602/旧形态拒绝) + C4 系列 7 例 (字符串 catalog 解析/无 reasoning 条目/对象透传/未知 modelId/setModel 失败连带 new 失败/不带 model 向后兼容/非法形态)。547 passed + 34 subtests。 --- README.md | 4 +- packages/acp-bridge/zcode-acp-bridge | 89 ++++++++++++++++++-- skills/zcode-bridge-guide/SKILL.md | 2 +- tests/test_app_server_methods.py | 121 +++++++++++++++++++++++++-- 4 files changed, 199 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d7e2c8d..cb526b5 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,11 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用 | `session/updateRuntimeModelConfig` ❌ | 运行时覆盖会话模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}`(0.16 起 `runtimeModel.revision` 必填) | | `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | | `session/rewindCascade` ❌ | 级联回退(与 rewind 同 schema,**0.16 已移除**) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | -| `session/setModel` | 切换会话模型 | 0.14.8 | `{sessionId, modelId}` | +| `session/setModel` | 切换会话模型 | 0.14.8 | `{sessionId, model}` — model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`(0.16 后端 schema 要对象;旧 `modelId` 字符串形态从未通过 0.16 schema,已移除)。缺 `reasoningLevel` 时部分模型 turn 阶段报 `ModelProtocolError: Reasoning level is required` | | `session/setMode` | 切换会话权限模式 | 0.14.8 | `{sessionId, mode}` | +> **session/new 可选 `model` 参数**:`{cwd, mode?, model?}` —— model 可传 catalog 里的 modelId 字符串(如 `"GLM-5.3"`,按 create 快照解析成完整 ModelSelection 并补默认 reasoningLevel)或完整对象;create 成功后桥内部经 `session/setModel` 应用,失败则 session/new 整体报错。会话创建即锁定模型,不依赖 App 侧「上次使用」默认(headless 场景刚需)。不透传给 create 本身:0.16.9 实测 create 的初始 model 形参丢 options。 + > ❌ **0.16 已移除**:`session/steer`、`session/rewind`、`session/rewindCascade` 已从 app-server 删除。steer 语义并入 `session/send`(turn 进行中发送即 steer);rewind 无协议替代,仅剩 slash 命令 `/rewind` 与 `rewind.triggered` 事件。0.16.1 上调用这些方法会收到 `-32601`。 > > ℹ️ **0.16 schema 变更**:`session/updateRuntimeModelConfig` 在 0.16.1 仍存活(实测),但 schema 新要求 `runtimeModel.revision`(string)必填。App 3.12.3 的同号 0.16.5 构建已删除该方法(2026-09-17 实测后端返 -32601;0.16.9/App 3.14.0 于 2026-09-19 复测仍删),桥透传时降级为「已移除」文案。 diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index fd6f124..87b97b7 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -58,7 +58,9 @@ CLI 0.16.1 适配 (依据 docs/upgrade-0.16.1-spec.md, 全部经实测复核): 能力范围: ✅ initialize 握手 - ✅ session/new → session/create + ✅ session/new → session/create (可选 model: "GLM-5.3" 字符串按 create 快照 + catalog 解析并补默认 reasoningLevel, 或完整 ModelSelection 对象; 经 + session/setModel 应用, 会话创建即锁定模型 — 0.16.9 实测) ✅ session/prompt → 事件驱动真流式 (0.14.8+) / 轮询伪流式 (旧协议降级) ✅ 流式文本 (model.streaming → agent_message_chunk 逐段) ✅ 工具调用展示 (tool.updated → tool_call/tool_call_update 实时) @@ -69,6 +71,8 @@ CLI 0.16.1 适配 (依据 docs/upgrade-0.16.1-spec.md, 全部经实测复核): cancelBackgroundTask/rewindCascade/setModel/setMode (rewindCascade 于 0.16 移除; updateRuntimeModelConfig 于 App 3.12.3 的 同号 0.16.5 构建移除 — CLI 版本号未变而构建内容漂移, 同号不可靠) + setModel 于 0.16 后端要 model 对象 {providerId, modelId, options?}, + 旧 modelId 字符串形态在 0.16 后端从未通过 schema (实测 -32602) ✅ workspace/* 方法 (0.15.0+): readState/generateText/setDefault{Model,Mode,ThoughtLevel}/ upsertModelProvider/removeModelProvider/updateProviderRegistry (App 3.12.3 的 0.16.5 构建删 7/8, 仅 generateText 存活, 见 @@ -1319,7 +1323,20 @@ class ACPBridge: } def _on_session_new(self, msg_id, params): - """ACP session/new → zcode session/create""" + """ACP session/new → zcode session/create + + params: {cwd?, mode?, model?} + model 可选, 两种形态: + - 字符串: catalog 里的 modelId (如 "GLM-5.3")。用 create 快照的 + settings.model.available 解析成完整 ModelSelection, 并补 + reasoningLevel=catalog 默认值。 + - dict: 完整 ModelSelection 对象, 原样透传。 + 提供时 create 成功后内部调 session/setModel 应用; 失败则 session/new + 整体报错 (客户端明确知道模型没生效)。不把 model 透传给 create 本身: + 0.16.9 实测 create 的初始 model 形参接受对象但丢 options, turn 阶段 + 报 "Reasoning level is required" (ModelProtocolError); setModel + 路径无此问题。 + """ self.ensure_backend() cwd = params.get("cwd") or os.getcwd() # 显式 mode 透传; 缺省用 DEFAULT_ACP_MODE (默认 yolo, 可用环境变量 @@ -1353,10 +1370,54 @@ class ACPBridge: # ACP sessionId: 用 zcode 的或生成一个 acp_sid = zcode_sid # 格式兼容, 直接用 self.session_map[acp_sid] = zcode_sid + + # 可选 model: 归一后经 setModel 应用 (见 docstring 为何不走 create 形参) + model_param = params.get("model") + if model_param is not None: + selection = self._resolve_session_model(model_param, result) + if isinstance(selection, str): + return self._error(msg_id, -32602, f"session/new model 参数无效: {selection}") + zc_id = self._next_id() + resp, _ = self.backend.request(zc_id, "session/setModel", + {"sessionId": zcode_sid, "model": selection}, + timeout=15) + if "error" in resp: + return self._passthrough_error(msg_id, "session/setModel", resp) + log(f" session/new model → {selection.get('providerId', '?')}/" + f"{selection.get('modelId', '?')}") + log(f"session/new → {acp_sid}") return {"jsonrpc": "2.0", "id": msg_id, "result": {"sessionId": acp_sid}} + @staticmethod + def _resolve_session_model(model_param, create_result): + """把 session/new 的 model 参数归一成 setModel 的 ModelSelection 对象。 + + dict (非空) → 原样返回; 字符串 → 在 create 快照 settings.model.available + 里按 modelId 精确匹配 (多 provider 同名取第一个; 需要指定 provider 的 + 调用方应直接传对象), 并补 options.reasoningLevel=reasoning.defaultLevel + (0.16.9 实测部分模型缺它会 ModelProtocolError)。无法解析时返回错误 + 字符串 (调用方转 -32602)。 + """ + if isinstance(model_param, dict): + return model_param if model_param else "model 对象为空" + if not isinstance(model_param, str) or not model_param.strip(): + return "model 需为非空字符串或 ModelSelection 对象" + wanted = model_param.strip() + available = (((create_result.get("settings") or {}).get("model") or {}) + .get("available")) or [] + for entry in available: + ref = entry.get("ref") or {} + if ref.get("modelId") == wanted: + selection = {"providerId": ref.get("providerId"), + "modelId": ref.get("modelId")} + default_level = (entry.get("reasoning") or {}).get("defaultLevel") + if default_level: + selection["options"] = {"reasoningLevel": default_level} + return selection + return f"modelId '{wanted}' 不在本会话的模型目录中" + def _on_session_list(self, msg_id, params): """ACP session/list → zcode session/list, 转成 ACP SessionInfo[]""" self.ensure_backend() @@ -1699,22 +1760,32 @@ class ACPBridge: def _on_session_set_model(self, msg_id, params): """扩展 session/setModel → zcode session/setModel: 切换会话模型。 - (0.14.8 旧方法, bridge 之前未暴露, 本次补齐) - params: {sessionId, modelId} + params: {sessionId, model} + model 是 0.16 后端的 ModelSelection 对象, 原样透传: + {providerId, modelId, options?: {reasoningLevel}}。 + 0.16.9 实测: 后端 schema 要 model 对象 (旧 modelId 字符串形态必报 + -32602 "expected object, received undefined / Unrecognized key"; + 故本参数形态是破坏性修正, 旧形态在 0.16 后端上从未成功过); 缺 + options.reasoningLevel 时部分模型在 turn 阶段报 ModelProtocolError + "Reasoning level is required for /" — 客户端不 + 确定时应改用 session/new 的 model 字符串形态 (按 create 快照的 + catalog 默认 reasoningLevel 解析, 见 _resolve_session_model)。 """ 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} + model = params.get("model") + if not isinstance(model, dict) or not model: + return self._error(msg_id, -32602, + "setModel 需要 model 对象 " + "({providerId, modelId, options?: {reasoningLevel}})") + zc_params = {"sessionId": zcode_sid, "model": model} zc_id = self._next_id() resp, _ = self.backend.request(zc_id, "session/setModel", zc_params, timeout=15) if "error" in resp: return self._passthrough_error(msg_id, "session/setModel", resp) result = resp.get("result", {}) - log(f"session/setModel → {model_id}") + log(f"session/setModel → {model.get('providerId', '?')}/{model.get('modelId', '?')}") return {"jsonrpc": "2.0", "id": msg_id, "result": result} def _on_session_set_mode(self, msg_id, params): diff --git a/skills/zcode-bridge-guide/SKILL.md b/skills/zcode-bridge-guide/SKILL.md index bb2d933..a266acc 100644 --- a/skills/zcode-bridge-guide/SKILL.md +++ b/skills/zcode-bridge-guide/SKILL.md @@ -259,7 +259,7 @@ ACP bridge 暴露的 ZCode 新版协议方法,按定位维度分组。**sessio | `session/compact` | 压缩对话上下文 | 0.14.8 | `{sessionId}` | | `session/steer` ❌ | turn 进行中追加指令(**0.16 已移除**,语义并入 `session/send`) | 0.14.8 | `{sessionId, content}` | | `session/setThoughtLevel` | ⭐ 设置思考强度 | 0.15.0 | `{sessionId, thoughtLevel}` | -| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, modelId}` / `{sessionId, mode}` | +| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, model}` / `{sessionId, mode}`(model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`;0.16 后端 schema 要对象,旧 `modelId` 字符串必 -32602。另 `session/new` 支持可选 `model`(字符串按 catalog 解析并补默认 reasoningLevel,或完整对象),创建会话即锁定模型) | | `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | | `session/rewindCascade` ❌ | 级联回退(同 rewind schema,**0.16 已移除**) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | | `session/updateRuntimeModelConfig` ❌ | 运行时覆盖模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}`(0.16 起 `runtimeModel.revision` 必填) | diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index 0ab4f40..6d14cef 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -323,6 +323,105 @@ def test_c3_create_mode_passthrough(self): self.assertEqual(len(create), 1) self.assertEqual(create[0]["params"].get("mode"), "plan") + # ---------- C4+: session/new 可选 model (0.16.9 实测) ---------- + # create 快照的 catalog 形态 (0.16.9 实测 settings.model.available): + _CATALOG = { + "sessionId": "sess_m", + "settings": {"model": {"available": [ + {"ref": {"providerId": "bigmodel-api", "modelId": "GLM-5.3"}, + "label": "GLM-5.3", "providerLabel": "bigmodel-api", + "reasoning": {"levels": [{"value": "low", "label": "low"}, + {"value": "max", "label": "max"}], + "defaultLevel": "max"}}, + {"ref": {"providerId": "bigmodel-api", "modelId": "GLM-5.3-Flash"}, + "label": "GLM-5.3-Flash", "providerLabel": "bigmodel-api"}, + ]}}, + } + + def test_c4_new_model_string_resolves_catalog(self): + """C4: model 字符串 → 按 create 快照 catalog 解析成 ModelSelection 并补 + reasoningLevel=defaultLevel, 经 session/setModel 应用 (0.16.9 实测: + 缺 reasoningLevel 的模型 turn 阶段报 ModelProtocolError; create 的 + 初始 model 形参丢 options, 故必须走 setModel)""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_ok(resp) + set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(len(set_calls), 1, "model 应恰好触发一次 setModel") + self.assertEqual(set_calls[0]["params"]["model"], + {"providerId": "bigmodel-api", "modelId": "GLM-5.3", + "options": {"reasoningLevel": "max"}}, + "应补 catalog 默认 reasoningLevel") + self.assertNotIn("model", [c for c in fake.calls + if c["method"] == "session/create"][0]["params"], + "model 不得透传给 create (create 形参丢 options, 0.16.9 实测)") + + def test_c4a_new_model_string_no_reasoning_entry(self): + """C4a: catalog 条目无 reasoning (如 Flash) → 只透 providerId/modelId""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3-Flash"}) + self._assert_ok(resp) + set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(set_calls[0]["params"]["model"], + {"providerId": "bigmodel-api", "modelId": "GLM-5.3-Flash"}) + + def test_c4b_new_model_object_passthrough(self): + """C4b: model 对象 → 原样透传给 setModel (调用方自己指定 provider/level)""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + ref = {"providerId": "other", "modelId": "m1", + "options": {"reasoningLevel": "low"}} + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": ref}) + self._assert_ok(resp) + set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(set_calls[0]["params"]["model"], ref) + + def test_c4c_new_model_unknown_string(self): + """C4c: model 字符串不在 catalog → -32602, 不发 setModel""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "nope"}) + self._assert_error_code(resp, -32602) + self.assertEqual([c for c in fake.calls + if c["method"] == "session/setModel"], [], + "解析失败不得调 setModel") + + def test_c4d_new_set_model_failure_fails_new(self): + """C4d: setModel 失败 → session/new 整体报错 (客户端明确知道模型没生效)""" + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + "session/setModel": {"response": {"error": { + "code": -32603, "message": "ModelProtocolError: boom"}}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_error_code(resp, -32603) + + def test_c4e_new_without_model_no_set_model(self): + """C4e: 不带 model → 行为与旧版一致, 不触发 setModel (向后兼容)""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "sess_plain"}}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p"}) + self._assert_ok(resp) + self.assertEqual([c for c in fake.calls + if c["method"] == "session/setModel"], []) + + def test_c4f_new_model_invalid_types(self): + """C4f: model 空对象/空串/数字 → -32602""" + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + for bad in ({}, "", 42): + self._assert_error_code( + self._call(bridge, "session/new", {"cwd": "/p", "model": bad}), + -32602, msg=f"model={bad!r}") + # ---------- EV: 事件/轮询模式选择 · 事件分支 ---------- def test_ev1_event_branch_subscribe_deliverykind(self): """EV1: subscribe 带 deliveryKind 成功 → 事件模式 (不触发轮询); 轮询分支见 PF3""" @@ -884,19 +983,29 @@ def test_m3_cancel_background_task_missing(self): self._assert_error_code(resp, -32602) def test_m5_set_model_passthrough(self): - """M5: setModel 透传 modelId""" + """M5: setModel 透传 model 对象 (0.16 后端 schema 要 ModelSelection)""" bridge, fake = self._new_bridge() + ref = {"providerId": "bigmodel-api", "modelId": "GLM-5.3", + "options": {"reasoningLevel": "max"}} resp = self._call(bridge, "session/setModel", - {"sessionId": "sess_x", "modelId": "glm-5.2"}) + {"sessionId": "sess_x", "model": ref}) self._assert_ok(resp) self.assertEqual(fake.calls[0]["method"], "session/setModel") - self.assertEqual(fake.calls[0]["params"]["modelId"], "glm-5.2") + self.assertEqual(fake.calls[0]["params"]["model"], ref, + "model 对象应原样透传 (0.16.9 实测形态)") def test_m5_set_model_missing(self): - """M5a: 缺 modelId → -32602""" + """M5a: 缺 model (或非对象) → -32602 (旧 modelId 字符串形态已移除: + 0.16 后端 schema 必拒, 留着只会让调用方误以为已生效)""" bridge, _ = self._new_bridge() - resp = self._call(bridge, "session/setModel", {"sessionId": "sess_x"}) - self._assert_error_code(resp, -32602) + self._assert_error_code(self._call(bridge, "session/setModel", + {"sessionId": "sess_x"}), -32602) + self._assert_error_code(self._call(bridge, "session/setModel", + {"sessionId": "sess_x", "modelId": "GLM-5.3"}), + -32602) + self._assert_error_code(self._call(bridge, "session/setModel", + {"sessionId": "sess_x", "model": {}}), + -32602) def test_m5_set_mode_passthrough(self): """M5b: setMode 透传 mode""" From a28f1b54d638b23aa8dcdc15a45cd6e6c7dcc4d9 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:25:28 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20R1=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E5=AD=A4=E5=84=BF=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C/=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=B8=A6=20sessionId=20+=20=E6=96=87=E6=A1=A3=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E5=90=8C=E6=AD=A5=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 jasonQin6 PR #52 基础上按 R1 审查清单修复, 保持原方案 (setModel 对象 形态 + session/new 可选 model) 不变, 只修审查指出的问题。 A (P1 孤儿会话): - model 形状校验前移到 session/create 之前 — 非法形态本地 -32602 且不 触碰后端, 不再先真实建会话再报错; - create 后失败 (catalog 未命中/不可用、setModel 失败、setModel 超时) 的错误文案带已创建的 sessionId (「已创建会话 但 model 未生效: <原因>」) 供客户端收编; 后端无会话删除方法, 不尝试回收; - session_map 注册后移到 model 应用成功之后, 失败会话不登记、不能被 session/prompt 使用。消费方确认: session/prompt/session/cancel 用 .get(acp_sid) 严格取, 扩展 handler 用 .get(acp_sid) or acp_sid 兜底; acp_sid 恒等于 zcode_sid 且登记先于响应返回, 移位对成功路径无行为差异; - _on_session_new docstring 同步失败语义。 B (P3): - 对象形态 providerId/modelId 须为非空字符串 (本地 -32602, 消除透传后端 -32602 被 _passthrough_error 改写成 -32603 的不一致); - catalog 条目/ref/reasoning 加 isinstance 守卫 (畸形条目不抛 AttributeError); - 「catalog 不可用」与「未命中」文案区分; - _passthrough_error 加 prefix 参数 + docstring 注记 session/new 嵌套 setModel 是核心 handler 走本 helper 的例外场景; - _on_session_set_model docstring 改写 (需要按 catalog 解析的调用方应在 session/new 指定; 本方法要求调用方提供完整对象)。 C (文档): - README 版本兼容性加注: setModel 对象形态仅 0.16.x 验证 (0.16.9 实测), ≤0.15 未验证; - README/SKILL 的 session/new 说明: 「补默认 reasoningLevel」条件化为 「catalog 条目带 reasoning.defaultLevel 时补」, 补 model:null 语义; - agent-help key_methods: setModel 参数改对象形态 + 0.16 注记; - docs/recheck-3.14.0.md 追加补遗 (旧形态 -32602 原文/新对象形态实测/ create 快照 catalog 形态/空参探存活的方法论教训)。 D (测试 547→551): - C4/C4a/C4b 补 sessionId 断言; C4d 补 setModel 文案 + 超时分支 + 失败 不登记; C4e 锁定 create 参数逐字段不变; - 新增 C4g (嵌套 setModel -32601 → 「已移除 (session/setModel)」文案)、 C4h (对象缺字段 → 本地 -32602 且不发起后端调用)、C4i (catalog 不可用 文案)、C4j (畸形 catalog 条目不抛); - C4c/C4f 补「不发起 create」「不登记 session_map」断言。 验证: python3 -m unittest discover -s tests → 551 passed; ruff check 干净。 --- README.md | 4 +- docs/recheck-3.14.0.md | 7 ++ packages/acp-bridge/zcode-acp-bridge | 131 ++++++++++++++++++++------- packages/agent-help/zcode-agent-help | 5 +- skills/zcode-bridge-guide/SKILL.md | 2 +- tests/test_app_server_methods.py | 127 +++++++++++++++++++++++--- 6 files changed, 226 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index cb526b5..bc2f72a 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用 | `session/setModel` | 切换会话模型 | 0.14.8 | `{sessionId, model}` — model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`(0.16 后端 schema 要对象;旧 `modelId` 字符串形态从未通过 0.16 schema,已移除)。缺 `reasoningLevel` 时部分模型 turn 阶段报 `ModelProtocolError: Reasoning level is required` | | `session/setMode` | 切换会话权限模式 | 0.14.8 | `{sessionId, mode}` | -> **session/new 可选 `model` 参数**:`{cwd, mode?, model?}` —— model 可传 catalog 里的 modelId 字符串(如 `"GLM-5.3"`,按 create 快照解析成完整 ModelSelection 并补默认 reasoningLevel)或完整对象;create 成功后桥内部经 `session/setModel` 应用,失败则 session/new 整体报错。会话创建即锁定模型,不依赖 App 侧「上次使用」默认(headless 场景刚需)。不透传给 create 本身:0.16.9 实测 create 的初始 model 形参丢 options。 +> **session/new 可选 `model` 参数**:`{cwd, mode?, model?}` —— model 可传 catalog 里的 modelId 字符串(如 `"GLM-5.3"`,按 create 快照解析成完整 ModelSelection;catalog 条目带 `reasoning.defaultLevel` 时补默认 reasoningLevel)或完整对象(`{providerId, modelId, options?}`,两者须为非空字符串;`model: null` 或省略等同未提供)。create 成功后桥内部经 `session/setModel` 应用,失败则 session/new 整体报错(文案带已创建的 sessionId,且该会话不登记、不可经 session/prompt 使用)。会话创建即锁定模型,不依赖 App 侧「上次使用」默认(headless 场景刚需)。不透传给 create 本身:0.16.9 实测 create 的初始 model 形参丢 options。 > ❌ **0.16 已移除**:`session/steer`、`session/rewind`、`session/rewindCascade` 已从 app-server 删除。steer 语义并入 `session/send`(turn 进行中发送即 steer);rewind 无协议替代,仅剩 slash 命令 `/rewind` 与 `rewind.triggered` 事件。0.16.1 上调用这些方法会收到 `-32601`。 > @@ -326,6 +326,8 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D | **< 0.14.5** | ⚠️ 未测 | — | — | > 注:CLI 版本号相同不代表协议面相同——`prompt/enhance` 是 App 3.3.0 引入的协议方法(CLI 同为 0.15.0,仅 App 3.3.0+ 的 app-server 支持),又于 0.16 整体移除,仅 0.15.0 + App ≥ 3.3.0 的组合可用。0.16.1(App 3.6.5)协议面大改——真正断点是反向调用必须应答、事件模型调整、删除 steer/rewind/enhance(信封去 `jsonrpc`/方法 rename/`deliveryKind` 必填同为协议事实,但桥对内本就用这套调用面),详见 [docs/upgrade-0.16.1-spec.md](docs/upgrade-0.16.1-spec.md)(含勘误)。0.16.5 已于 2026-09-01 全链路复测(协议面兼容、桥无需代码改动),详见 [docs/recheck-0.16.5.md](docs/recheck-0.16.5.md)。App 3.12.3 的内嵌 CLI `--version` 仍为 0.16.5 但**构建内容漂移**(同号删了 workspace/* 7/8 等,`--version` 不再是唯一兼容性判据),已于 2026-09-17 复测,详见 [docs/recheck-3.12.3.md](docs/recheck-3.12.3.md)。0.16.9(App 3.14.0)已于 2026-09-19 复测:协议面与 3.12.3 的 0.16.5 构建完全一致、桥零改动;变化仅在 CLI 旗标面(`--allowed-tools`/`--max-turns` 连帮助文案一并消失,`--permission-mode`/`--allow-main-worktree-yolo` 移除,新增 `--cwd`/`--target-replace`/`--browser-use` 等),详见 [docs/recheck-3.14.0.md](docs/recheck-3.14.0.md)。 +> +> ⚠️ **`session/setModel` 对象形态的适用范围**:仅在 0.16.x 验证(0.16.9 实测可用);≤0.15 后端未验证该形态(历史 `{modelId}` 字符串形态在 0.16 schema 下必拒,故桥已切换为对象形态——这是破坏性变更,旧版调用方需注意)。 **降级行为**: - 轮询降级**仅限 legacy(< 0.16)协议模式**:旧版下 `session/subscribe` 不可用时自动切换到轮询 `session/read`(伪流式)。**0.16+ 不再自动降级**——新协议模式下 subscribe 失败直接报错 `-32603`("0.16+ 必须走事件订阅;轮询降级仅限旧协议模式")。 diff --git a/docs/recheck-3.14.0.md b/docs/recheck-3.14.0.md index c071e23..f13aa44 100644 --- a/docs/recheck-3.14.0.md +++ b/docs/recheck-3.14.0.md @@ -79,3 +79,10 @@ initialize → session/new → session/prompt 流式完成(`stopReason=end_tur - **方法存在性**:空 params 发一次——`-32602`(参数校验)即存活,`-32601` 即已删。 - **headless**:`zcode --prompt "…" --mode yolo --no-color --json`。 - **桥端到端**:`ZCODE_BIN` 指向官方构建后按 README 的 ACP bridge 用法跑 initialize → session/new → session/prompt。 + +## 补遗(2026-10-03,PR #52 合并随附) + +- **旧 `{sessionId, modelId}` 形态实测原文**:0.16.9 下 `session/setModel` 发旧形态 → `-32602 "expected object, received undefined; Unrecognized key: modelId"`(后端 schema 要 `model` 对象)——0.16 起该方法从未真正可用。 +- **新对象形态实测可用**:`{sessionId, model: {providerId, modelId, options?:{reasoningLevel}}}` → ok;补 create 快照 catalog 默认 reasoningLevel 后 turn 通过(缺 options 的部分模型 turn 报 `ModelProtocolError: Reasoning level is required`)。 +- **create 快照 catalog 形态**:`result.settings.model.available[]`,条目形如 `{ref: {providerId, modelId}, label, providerLabel, reasoning?: {levels: [{value, label}], defaultLevel}}`。 +- **方法论教训**:§5 的「空 params 返 -32602 即存活」只验存活性、不验参数形态——setModel 的 schema 不匹配正是被它掩盖(存活却 0.16 起从未通过)。参数级兼容性须按真实 payload 实测。由外部贡献者 jasonQin6 在 PR #52 发现并修复。 diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index 87b97b7..1879a3f 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -1172,7 +1172,7 @@ class ACPBridge: except Exception: return False - def _passthrough_error(self, msg_id, zcode_method, resp, redact=False): + def _passthrough_error(self, msg_id, zcode_method, resp, redact=False, prefix=""): """透传/扩展方法后端错误的统一出口 (原 _removed_method_error 的泛化, 规格书 §7)。 判定只看错误码, 不再依赖硬编码方法清单 (原 _REMOVED_IN_016, zcode review @@ -1191,6 +1191,11 @@ class ACPBridge: redact=True (provider/registry 族): 后端原文经 _redact_secret 脱敏, 防 apiKey 随错误回显泄漏 (整体 review P2-3, 对 -32601 分支无需 — 文案 不含后端原文)。 + prefix 拼在错误文案最前 (默认空): session/new 应用可选 model 时嵌套调用 + 的 session/setModel 也走本 helper (setModel 本身是扩展方法, 失败语义 + 与直调一致), 以 prefix 标注「已创建会话 但 model 未生效: 」供 + 客户端识别收编 — 不违反下方边界: create/send 等核心调用自身的错误仍 + 走原始透传。 边界: 只用于扩展/透传 handler 的错误分支; 核心协议路径 (create/send/ stop/list/resume 等) 的 -32601 属深度异常, 需要原始错误信息, 不走本 @@ -1199,7 +1204,7 @@ class ACPBridge: err = resp.get("error", {}) if err.get("code") == -32601: return self._error(msg_id, -32601, - f"当前 ZCode 版本已移除该能力 ({zcode_method}); " + f"{prefix}当前 ZCode 版本已移除该能力 ({zcode_method}); " f"该 ZCode 版本不支持此能力") message = err.get("message", "") if redact: @@ -1207,7 +1212,7 @@ class ACPBridge: # split 的 [-1] 在无 "/" 时本就返回原串, 无需三元分支 short = zcode_method.split("/", 1)[-1].replace("/", " ") return self._error(msg_id, -32603, - f"zcode {short} failed: {message}") + f"{prefix}zcode {short} failed: {message}") def ensure_backend(self): if self.backend is None: @@ -1328,14 +1333,20 @@ class ACPBridge: params: {cwd?, mode?, model?} model 可选, 两种形态: - 字符串: catalog 里的 modelId (如 "GLM-5.3")。用 create 快照的 - settings.model.available 解析成完整 ModelSelection, 并补 - reasoningLevel=catalog 默认值。 - - dict: 完整 ModelSelection 对象, 原样透传。 + settings.model.available 解析成完整 ModelSelection, catalog 条目 + 带 reasoning.defaultLevel 时补为 reasoningLevel。 + - dict: 完整 ModelSelection 对象 ({providerId, modelId, + options?:{reasoningLevel}}, providerId/modelId 须为非空字符串), + 原样透传。 + model=null 或省略等同未提供; 形状非法在 create 前本地 -32602 (不得 + 先真实建会话再报错, 否则留下客户端无法收编的孤儿会话)。 提供时 create 成功后内部调 session/setModel 应用; 失败则 session/new - 整体报错 (客户端明确知道模型没生效)。不把 model 透传给 create 本身: - 0.16.9 实测 create 的初始 model 形参接受对象但丢 options, turn 阶段 - 报 "Reasoning level is required" (ModelProtocolError); setModel - 路径无此问题。 + 整体报错, 错误文案带已创建的 sessionId (「已创建会话 但 model + 未生效: <原因>」) 供客户端识别收编, 且该会话不登记 session_map (不 + 能经 session/prompt 使用; 后端无会话删除方法, 不尝试回收)。不把 + model 透传给 create 本身: 0.16.9 实测 create 的初始 model 形参接受 + 对象但丢 options, turn 阶段报 "Reasoning level is required" + (ModelProtocolError); setModel 路径无此问题。 """ self.ensure_backend() cwd = params.get("cwd") or os.getcwd() @@ -1346,6 +1357,14 @@ class ACPBridge: mode = params.get("mode") or DEFAULT_ACP_MODE log(f"session/new: cwd={cwd}, mode={mode}") + # model 形状校验必须在 create 之前: 非法参数不触碰后端 (否则先建会话再 + # 报错, 客户端拿不到 sessionId 也无从收编)。null/缺省等同未提供。 + model_param = params.get("model") + if model_param is not None: + err = self._model_param_error(model_param) + if err: + return self._error(msg_id, -32602, f"session/new model 参数无效: {err}") + zc_id = self._next_id() resp, _ = self.backend.request(zc_id, "session/create", { "workspace": {"workspacePath": cwd, "workspaceKey": cwd}, @@ -1369,53 +1388,94 @@ class ACPBridge: # ACP sessionId: 用 zcode 的或生成一个 acp_sid = zcode_sid # 格式兼容, 直接用 - self.session_map[acp_sid] = zcode_sid # 可选 model: 归一后经 setModel 应用 (见 docstring 为何不走 create 形参) - model_param = params.get("model") if model_param is not None: selection = self._resolve_session_model(model_param, result) if isinstance(selection, str): - return self._error(msg_id, -32602, f"session/new model 参数无效: {selection}") + return self._error( + msg_id, -32602, + f"已创建会话 {zcode_sid} 但 model 未生效: {selection}") zc_id = self._next_id() resp, _ = self.backend.request(zc_id, "session/setModel", {"sessionId": zcode_sid, "model": selection}, timeout=15) if "error" in resp: - return self._passthrough_error(msg_id, "session/setModel", resp) + # 含 setModel 超时 (request 超时以 {"error":{"message":"timeout"}} + # 返回), 与后端错误同路: 文案带 sessionId 供客户端收编 + return self._passthrough_error( + msg_id, "session/setModel", resp, + prefix=f"已创建会话 {zcode_sid} 但 model 未生效: ") log(f" session/new model → {selection.get('providerId', '?')}/" f"{selection.get('modelId', '?')}") + # model 生效 (或未指定) 后才登记映射: 失败路径不登记, 防客户端凭报错 + # 信息里的 sessionId 走 session/prompt 使用一个模型未锁定的会话。 + # (session/prompt 用 .get(acp_sid) 严格取; 扩展 handler 用 + # .get(acp_sid) or acp_sid 兜底 — acp_sid 恒等于 zcode_sid 且登记先于 + # 本响应返回, 故移位对成功路径无行为差异。) + self.session_map[acp_sid] = zcode_sid + log(f"session/new → {acp_sid}") return {"jsonrpc": "2.0", "id": msg_id, "result": {"sessionId": acp_sid}} + @staticmethod + def _model_param_error(model_param): + """session/new 的 model 参数形状校验 (create 前调用, 非法本地 -32602)。 + + 合法形态: strip 后非空的字符串 (catalog modelId), 或非空 ModelSelection + 对象且 providerId/modelId 均为非空字符串 (对象形态缺字段若透传后端, + -32602 会被 _passthrough_error 改写成 -32603, 掩盖参数问题)。返回错误 + 文案, 合法返回 None。 + """ + if isinstance(model_param, dict): + if not model_param: + return "model 对象为空" + for key in ("providerId", "modelId"): + value = model_param.get(key) + if not isinstance(value, str) or not value.strip(): + return f"model 对象的 {key} 需为非空字符串" + return None + if not isinstance(model_param, str) or not model_param.strip(): + return "model 需为非空字符串 (catalog modelId) 或 ModelSelection 对象" + return None + @staticmethod def _resolve_session_model(model_param, create_result): """把 session/new 的 model 参数归一成 setModel 的 ModelSelection 对象。 - dict (非空) → 原样返回; 字符串 → 在 create 快照 settings.model.available - 里按 modelId 精确匹配 (多 provider 同名取第一个; 需要指定 provider 的 - 调用方应直接传对象), 并补 options.reasoningLevel=reasoning.defaultLevel - (0.16.9 实测部分模型缺它会 ModelProtocolError)。无法解析时返回错误 - 字符串 (调用方转 -32602)。 + 形状已由 _model_param_error 在 create 前校验。dict → 原样返回; + 字符串 → 在 create 快照 settings.model.available 里按 modelId 精确 + 匹配 (多 provider 同名取第一个; 需要指定 provider 的调用方应直接传 + 对象), catalog 条目带 reasoning.defaultLevel 时补 + options.reasoningLevel (0.16.9 实测部分模型缺它会 ModelProtocolError)。 + 无法解析时返回错误字符串, 「catalog 不可用」与「未命中」分开报 (调用 + 方转 -32602 并附上已创建的 sessionId)。 """ if isinstance(model_param, dict): - return model_param if model_param else "model 对象为空" - if not isinstance(model_param, str) or not model_param.strip(): - return "model 需为非空字符串或 ModelSelection 对象" + return model_param wanted = model_param.strip() - available = (((create_result.get("settings") or {}).get("model") or {}) - .get("available")) or [] + settings_model = ((create_result.get("settings") or {}).get("model") or {}) + available = settings_model.get("available") + # 容错: catalog 缺失/形态漂移按「不可用」报, 非 dict 条目跳过 (防 + # AttributeError 把 -32602 变成 -32603 内部错) + if not isinstance(available, list): + return "本会话模型目录不可用 (create 快照缺少 settings.model.available)" for entry in available: - ref = entry.get("ref") or {} - if ref.get("modelId") == wanted: - selection = {"providerId": ref.get("providerId"), - "modelId": ref.get("modelId")} - default_level = (entry.get("reasoning") or {}).get("defaultLevel") - if default_level: - selection["options"] = {"reasoningLevel": default_level} - return selection + if not isinstance(entry, dict): + continue + ref = entry.get("ref") + if not isinstance(ref, dict) or ref.get("modelId") != wanted: + continue + selection = {"providerId": ref.get("providerId"), + "modelId": ref.get("modelId")} + reasoning = entry.get("reasoning") + default_level = (reasoning.get("defaultLevel") + if isinstance(reasoning, dict) else None) + if default_level: + selection["options"] = {"reasoningLevel": default_level} + return selection return f"modelId '{wanted}' 不在本会话的模型目录中" def _on_session_list(self, msg_id, params): @@ -1767,9 +1827,10 @@ class ACPBridge: -32602 "expected object, received undefined / Unrecognized key"; 故本参数形态是破坏性修正, 旧形态在 0.16 后端上从未成功过); 缺 options.reasoningLevel 时部分模型在 turn 阶段报 ModelProtocolError - "Reasoning level is required for /" — 客户端不 - 确定时应改用 session/new 的 model 字符串形态 (按 create 快照的 - catalog 默认 reasoningLevel 解析, 见 _resolve_session_model)。 + "Reasoning level is required for /"。需要按 catalog + 解析默认 reasoningLevel 的调用方应在 session/new 时指定 model + (字符串形态, 见 _resolve_session_model); 本方法要求调用方提供完整 + 对象 (含 options)。 """ self.ensure_backend() acp_sid = params.get("sessionId") diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index c444037..b9e0d15 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -534,7 +534,10 @@ APP_SERVER = { "note": "❌ 0.16 已移除 (调用返 -32601); 无协议替代, 仅剩 slash 命令 /rewind 与 rewind.triggered 事件"}, {"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/setModel", + "params": "sessionId, model{providerId, modelId, options?:{reasoningLevel}}", + "note": "0.14.8+: 切换模型 (新); 0.16 起 schema 要 model 对象 — 旧 modelId 字符串形态必报 " + "-32602 (0.16.9 实测), 缺 reasoningLevel 时部分模型 turn 阶段报 ModelProtocolError"}, {"method": "session/cancelBackgroundTask", "params": "sessionId, taskId", "note": "0.14.8+: 取消后台 Bash 任务 (新)"}, # ----- 0.15.0 (App 3.2.0) 新增 session 级方法 ----- diff --git a/skills/zcode-bridge-guide/SKILL.md b/skills/zcode-bridge-guide/SKILL.md index a266acc..9382d70 100644 --- a/skills/zcode-bridge-guide/SKILL.md +++ b/skills/zcode-bridge-guide/SKILL.md @@ -259,7 +259,7 @@ ACP bridge 暴露的 ZCode 新版协议方法,按定位维度分组。**sessio | `session/compact` | 压缩对话上下文 | 0.14.8 | `{sessionId}` | | `session/steer` ❌ | turn 进行中追加指令(**0.16 已移除**,语义并入 `session/send`) | 0.14.8 | `{sessionId, content}` | | `session/setThoughtLevel` | ⭐ 设置思考强度 | 0.15.0 | `{sessionId, thoughtLevel}` | -| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, model}` / `{sessionId, mode}`(model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`;0.16 后端 schema 要对象,旧 `modelId` 字符串必 -32602。另 `session/new` 支持可选 `model`(字符串按 catalog 解析并补默认 reasoningLevel,或完整对象),创建会话即锁定模型) | +| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, model}` / `{sessionId, mode}`(model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`;0.16 后端 schema 要对象,旧 `modelId` 字符串必 -32602,且对象形态仅在 0.16.x 验证、≤0.15 未验证。另 `session/new` 支持可选 `model`(字符串按 catalog 解析,条目带 `reasoning.defaultLevel` 时补;或完整对象;`model: null`/省略等同未提供),创建会话即锁定模型) | | `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | | `session/rewindCascade` ❌ | 级联回退(同 rewind schema,**0.16 已移除**) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | | `session/updateRuntimeModelConfig` ❌ | 运行时覆盖模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}`(0.16 起 `runtimeModel.revision` 必填) | diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index 6d14cef..de0d026 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -350,10 +350,15 @@ def test_c4_new_model_string_resolves_catalog(self): self._assert_ok(resp) set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] self.assertEqual(len(set_calls), 1, "model 应恰好触发一次 setModel") + self.assertEqual(set_calls[0]["params"]["sessionId"], "sess_m", + "setModel 必须发给 create 返回的会话") self.assertEqual(set_calls[0]["params"]["model"], {"providerId": "bigmodel-api", "modelId": "GLM-5.3", "options": {"reasoningLevel": "max"}}, "应补 catalog 默认 reasoningLevel") + self.assertEqual(resp["result"]["sessionId"], "sess_m") + self.assertIn("sess_m", bridge.session_map, + "model 生效后会话须登记 (注册后移不得破坏成功路径)") self.assertNotIn("model", [c for c in fake.calls if c["method"] == "session/create"][0]["params"], "model 不得透传给 create (create 形参丢 options, 0.16.9 实测)") @@ -366,6 +371,8 @@ def test_c4a_new_model_string_no_reasoning_entry(self): resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3-Flash"}) self._assert_ok(resp) set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(set_calls[0]["params"]["sessionId"], "sess_m", + "setModel 必须发给 create 返回的会话") self.assertEqual(set_calls[0]["params"]["model"], {"providerId": "bigmodel-api", "modelId": "GLM-5.3-Flash"}) @@ -379,31 +386,48 @@ def test_c4b_new_model_object_passthrough(self): resp = self._call(bridge, "session/new", {"cwd": "/p", "model": ref}) self._assert_ok(resp) set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(set_calls[0]["params"]["sessionId"], "sess_m", + "setModel 必须发给 create 返回的会话") self.assertEqual(set_calls[0]["params"]["model"], ref) def test_c4c_new_model_unknown_string(self): - """C4c: model 字符串不在 catalog → -32602, 不发 setModel""" + """C4c: model 字符串不在 catalog → -32602, 不发 setModel; 文案带已创建 + sessionId 供客户端收编, 且该会话不登记 session_map (孤儿不可用)""" bridge, fake = self._new_bridge({ "session/create": {"response": {"result": dict(self._CATALOG)}}, }) resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "nope"}) self._assert_error_code(resp, -32602) + self.assertIn("sess_m", resp["error"]["message"], + "catalog 未命中属 create 后失败, 文案须带 sessionId") self.assertEqual([c for c in fake.calls if c["method"] == "session/setModel"], [], "解析失败不得调 setModel") + self.assertNotIn("sess_m", bridge.session_map, + "model 未生效的会话不得登记 (不能经 session/prompt 使用)") def test_c4d_new_set_model_failure_fails_new(self): - """C4d: setModel 失败 → session/new 整体报错 (客户端明确知道模型没生效)""" - bridge, _ = self._new_bridge({ - "session/create": {"response": {"result": dict(self._CATALOG)}}, - "session/setModel": {"response": {"error": { - "code": -32603, "message": "ModelProtocolError: boom"}}}, - }) - resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) - self._assert_error_code(resp, -32603) + """C4d: setModel 失败/超时 → session/new 整体报错 (客户端明确知道模型 + 没生效); 文案含 setModel 可定位失败步骤、含 sessionId 供收编, 会话不登记""" + for err_resp in ({"error": {"code": -32603, + "message": "ModelProtocolError: boom"}}, + {"error": {"message": "timeout"}}): + with self.subTest(err=err_resp): + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + "session/setModel": {"response": err_resp}, + }) + resp = self._call(bridge, "session/new", + {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_error_code(resp, -32603) + msg = resp["error"]["message"] + self.assertIn("setModel", msg, f"应指明 setModel 步骤: {msg}") + self.assertIn("sess_m", msg, f"应带已创建的 sessionId: {msg}") + self.assertNotIn("sess_m", bridge.session_map, + "model 未生效的会话不得登记") def test_c4e_new_without_model_no_set_model(self): - """C4e: 不带 model → 行为与旧版一致, 不触发 setModel (向后兼容)""" + """C4e: 不带 model → 行为与旧版一致 (create 参数逐字段不变), 不触发 setModel""" bridge, fake = self._new_bridge({ "session/create": {"response": {"result": {"sessionId": "sess_plain"}}}, }) @@ -411,16 +435,95 @@ def test_c4e_new_without_model_no_set_model(self): self._assert_ok(resp) self.assertEqual([c for c in fake.calls if c["method"] == "session/setModel"], []) + create = [c for c in fake.calls if c["method"] == "session/create"] + self.assertEqual(create[0]["params"], { + "workspace": {"workspacePath": "/p", "workspaceKey": "/p"}, + "mode": self.mod.DEFAULT_ACP_MODE, + }, "不带 model 的 create 参数必须与旧调用逐字段一致 (向后兼容)") + self.assertIn("sess_plain", bridge.session_map, + "未指定 model 的会话照常登记 (注册后移不得漏)") def test_c4f_new_model_invalid_types(self): - """C4f: model 空对象/空串/数字 → -32602""" - bridge, _ = self._new_bridge({ + """C4f: model 空对象/空串/数字 → -32602; 形状校验在 create 前, 不触碰 + 后端 (A1: 不得先建会话再报错留孤儿)""" + bridge, fake = self._new_bridge({ "session/create": {"response": {"result": dict(self._CATALOG)}}, }) for bad in ({}, "", 42): self._assert_error_code( self._call(bridge, "session/new", {"cwd": "/p", "model": bad}), -32602, msg=f"model={bad!r}") + self.assertEqual(fake.calls, [], + f"非法形态不得发起任何后端调用: {fake.calls}") + + def test_c4g_new_set_model_32601_removed_wording(self): + """C4g (D17): session/new 的 model 路径中 setModel 后端 -32601 → 走 + _passthrough_error 得「已移除 (session/setModel)」文案 (核心 handler 内 + 嵌套调用沿用扩展方法降级约定), 错误码保持 -32601 且带 sessionId""" + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + "session/setModel": {"response": {"error": { + "code": -32601, "message": "Method not found"}}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_error_code(resp, -32601, "setModel 已删应保持 -32601") + msg = resp["error"]["message"] + self.assertIn("已移除", msg) + self.assertIn("(session/setModel)", msg) + self.assertIn("sess_m", msg, "文案须带已创建的 sessionId 供收编") + self.assertNotIn("sess_m", bridge.session_map) + + def test_c4h_new_model_object_bad_fields(self): + """C4h (B5): 对象形态缺 providerId/modelId (含空串/非字符串) → 本地 + -32602, create 前拦截不触碰后端 (否则透传后端 -32602 会被改写成 -32603, + 且先建会话留孤儿)""" + for bad in ({"modelId": "m1"}, + {"providerId": "bigmodel-api"}, + {"providerId": "", "modelId": "m1"}, + {"providerId": "bigmodel-api", "modelId": " "}, + {"providerId": 42, "modelId": "m1"}): + with self.subTest(model=bad): + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + resp = self._call(bridge, "session/new", + {"cwd": "/p", "model": bad}) + self._assert_error_code(resp, -32602, msg=f"model={bad!r}") + self.assertEqual(fake.calls, [], + f"形状非法不得发起 create/setModel: {fake.calls}") + + def test_c4i_new_model_catalog_unavailable(self): + """C4i (B7): create 快照无 settings.model.available → 「catalog 不可用」 + 文案 (与「未命中」区分), 且带已创建 sessionId""" + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "sess_m"}}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_error_code(resp, -32602) + msg = resp["error"]["message"] + self.assertIn("模型目录不可用", msg, f"应报 catalog 不可用: {msg}") + self.assertIn("sess_m", msg) + self.assertNotIn("不在本会话的模型目录中", msg, + "不可用与未命中是两类错误, 文案应区分") + + def test_c4j_new_model_catalog_malformed_entries(self): + """C4j (B6): catalog 含非 dict 条目/非 dict ref/非 dict reasoning → + 跳过不抛 AttributeError, 后续合法条目仍可命中""" + catalog = {"sessionId": "sess_m", "settings": {"model": {"available": [ + "bogus", + {"ref": "not-a-dict"}, + {"ref": {"providerId": "bigmodel-api", "modelId": "GLM-5.3"}, + "reasoning": "not-a-dict"}, + ]}}} + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": catalog}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_ok(resp) + set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(set_calls[0]["params"]["model"], + {"providerId": "bigmodel-api", "modelId": "GLM-5.3"}, + "畸形条目跳过, 合法条目不补 reasoningLevel") # ---------- EV: 事件/轮询模式选择 · 事件分支 ---------- def test_ev1_event_branch_subscribe_deliverykind(self): From 446737ce5a4cda2813b48e9140c2efa4178f3bcb Mon Sep 17 00:00:00 2001 From: jason <991262382@qq.com> Date: Mon, 21 Sep 2026 12:56:11 +0800 Subject: [PATCH 3/3] feat(acp-bridge): session/new passthrough toolAllowlist/toolDenylist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.16 stdio 下 mode=plan 是 advisory (工具照常自动执行, 实测), 快照 diff 只是 事后发现; 引擎 session/create 的 toolAllowlist/toolDenylist 映射 引擎的工具集注册级硬 allow/deny (先于权限层、与 mode 无关), 是会话级前置只读手段。非空字符串数组 原样透传 (空条目剔除), 缺省/空数组/非数组时 create 参数与旧版逐字一致。 C5/C5a 回归用例; 全套 549 passed + 34 subtests。 R2 审查修复: 严格校验 fail-closed / 空数组透传 / 文档与测试同步 — - 键存在 → 必须为字符串数组且每个元素 strip 后非空, 非法 (含显式 null/ 非数组/元素非字符串/空串) 本地 -32602 且不触碰后端; 删除 str() 强转与 非数组静默忽略路径; 键缺省 → 不写入 create 参数 (与旧行为逐字节一致); - 字面空数组 [] 原样透传 (引擎语义: 空 allowlist = 全部禁用, 空 denylist = 无限制), 不再静默丢弃; - docstring/README/SKILL 同步: 机制归属修正为工具集注册级物理过滤 (先于 权限层、与 mode 无关; 非 PermissionService 映射), allow+deny 同传 = 交集且 deny 优先, 只读监督建议同时 deny Node REPL 族; - 测试改写为 C5/C5a-C5e (归一/缺省兼容/空数组透传/非法 fail-closed/单边/ 与 model 并存), 全套 557 passed; ruff check 干净。 --- README.md | 4 +- packages/acp-bridge/zcode-acp-bridge | 69 ++++++++++++++++-- skills/zcode-bridge-guide/SKILL.md | 4 +- tests/test_app_server_methods.py | 102 +++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bc2f72a..881b3c1 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用 > **session/new 可选 `model` 参数**:`{cwd, mode?, model?}` —— model 可传 catalog 里的 modelId 字符串(如 `"GLM-5.3"`,按 create 快照解析成完整 ModelSelection;catalog 条目带 `reasoning.defaultLevel` 时补默认 reasoningLevel)或完整对象(`{providerId, modelId, options?}`,两者须为非空字符串;`model: null` 或省略等同未提供)。create 成功后桥内部经 `session/setModel` 应用,失败则 session/new 整体报错(文案带已创建的 sessionId,且该会话不登记、不可经 session/prompt 使用)。会话创建即锁定模型,不依赖 App 侧「上次使用」默认(headless 场景刚需)。不透传给 create 本身:0.16.9 实测 create 的初始 model 形参丢 options。 +> **session/new 可选 `toolAllowlist` / `toolDenylist` 参数**:字符串数组,在会话创建时按名过滤工具(**工具集注册级物理过滤**:先于权限层、与 mode 无关,hook/权限确认都覆盖不了)。键缺省 → 不写入 create 参数(引擎默认全量工具,与旧行为一致);键存在 → 每个元素须为 strip 后非空的字符串,非法(非数组、元素非字符串、空串,**含显式 `null`**)本地 `-32602` 且不触碰后端——fail-closed,安全参数不静默忽略。条目 strip 后透传,字面 `[]` 原样透传(空 allowlist = 全部禁用,空 denylist = 无限制)。allow+deny 同传时引擎取**交集且 deny 优先**。用于只读监督:0.16 stdio 下 `mode=plan` 只是 advisory(0.16.1 实测工具照常自动执行,快照 diff 只是事后发现),名单是唯一的会话级前置只读手段;建议同时 deny Node REPL 族(`js` / `js_reset` / `js_add_node_module_dir` / `mcp__node_repl__js*`),否则可经 `execSync` 打穿 `Bash` 黑名单(先例见上文 `zcode_review` 只读原理)。 + > ❌ **0.16 已移除**:`session/steer`、`session/rewind`、`session/rewindCascade` 已从 app-server 删除。steer 语义并入 `session/send`(turn 进行中发送即 steer);rewind 无协议替代,仅剩 slash 命令 `/rewind` 与 `rewind.triggered` 事件。0.16.1 上调用这些方法会收到 `-32601`。 > > ℹ️ **0.16 schema 变更**:`session/updateRuntimeModelConfig` 在 0.16.1 仍存活(实测),但 schema 新要求 `runtimeModel.revision`(string)必填。App 3.12.3 的同号 0.16.5 构建已删除该方法(2026-09-17 实测后端返 -32601;0.16.9/App 3.14.0 于 2026-09-19 复测仍删),桥透传时降级为「已移除」文案。 @@ -372,7 +374,7 @@ cp -r skills/zcode-bridge-guide ~/.zcode/skills/ 4. **diff 无内容**:ZCode 协议层不暴露 oldText/newText,只能列文件名。 5. **GLM-5.2 无推理输出**:思考过程(agent_thought_chunk)在 GLM-5.2 下不触发,需 GLM-5-Turbo(GLM-5.2 为旧默认模型;GLM-5.3 行为未复测。GLM-5-Turbo 已于 App 3.12.3 时代由服务端从 coding-plan provider 下线,此条为历史观察)。 6. **TUI 不可用**:0.16.1 起 CLI 帮助虽列出 `tui` 命令(无参数即进入 TUI),但独立终端实测仍报错(`Cannot find package '@zcode/tui'`),仅 headless 模式可用。 -7. **⚠️ ACP bridge 默认 `mode=yolo`(权限风险)**:为避免工具调用 turn 卡在权限确认,ACP bridge 的 `session/new` 强制以 `mode=yolo` 创建会话(见 `zcode-acp-bridge` 的 `_on_session_new`)。这意味着任意 prompt 都可能触发**无确认的文件修改和命令执行**。作为编辑器集成时请知悉此风险;现可用 `ZCODE_ACP_DEFAULT_MODE=build` 收紧默认值,且 bridge 启动日志(stderr)会对当前默认 mode 打显眼告警。更完整的方案是实现 ACP↔ZCode 的 permission 转发(本项目 P4b 未实现)。 +7. **⚠️ ACP bridge 默认 `mode=yolo`(权限风险)**:为避免工具调用 turn 卡在权限确认,ACP bridge 的 `session/new` 强制以 `mode=yolo` 创建会话(见 `zcode-acp-bridge` 的 `_on_session_new`)。这意味着任意 prompt 都可能触发**无确认的文件修改和命令执行**。作为编辑器集成时请知悉此风险;现可用 `ZCODE_ACP_DEFAULT_MODE=build` 收紧默认值,且 bridge 启动日志(stderr)会对当前默认 mode 打显眼告警;调用方还可用 `session/new` 的 `toolDenylist` 做会话级前置缓解(工具集注册级物理过滤,与 mode 无关,见「扩展方法」的 session/new 名单说明)。更完整的方案是实现 ACP↔ZCode 的 permission 转发(本项目 P4b 未实现)。 8. **⚠️ Provider 管理方法涉及 apiKey**:`workspace/upsertModelProvider`、`workspace/updateProviderRegistry` 的 `provider`/`registry` 参数会携带 `apiKey`(可能为 `{source:"inline", value:"sk-..."}` 明文)。ACP bridge 仅整体透传给 ZCode 后端、不读取也不在日志打印其明文;但调用方应自行确保传输通道(stdio)可信,并避免在日志中回显原始参数。(这两个方法已于 App 3.12.3 的 0.16.5 构建删除,本条适用于 3.10.2 及更早构建。) 9. **⚠️ 事件模式 turn 超时契约(2026-08-08 起)**:`session/prompt` 在事件模式下若 turn 已启动但 120s 未收到完成信号,返回 **JSON-RPC 错误 `-32603`("事件流超时")**,而**不是**正常 `stopReason=max_turn_requests`——后者只保留给"turn 从未启动"的场景。ACP client 侧应按此区分「卡死」与「真的太长」(整体 review P1 + 复审 P1-B 的契约变更)。 diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index 1879a3f..eee32b3 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -1330,7 +1330,7 @@ class ACPBridge: def _on_session_new(self, msg_id, params): """ACP session/new → zcode session/create - params: {cwd?, mode?, model?} + params: {cwd?, mode?, model?, toolAllowlist?, toolDenylist?} model 可选, 两种形态: - 字符串: catalog 里的 modelId (如 "GLM-5.3")。用 create 快照的 settings.model.available 解析成完整 ModelSelection, catalog 条目 @@ -1347,6 +1347,23 @@ class ACPBridge: model 透传给 create 本身: 0.16.9 实测 create 的初始 model 形参接受 对象但丢 options, turn 阶段报 "Reasoning level is required" (ModelProtocolError); setModel 路径无此问题。 + toolAllowlist/toolDenylist 可选, 是工具集注册级物理过滤 (0.16.9 + bundle 证据): 会话创建时按名剔除/限定工具, 先于权限层且与 mode + 无关 — hook/权限确认都不能覆盖。用于只读监督类调用方: 0.16 stdio + 下 mode=plan 只是 advisory (0.16.1 实测工具照常自动执行, 快照 diff + 只是事后发现), 名单是唯一的会话级前置只读手段。校验与归一: + - 键缺省 → 不写入 create 参数 (引擎默认全量工具, 与旧行为一致); + - 键存在 → 必须是字符串数组且每个元素 strip 后非空, 否则本地 + -32602 且不触碰后端 (fail-closed: 显式 null/字符串等非法形态 + 不静默忽略, 否则调用方会误以为隔离已生效); + - 存 strip 后的值; 字面空数组 [] 原样透传 — 空 allowlist 表示 + 全部禁用, 空 denylist 表示无限制 (引擎语义, 不静默丢弃); + - allow 与 deny 同传时引擎取交集且 deny 优先 (deny 中的工具即使 + 在 allow 中也禁用)。 + 只读监督建议同时 deny Node REPL 族 (js/js_reset/ + js_add_node_module_dir 与 mcp__node_repl__js*): 仅禁 Write/Edit/ + Bash 会被其 execSync 打穿 (0.16.1 实测先例, 见 README「只读原理」 + 与「重要限制」#7)。 """ self.ensure_backend() cwd = params.get("cwd") or os.getcwd() @@ -1356,20 +1373,39 @@ class ACPBridge: # 0.16 stdio 下工具自动执行, 参数被忽略) mode = params.get("mode") or DEFAULT_ACP_MODE log(f"session/new: cwd={cwd}, mode={mode}") + # 形状校验必须在 create 之前: 非法参数不触碰后端 (否则先建会话再 + # 报错, 客户端拿不到 sessionId 也无从收编)。model=null/缺省等同未 + # 提供; 工具名单只看键是否存在 (显式 null 也按非法拒绝 — 安全开关 + # fail-closed, 不静默忽略)。 + create_params = { + "workspace": {"workspacePath": cwd, "workspaceKey": cwd}, + "mode": mode, + } + for list_key in ("toolAllowlist", "toolDenylist"): + if list_key not in params: + continue + err = self._tool_list_param_error(params[list_key]) + if err: + return self._error( + msg_id, -32602, f"session/new {list_key} 参数无效: {err}") + # 条目归一为 strip 后的值; 空数组原样保留 (引擎语义: 空 allowlist + # = 全部禁用, 空 denylist = 无限制), 不静默丢弃 + create_params[list_key] = [item.strip() for item in params[list_key]] + log(f" session/new {list_key} → {len(create_params[list_key])} tools") - # model 形状校验必须在 create 之前: 非法参数不触碰后端 (否则先建会话再 - # 报错, 客户端拿不到 sessionId 也无从收编)。null/缺省等同未提供。 model_param = params.get("model") if model_param is not None: err = self._model_param_error(model_param) if err: return self._error(msg_id, -32602, f"session/new model 参数无效: {err}") + + # 校验全部通过才拉起后端: 非法参数连子进程都不启动 (纯本地校验, + # "零后端调用"字面成立 — 复审 P3) + self.ensure_backend() + zc_id = self._next_id() - resp, _ = self.backend.request(zc_id, "session/create", { - "workspace": {"workspacePath": cwd, "workspaceKey": cwd}, - "mode": mode, - }, timeout=15) + resp, _ = self.backend.request(zc_id, "session/create", create_params, timeout=15) if "error" in resp: return self._error(msg_id, -32603, f"zcode create failed: {resp['error'].get('message','')}") @@ -1441,6 +1477,25 @@ class ACPBridge: return "model 需为非空字符串 (catalog modelId) 或 ModelSelection 对象" return None + @staticmethod + def _tool_list_param_error(list_value): + """session/new 的 toolAllowlist/toolDenylist 形状校验 (create 前调用)。 + + 键存在时须为数组, 每个元素是 strip 后非空的字符串 — 安全参数 + fail-closed: 非法形态本地 -32602, 不触碰后端也不静默忽略 (静默忽略 + 会让调用方误以为隔离已生效)。空数组合法 (引擎语义: 空 allowlist = + 全部禁用, 空 denylist = 无限制), 由调用方原样透传, 本函数不判空。 + 返回错误文案 (含期望形态与实际类型), 合法返回 None。 + """ + if not isinstance(list_value, list): + return (f"需为字符串数组 (每个元素为 strip 后非空的字符串), " + f"实际为 {type(list_value).__name__}") + for index, item in enumerate(list_value): + if not isinstance(item, str) or not item.strip(): + return (f"第 {index} 个元素需为 strip 后非空的字符串, " + f"实际为 {repr(item)[:80]!r} ({type(item).__name__})") + return None + @staticmethod def _resolve_session_model(model_param, create_result): """把 session/new 的 model 参数归一成 setModel 的 ModelSelection 对象。 diff --git a/skills/zcode-bridge-guide/SKILL.md b/skills/zcode-bridge-guide/SKILL.md index 9382d70..f9b5433 100644 --- a/skills/zcode-bridge-guide/SKILL.md +++ b/skills/zcode-bridge-guide/SKILL.md @@ -259,11 +259,13 @@ ACP bridge 暴露的 ZCode 新版协议方法,按定位维度分组。**sessio | `session/compact` | 压缩对话上下文 | 0.14.8 | `{sessionId}` | | `session/steer` ❌ | turn 进行中追加指令(**0.16 已移除**,语义并入 `session/send`) | 0.14.8 | `{sessionId, content}` | | `session/setThoughtLevel` | ⭐ 设置思考强度 | 0.15.0 | `{sessionId, thoughtLevel}` | -| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, model}` / `{sessionId, mode}`(model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`;0.16 后端 schema 要对象,旧 `modelId` 字符串必 -32602,且对象形态仅在 0.16.x 验证、≤0.15 未验证。另 `session/new` 支持可选 `model`(字符串按 catalog 解析,条目带 `reasoning.defaultLevel` 时补;或完整对象;`model: null`/省略等同未提供),创建会话即锁定模型) | +| `session/setModel` / `setMode` | 切换模型 / 权限模式 | 0.14.8 | `{sessionId, model}` / `{sessionId, mode}`(model 是 ModelSelection 对象 `{providerId, modelId, options?: {reasoningLevel}}`;0.16 后端 schema 要对象,旧 `modelId` 字符串必 -32602,且对象形态仅在 0.16.x 验证、≤0.15 未验证。另 `session/new` 支持可选 `model`(字符串按 catalog 解析,条目带 `reasoning.defaultLevel` 时补;或完整对象;`model: null`/省略等同未提供),创建会话即锁定模型;另支持 `toolAllowlist`/`toolDenylist` 工具名单,见下方注) | | `session/cancelBackgroundTask` | 取消后台 Bash 任务 | 0.14.8 | `{sessionId, taskId}` | | `session/rewindCascade` ❌ | 级联回退(同 rewind schema,**0.16 已移除**) | 0.15.0 | `{sessionId, target?, scope?, expectedRevision?}` | | `session/updateRuntimeModelConfig` ❌ | 运行时覆盖模型配置 | 0.15.0 | `{sessionId, runtimeModel, applyModelSelection?}`(0.16 起 `runtimeModel.revision` 必填) | +> 🔒 **`session/new` 可选工具名单**:`toolAllowlist` / `toolDenylist`(字符串数组,strip 后非空;非法形态——含显式 `null`——本地 `-32602` 且不碰后端,fail-closed)在创建会话时按名过滤工具:**工具集注册级物理过滤**,先于权限层、与 mode 无关。allow+deny 同传 = 交集且 deny 优先;字面 `[]` 原样透传(空 allowlist = 全部禁用,空 denylist = 无限制)。0.16 stdio 下 `mode=plan` 只是 advisory,只读监督建议用 `toolDenylist` 并同时 deny Node REPL 族(`js` / `js_reset` / `js_add_node_module_dir` / `mcp__node_repl__js*`,防 `execSync` 打穿 `Bash`)。 +> > ❌ **0.16 已移除**:`session/steer`、`session/rewind`、`session/rewindCascade` 已从 app-server 删除(steer 并入 `session/send`——turn 进行中发送即 steer;rewind 仅剩 slash 命令 `/rewind`),0.16.1 上调用会收到 `-32601`。 > > ℹ️ **0.16 schema 变更**:`session/updateRuntimeModelConfig` 在 0.16.1 仍存活(实测),但 schema 新要求 `runtimeModel.revision`(string)必填。App 3.12.3 的同号 0.16.5 构建已删除该方法(2026-09-17 实测后端返 -32601),桥透传时降级为「已移除」文案。 diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index de0d026..0ddb8be 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -525,6 +525,108 @@ def test_c4j_new_model_catalog_malformed_entries(self): {"providerId": "bigmodel-api", "modelId": "GLM-5.3"}, "畸形条目跳过, 合法条目不补 reasoningLevel") + # ---------- C5: session/new 工具名单透传 (只读监督的会话级前置手段) ---------- + def test_c5_new_toollists_normalized_into_create(self): + """C5: 合法 toolAllowlist/toolDenylist → 归一 (逐条 strip) 后并入 + session/create 参数。名单是工具集注册级物理过滤, 先于权限层、与 mode + 无关 — 0.16 stdio 下 mode=plan 是 advisory, 名单是唯一的会话级只读手段""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "sess_tl"}}}, + }) + resp = self._call(bridge, "session/new", { + "cwd": "/p", + "toolDenylist": [" Write ", "Edit", "Bash"], + "toolAllowlist": ["Read ", " Grep"], + }) + self._assert_ok(resp) + create_params = [c for c in fake.calls + if c["method"] == "session/create"][0]["params"] + self.assertEqual(create_params.get("toolDenylist"), + ["Write", "Edit", "Bash"], + "条目应 strip 后透传") + self.assertEqual(create_params.get("toolAllowlist"), ["Read", "Grep"]) + + def test_c5a_new_toollists_absent_unchanged(self): + """C5a: 两键都不传 → create 参数与旧版逐字段一致 (向后兼容)""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "sess_plain"}}}, + }) + resp = self._call(bridge, "session/new", {"cwd": "/p"}) + self._assert_ok(resp) + create = [c for c in fake.calls if c["method"] == "session/create"] + self.assertEqual(create[0]["params"], { + "workspace": {"workspacePath": "/p", "workspaceKey": "/p"}, + "mode": self.mod.DEFAULT_ACP_MODE, + }, "不带名单的 create 参数必须与旧调用逐字段一致 (向后兼容)") + + def test_c5b_new_empty_list_passthrough(self): + """C5b: 字面空数组 → 原样透传入 create (引擎语义: 空 allowlist = 全部 + 禁用, 空 denylist = 无限制), 不得静默丢弃""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "sess_empty"}}}, + }) + resp = self._call(bridge, "session/new", + {"cwd": "/p", "toolAllowlist": [], "toolDenylist": []}) + self._assert_ok(resp) + create_params = [c for c in fake.calls + if c["method"] == "session/create"][0]["params"] + self.assertEqual(create_params.get("toolAllowlist"), [], + "空 allowlist 应原样透传 (全部禁用语义), 不得丢弃") + self.assertEqual(create_params.get("toolDenylist"), [], + "空 denylist 应原样透传 (无限制语义), 不得丢弃") + + def test_c5c_new_toollists_invalid_fail_closed(self): + """C5c: 键存在但形态非法 (非数组/元素非字符串/元素空串) → 本地 -32602 + 且零后端调用 (fail-closed, 安全参数不静默忽略)""" + for bad in ("Write", 42, None, + ["Write", 123], [None], [["Write"]], [" "], ["Write", ""]): + with self.subTest(bad=bad): + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "s"}}}, + }) + resp = self._call(bridge, "session/new", + {"cwd": "/p", "toolDenylist": bad}) + self._assert_error_code(resp, -32602, msg=f"bad={bad!r}") + self.assertEqual(fake.calls, [], + f"非法名单不得发起任何后端调用: {fake.calls}") + + def test_c5d_new_toollists_single_sided(self): + """C5d: 只传单边名单 → create 只含该键 (不得凭空生成另一键)""" + for key in ("toolAllowlist", "toolDenylist"): + with self.subTest(key=key): + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": {"sessionId": "s"}}}, + }) + resp = self._call(bridge, "session/new", + {"cwd": "/p", key: ["Read"]}) + self._assert_ok(resp) + create_params = [c for c in fake.calls + if c["method"] == "session/create"][0]["params"] + other = ("toolDenylist" if key == "toolAllowlist" + else "toolAllowlist") + self.assertEqual(create_params.get(key), ["Read"]) + self.assertNotIn(other, create_params, + "单边名单不得生成另一键") + + def test_c5e_new_toollists_with_model(self): + """C5e: 名单与 model 并存 → create 带名单, model 照常经 setModel 应用, + 成功后会话登记""" + bridge, fake = self._new_bridge({ + "session/create": {"response": {"result": dict(self._CATALOG)}}, + }) + resp = self._call(bridge, "session/new", { + "cwd": "/p", "model": "GLM-5.3", + "toolDenylist": ["Write", "Bash", "js"], + }) + self._assert_ok(resp) + create_params = [c for c in fake.calls + if c["method"] == "session/create"][0]["params"] + self.assertEqual(create_params.get("toolDenylist"), + ["Write", "Bash", "js"]) + set_calls = [c for c in fake.calls if c["method"] == "session/setModel"] + self.assertEqual(len(set_calls), 1, "model 与名单并存时 setModel 照常走") + self.assertIn("sess_m", bridge.session_map) + # ---------- EV: 事件/轮询模式选择 · 事件分支 ---------- def test_ev1_event_branch_subscribe_deliverykind(self): """EV1: subscribe 带 deliveryKind 成功 → 事件模式 (不触发轮询); 轮询分支见 PF3"""