diff --git a/README.md b/README.md index 41ec38c..75bdb8f 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ PR 自动审查闸门守护进程(第 4 组件,experimental):常驻轮 **canonical model id = `~/.zcode/v2/config.json` 里 `models` 的 key 原样**(如 `GLM-5.3`),**不加 provider 前缀**。`shared/credentials.py`、MCP server、ACP bridge、agent-help 四处统一用原始 id。实测(0.16.1 时代)`zai/GLM-5.2` 前缀形式也兼容,但非 canonical,本项目不使用。 -模型面现状(App 3.12.3 的 0.16.5 构建实测,2026-09-17;0.16.9/App 3.14.0 于 2026-09-19 复测一致):当前 enabled provider(`builtin:zai-coding-plan`)的 models 为 `GLM-5.3` / `GLM-5.3-Flash`(`GLM-5-Turbo` 已由服务端移除;3.10.2 时代为三者)。 +模型面现状(App 3.12.3 的 0.16.5 构建实测,2026-09-17;0.16.9/App 3.14.0 于 2026-09-19 在完整桌面配置机器上复测一致;最小 headless 环境的模型引导见下方新节):当前 enabled provider(`builtin:zai-coding-plan`)的 models 为 `GLM-5.3` / `GLM-5.3-Flash`(`GLM-5-Turbo` 已由服务端移除;3.10.2 时代为三者)。 ### 凭证注入:显式环境变量优先 @@ -247,8 +247,26 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se ```bash ./packages/agent-help/zcode-agent-help --print-injected-env # 输出每个 key 的来源(config vs env)+ 最终值(apiKey 脱敏) +# 末尾另附「模型解析面」段: config.json 注入模型 + provider_config.json 的 +# defaultModelSelection/providerOrder 首位 (只读推断, runtime 不回报实际值) ``` +> ⚠️ **env 注入的边界**:以上 env 覆盖在**有完整 `~/.zcode` 配置的机器上无法验证效果**(配置文件兜底掩盖了 env 是否真的被读取);而**最小环境实测中 env 完全不被读取**(0.16.9 官方 runtime 报 `Model creation failed`)。桥保留现有注入行为不变(对既有用户无害);服务器/容器等最小环境还需要完成模型引导,见下一节。 + +### 最小 headless 环境的模型引导(0.16.9 官方 runtime) + +在有完整桌面配置的机器上,模型选择由 `~/.zcode` 下的配置文件兜底,桥的 env 注入是否生效不可分辨;但在**最小环境**(空 HOME、仅入口旁有内置 provider 文件)实测中,官方 runtime **完全不读取** `ZCODE_MODEL` / `ZCODE_BASE_URL` / `ANTHROPIC_API_KEY`,`--prompt` 直接失败退出。服务器/容器部署需要按本节准备: + +**前置一步:内置 provider 配置文件**。官方 runtime 按**调用入口路径(不解析符号链接)**探测 `<入口目录>/provider/zcode-builtin.json`。部署时需把官方发行物中的该文件(连同 `provider/` 目录)放到调用入口旁,否则 `--prompt` 报 `无法定位 CLI ZCode Built-in Provider Config`。 + +**模型选择两条可行路径**: +1. **宿主机先完成一次模型选择**(推荐):桌面 App 登录并选择模型会生成 `~/.zcode/v2/provider_config.json` 注册表(含加密凭证),把整个 `~/.zcode` 配置带到服务器即可;也可以在该文件的 `config.defaultModelSelection` 写 `{"providerId": ..., "modelId": ...}` 指定默认模型。 +2. **社区包装器**:`zcode-app-cli`(社区项目,非本项目维护/背书,仅供参考)+ legacy `~/.zcode/cli/config.json` 路径。 + +**桥的行为**:mcp-server 检测到 `Model creation failed` / `无法定位 CLI ZCode Built-in Provider Config` 两类特征(且既有错误分类为 unknown——限流/配额优先级不变)时,返回 `isError` + 顶层结构化键 `bootstrap.kind`(`model_selection` / `builtin_provider_config`),错误文本前置修复指引;review-gate 把它当**终态**处理(`gave_up` + 告警评论,同 head 不重试、新 head 自动复活),不再 5 次退避后静默放弃。 + +**ACP 路径**:最小环境 `session/create` 会成功但模型目录为空(`settings.model.available: []`),turn 静默停滞、不产出任何事件。bridge 对空目录打一行 stderr 告警(行为不变,session 照常返回);建议 ACP 调用方在 `session/new` 传 `model` 参数显式选模型——传字符串 model 且目录为空时会得到明确 `-32602`(对象形态不做 catalog 校验,拿到的是后端 `setModel` 错误),而不是静默停滞。 + ### ZCODE_BASE_URL 残留自动检测(切换过 plan 的用户) **背景**:ZCode App 切换过订阅 plan 的用户,旧 plan 的 baseURL 会残留在子进程环境(即使该 plan 已失效)。例如 `ZCODE_BASE_URL=https://zcode.z.ai`(start-plan 残留)与 config 当前 enabled 的 `zai-coding-plan`(`api.z.ai`)不一致,会导致 headless 调用打到错误端点(404)。 @@ -315,7 +333,7 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D | ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 | |:--------------:|:--------:|:---------------:|:--------:| -| **0.16.9**(App 3.14.0) | ✅ 完整(核心面与 3.12.3 的 0.16.5 构建一致) | **真流式**(事件驱动) | ✅ session/*(workspace/* 仍删 7/8 仅 generateText 存活;CLI 旗标面增删:`--permission-mode`/`--allow-main-worktree-yolo` 已移除,新增 `--cwd`/`--target-replace` 等,见 agent-help) | +| **0.16.9**(App 3.14.0) | ✅ 完整(核心面与 3.12.3 的 0.16.5 构建一致;最小 headless 环境的模型引导需配置文件,见[^bootstrap]) | **真流式**(事件驱动) | ✅ session/*(workspace/* 仍删 7/8 仅 generateText 存活;CLI 旗标面增删:`--permission-mode`/`--allow-main-worktree-yolo` 已移除,新增 `--cwd`/`--target-replace` 等,见 agent-help) | | **0.16.5**(App 3.12.3,同版本号构建漂移) | ✅ 完整(核心面) | **真流式**(事件驱动) | ✅ session/*(workspace/* 删 7/8 仅 generateText 存活;updateRuntimeModelConfig/updateInteractionPreferences 已删,透传优雅降级) | | **0.16.5**(App 3.10.2) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(与 0.16.1 同面;`automation/*` 未实现不受其删除影响) | | **0.16.1**(App 3.6.5) | ✅ 完整 | **真流式**(事件驱动) | ✅ session/* + workspace/*(`steer`/`rewind*`/`prompt/enhance*` 已于 0.16 移除;`updateRuntimeModelConfig` 存活但 `runtimeModel.revision` 必填) | @@ -331,6 +349,8 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D > > ⚠️ **`session/setModel` 对象形态的适用范围**:仅在 0.16.x 验证(0.16.9 实测可用);≤0.15 后端未验证该形态(历史 `{modelId}` 字符串形态在 0.16 schema 下必拒,故桥已切换为对象形态——这是破坏性变更,旧版调用方需注意)。 +[^bootstrap]: 最小 headless 环境不能依赖 env 注入完成模型引导(0.16.9 官方 runtime 实测 env 不被读取),需按「最小 headless 环境的模型引导」准备 `provider/zcode-builtin.json` 与 `~/.zcode/v2/provider_config.json`;否则 mcp-server 返回结构化 `bootstrap` 键、review-gate 按终态告警。 + **降级行为**: - 轮询降级**仅限 legacy(< 0.16)协议模式**:旧版下 `session/subscribe` 不可用时自动切换到轮询 `session/read`(伪流式)。**0.16+ 不再自动降级**——新协议模式下 subscribe 失败直接报错 `-32603`("0.16+ 必须走事件订阅;轮询降级仅限旧协议模式")。 - 轮询(legacy)路径的失败检测有固有局限:该路径收不到 `turn.failed` 事件(projection/messages 无失败标志),turn 失败只能靠「status=idle 但本轮无任何实质输出(text/tool/patch)」的启发式检测,可能误报(成功但无实质输出的 turn 被判失败)或漏报(失败前已吐出部分内容的 turn 被当成功);0.16+ 事件路径无此局限(`turn.failed` 终止帧已能正确判失败)。 diff --git a/packages/acp-bridge/zcode-acp-bridge b/packages/acp-bridge/zcode-acp-bridge index eee32b3..94e2262 100755 --- a/packages/acp-bridge/zcode-acp-bridge +++ b/packages/acp-bridge/zcode-acp-bridge @@ -1364,6 +1364,9 @@ class ACPBridge: js_add_node_module_dir 与 mcp__node_repl__js*): 仅禁 Write/Edit/ Bash 会被其 execSync 打穿 (0.16.1 实测先例, 见 README「只读原理」 与「重要限制」#7)。 + (#55) 未传 model 且 create 快照的 settings.model.available 为空列表 + (最小/未引导环境) 时, 打一行 stderr 告警 — ACP 路径 turn 会静默 + 停滞, 空目录是唯一确定性本地信号; 行为不变 (session 照常登记返回)。 """ self.ensure_backend() cwd = params.get("cwd") or os.getcwd() @@ -1425,6 +1428,26 @@ class ACPBridge: # ACP sessionId: 用 zcode 的或生成一个 acp_sid = zcode_sid # 格式兼容, 直接用 + # issue #55: 最小/未引导环境的确定性本地信号 — create 快照的模型目录 + # 为空。ACP 路径 turn 会静默停滞 (无 turn.failed、无错误文本可匹配), + # 故在创建时打一行 stderr 告警; 行为不变 (session 照常登记返回)。 + # 传了 model 参数的调用方由 _resolve_session_model 给出明确 -32602 + # (目录为空报「不在本会话的模型目录中」), 不重复告警。 + if model_param is None: + # settings 可能是真值非 dict (list/str): 先 isinstance 判空再取键, + # 防 AttributeError 把成功路径打成 -32603 (同 _resolve_session_model + # 的形态防御思路) + _settings = result.get("settings") + _settings_model = (_settings.get("model") + if isinstance(_settings, dict) else None) + _available = (_settings_model.get("available") + if isinstance(_settings_model, dict) else None) + if isinstance(_available, list) and not _available: + log("⚠ session/new: 模型目录为空 (settings.model.available=[]), " + "turn 可能停滞无法产出 — 请在宿主机完成模型引导 (见 README" + "「最小 headless 环境的模型引导」), 或 session/new 传 model " + "参数显式选模型") + # 可选 model: 归一后经 setModel 应用 (见 docstring 为何不走 create 形参) if model_param is not None: selection = self._resolve_session_model(model_param, result) @@ -1511,8 +1534,14 @@ class ACPBridge: if isinstance(model_param, dict): return model_param wanted = model_param.strip() - settings_model = ((create_result.get("settings") or {}).get("model") or {}) - available = settings_model.get("available") + # settings/settings.model 可能是真值非 dict (list/str): 先 isinstance + # 判空再取键, 与 session/new 空目录守卫同款防御 — 否则调用方传字符串 + # model 时会 AttributeError 打成 -32603, 且丢失已创建 sessionId。 + settings = create_result.get("settings") + settings_model = (settings.get("model") + if isinstance(settings, dict) else None) + available = (settings_model.get("available") + if isinstance(settings_model, dict) else None) # 容错: catalog 缺失/形态漂移按「不可用」报, 非 dict 条目跳过 (防 # AttributeError 把 -32602 变成 -32603 内部错) if not isinstance(available, list): diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index b9e0d15..fafe5ea 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -14,7 +14,7 @@ zcode-agent-help — ZCode 开放生态能力说明书 zcode-agent-help # 完整能力清单 (JSON) zcode-agent-help --pretty # 人类可读的格式化输出 zcode-agent-help --section # 只看某一节 - zcode-agent-help --print-injected-env # 诊断: 会注入哪些凭证 (脱敏, issue #3) + zcode-agent-help --print-injected-env # 诊断: 会注入哪些凭证 + 模型解析面 (脱敏, issue #3/#55) zcode-agent-help --help # 本帮助 可用 section: @@ -34,6 +34,12 @@ ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode") # 实测 "zai/GLM-5.2" 也兼容, 但与 shared/credentials.py 权威实现不一致, 故统一用原始 id。 ZCODE_CREDS_PATH = Path.home() / ".zcode" / "v2" / "config.json" +# issue #55: 模型解析面只读诊断用的官方 runtime 模型注册表 (桌面 App 完成 +# 模型选择后生成)。只读, 不写任何文件; 缺失/损坏按「未配置/不可解析」展示。 +ZCODE_PROVIDER_CONFIG_PATH = ( + Path.home() / ".zcode" / "v2" / "provider_config.json" +) + def _load_creds_internal(config_path=None): """内嵌的凭证读取 (源自 shared/credentials.py, 单文件可独立运行故内嵌)。 @@ -84,17 +90,76 @@ def _safe_host(url): return None -def print_injected_env(config_path=None): +def _read_model_selection(provider_config_path): + """只读 v2/provider_config.json (#55), 返回 (defaultModelSelection 串, providerOrder 首位)。 + + 容错契约: 文件缺失/权限错误/JSON 损坏/config 形状不符/选择对象缺字段 + → "(未配置/不可解析)"; defaultModelSelection 键存在但值为 null → "(未设置)"。 + 不抛错、不写任何文件 (可观测性最小诚实版: 只反映配置面, 不探测 runtime)。 + """ + unparsable = "(未配置/不可解析)" + try: + with open(provider_config_path) as f: + data = json.load(f) + except (OSError, ValueError): + # FileNotFoundError/PermissionError → OSError; JSONDecodeError → ValueError + return unparsable, None + config = data.get("config") if isinstance(data, dict) else None + if not isinstance(config, dict): + return unparsable, None + selection = config.get("defaultModelSelection") + if selection is None: + selection_text = "(未设置)" + elif isinstance(selection, dict): + pid = selection.get("providerId") + mid = selection.get("modelId") + if isinstance(pid, str) and pid and isinstance(mid, str) and mid: + selection_text = f"{pid}/{mid}" + else: + selection_text = unparsable + else: + selection_text = unparsable + order = config.get("providerOrder") + order_first = (order[0] if isinstance(order, list) and order + and isinstance(order[0], str) and order[0] else None) + return selection_text, order_first + + +def _print_model_resolution(creds, provider_config_path): + """打印「模型解析面」段 (只读配置面推断; #55)。 + + 层 1: v2/config.json enabled provider 的 model (桥的现注入口径); + 层 2: v2/provider_config.json 的 defaultModelSelection + providerOrder 首位 + (runtime 兜底解析顺序的第一个)。 + 固定免责: runtime 不回报实际使用的模型 id; 0.16.9 最小环境 env 注入不被 + 读取 — 不承诺做不到的事 (真实模型 id 事后不可得)。 + """ + print("\n模型解析面 (只读推断, 不写任何文件):") + layer1 = creds.get("ZCODE_MODEL") or "(未配置)" + print(f" 1. config.json enabled provider model: {layer1} (桥的现注入口径)") + selection, order_first = _read_model_selection(provider_config_path) + print(f" 2. provider_config.json defaultModelSelection: {selection}") + print(f" providerOrder 首位 (runtime 兜底解析顺序): " + f"{order_first or '(未设置)'}") + print(" 免责: runtime 不回报实际使用的模型 id; 以上为配置面推断。" + "0.16.9 最小环境 env 注入不被读取 (见 README「最小 headless 环境的模型引导」)") + + +def print_injected_env(config_path=None, provider_config_path=None): """打印"如果现在注入凭证, 会用哪些值"(脱敏), 用于不改 config 诊断 model/baseURL 来源。 显示每个 key 的来源 (config vs 显式 env) + 最终值 (apiKey 脱敏), - 体现"显式 env 优先"的合并语义 (issue #3 子项2)。 + 体现"显式 env 优先"的合并语义 (issue #3 子项2); 末尾附「模型解析面」段 + (层 1 config.json 模型 + 层 2 provider_config.json 的默认选择/顺序, #55)。 Args: config_path: 可选, 自定义配置文件路径 (测试用, 整体 review P2-3)。 默认 ZCODE_CREDS_PATH。 + provider_config_path: 可选, 模型注册表路径 (测试用, #55)。 + 默认 ZCODE_PROVIDER_CONFIG_PATH。 """ path = config_path or ZCODE_CREDS_PATH + provider_config_path = provider_config_path or ZCODE_PROVIDER_CONFIG_PATH creds = _load_creds_internal(path) keys = ["ZCODE_MODEL", "ZCODE_BASE_URL", "ANTHROPIC_API_KEY"] if not creds: @@ -103,6 +168,7 @@ def print_injected_env(config_path=None): env_hits = {k: v for k in keys if (v := os.environ.get(k))} if not env_hits: print(f"❌ 未从 {path} 读取到凭证 (config 无 enabled provider 或文件缺失)") + _print_model_resolution(creds, provider_config_path) return 0 print("=== 凭证注入诊断 (显式非空 env 优先于 config; 空串视为未设置) ===") print(f"config 源: {path}") @@ -110,6 +176,7 @@ def print_injected_env(config_path=None): for k, v in env_hits.items(): shown = _mask_key(v) if k == "ANTHROPIC_API_KEY" else v print(f" {k}: {shown} (来自 env)") + _print_model_resolution(creds, provider_config_path) return 0 # 读 config 里所有 provider 的 baseURL (残留检测用, host 集合) # 整体 review P1-1: 用与权威版逐字对齐的 _safe_host 构造, 消除诊断与真实注入的漂移 @@ -154,6 +221,7 @@ def print_injected_env(config_path=None): else: shown = _mask_key(config_val) if k == "ANTHROPIC_API_KEY" else config_val print(f" {k}: {shown} (来自 config)") + _print_model_resolution(creds, provider_config_path) return 0 @@ -209,8 +277,12 @@ NATIVE_CLI = { "ANTHROPIC_API_KEY": {"description": "API 密钥 (provider kind 为 anthropic)", "example": ""}, }, "config_note": ( - "~/.zcode/cli/config.json 的 model 字段在 --prompt 模式下不被读取, " - "必须用上述环境变量配置。凭证可从 ~/.zcode/v2/config.json 动态读取注入。" + "~/.zcode/cli/config.json 的 model 字段在 --prompt 模式下不被读取。" + "环境变量注入是条件性生效的: 0.16.9 官方 runtime 的最小环境实测中 " + "ZCODE_MODEL 等 env 完全不被读取 (报 Model creation failed), 模型选择" + "需宿主机 v2/provider_config.json 配置; 完整桌面配置机器上 env 是否" + "生效亦未被隔离验证 (见 README「最小 headless 环境的模型引导」)。" + "凭证可从 ~/.zcode/v2/config.json 动态读取注入。" ), "headless_modes": [ { @@ -808,7 +880,7 @@ def print_pretty(cap): # native_cli nc = cap["native_cli"] print("▼ 第一层: 原生 Headless CLI") - print(" 必需环境变量: ZCODE_MODEL / ZCODE_BASE_URL / ANTHROPIC_API_KEY") + print(" 凭证环境变量 (条件性生效, 详见下): ZCODE_MODEL / ZCODE_BASE_URL / ANTHROPIC_API_KEY") print(f" ⚠ {nc['config_note'][:60]}...\n") for m in nc["headless_modes"]: print(f" [{m['name']}] {m['description']}") diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 208ffb5..23c5750 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -213,6 +213,48 @@ def _extract_retry_after(sample): return None +# 引导失败解析 (issue #55): 源自 shared/provider_error.py, 修改时请同步。 +# 调用时机硬约束: 只在 _parse_provider_error 判 unknown 后调用 — 限流/配额 +# 分类优先; 指引文案泛称措辞, 指向 README「最小 headless 环境的模型引导」。 +_BOOTSTRAP_GUIDANCE = { + "model_selection": ( + "模型未引导: 官方 runtime 未读到可用模型 (Model creation failed)。" + "请在宿主机先完成一次模型选择 (桌面 App 登录并选择模型), 或配置 " + "~/.zcode/v2/provider_config.json 的 config.defaultModelSelection; " + "详见 README「最小 headless 环境的模型引导」。" + ), + "builtin_provider_config": ( + "内置 provider 配置缺失: 官方 runtime 未在调用入口旁找到 " + "provider/zcode-builtin.json。请把官方发行物中的该文件 (连同 " + "provider/ 目录) 放到调用入口旁; " + "详见 README「最小 headless 环境的模型引导」。" + ), +} + +_BOOTSTRAP_PATTERNS = ( + (re.compile(r"Model creation failed", re.IGNORECASE), "model_selection"), + (re.compile(r"无法定位 CLI ZCode Built-in Provider Config"), + "builtin_provider_config"), +) + + +def _parse_bootstrap_error(text): + """解析「模型/provider 未引导」特征错误 → {"kind","guidance"} 或 None。 + + 与 shared/provider_error.py.parse_bootstrap_error 逐字同源 (单文件可独立 + 运行故内嵌, 副本同步测试在 tests/test_provider_error.py)。只认两类稳定 + 特征串 (特征串匹配, 任意位置; 覆盖 .deb 构建的 "Model creation failed: + Select a model before continuing" 变体); 不命中返回 None。 + """ + if not text: + return None + sample = text if len(text) <= 2000 else text[:2000] # 只看头部, 与限流解析同口径 + for pattern, kind in _BOOTSTRAP_PATTERNS: + if pattern.search(sample): + return {"kind": kind, "guidance": _BOOTSTRAP_GUIDANCE[kind]} + return None + + # 1308 = z.ai 5 小时额度耗尽业务码 (issue #35): 解析 reset 时间作结构化 # 载荷附在 zcode_pr_review 的 result 上, 下游 (review-gate 额度墙) 直读 # 不猜。副本源自 shared/provider_error.py (正则/时区规则与其逐字同源, @@ -1237,7 +1279,15 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): 1308 额度错误, result 顶层附 "quota_limit": {"code": 1308, "reset_at": }; 子进程超时附 "timeout": {"seconds": }; 锁等待超时附 "lock_timeout": {"seconds": } (均 #54); 成功时有 usage 时附 - "usage": {"total_tokens": }。其余 review tool 的 result 形状保持不变。 + "usage": {"total_tokens": }。 + bootstrap (#55) 与 structured_output 无关: 任一 review tool 的失败在既有 + provider 分类为 unknown 且 stderr 命中模型/provider 未引导特征时, result + 顶层无条件(不设 structured_output 门控)附 + "bootstrap": {"kind": "model_selection"|"builtin_provider_config"} + (三 tool 形状一致, 对齐 refusal 先例), 错误文本前置修复指引。 + 其余情况下 result 形状保持不变 — 例外: rc=0 + stderr 命中引导特征 + + stdout 非合法 JSON 结果时同样判失败并附 bootstrap 键 (见下方 has_error + 判定; 合法 JSON 结果仍按成功处理)。 """ # 有限重试配置 (issue #3 子项3c): 仅对 provider 限流错误重试, 退避指数。 # clamp 到 >=0, 防止 ZCODE_BRIDGE_MAX_RETRIES=-1 导致不执行 zcode (Codex P2) @@ -1259,17 +1309,27 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): # 成限流而白重试, 甚至把成功结果截断成报错)。zcode headless 错误 # 常在 stderr (APICallError 堆栈), stdout 是审查结论正文。 err_info = _parse_provider_error(result.stderr or "") + # 引导失败分类 (#55): 仅在既有 provider 分类判 unknown 后 + # 查 — 限流/配额优先级不变。命中的 stderr 同样计入 + # stderr_matches, 沿用"stdout 非合法 --json 结果才算错误" + # 的保守判定 (覆盖 rc=0 + 错误在 stderr、stdout 非合法 + # JSON 的变体)。 + bootstrap = (_parse_bootstrap_error(result.stderr or "") + if err_info["error_kind"] == "unknown" else None) # has_error 判定 (整体 review P1-1 + 复审 P1-A): stderr 匹配 # 错误关键词时, 仅当 stdout 不是合法 --json 结果才判错误。 # zcode 常往 stderr 打 Node deprecation 警告, 恰好含 "Unauthorized" # 等词时会误杀成功调用; 但"stdout 非空"也可能是错误回显文本, # 所以判据是"能否解析出 response 结果"而非"非空"。 - stderr_matches = bool(result.stderr) \ - and err_info["error_kind"] != "unknown" + stderr_matches = bool(result.stderr) and ( + err_info["error_kind"] != "unknown" + or bootstrap is not None) has_error = result.returncode != 0 or ( stderr_matches and _parse_result_json(output) is None) if stderr_matches and not has_error and result.returncode == 0: - log(f"⚠ stderr 含 {err_info['error_kind']} 关键词但 stdout " + matched = (f"bootstrap:{bootstrap['kind']}" if bootstrap + else err_info["error_kind"]) + log(f"⚠ stderr 含 {matched} 关键词但 stdout " "是合法结果, 按成功处理 (便于事后排查边界)") if has_error and err_info["is_rate_limit"] and attempt < max_retries: @@ -1284,10 +1344,21 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): # 非限流错误, 或重试耗尽 → 返回错误 (重试过的话附说明) suffix = f" (已重试 {attempt} 次)" if attempt > 0 else "" err_text = result.stderr.strip() or output or "未知错误" + if bootstrap: + # 引导失败 (#55): 错误文本前置分类提示 + 指引块 + # (原始 stderr 摘要保留在尾部, 仍受下方 500 字符截断) + err_text = (f"引导失败 ({bootstrap['kind']}): " + f"{bootstrap['guidance']}\n" + f"原始错误: {err_text}") # 提取简短错误 (脱敏: provider 错误一般不含 apiKey, 但截断防泄漏) err_result = {"content": [{"type": "text", "text": f"zcode 调用失败{suffix}: {err_text[:500]}"}], "isError": True} + if bootstrap: + # 结构化引导键 (#55): 无条件附加 (与 refusal 先例 + # 一致, 不设 structured_output 门控) — 三个 review + # tool 形状一致, review-gate 只认此键判终态告警。 + err_result["bootstrap"] = {"kind": bootstrap["kind"]} # 1308 结构化载荷 (#35): 从完整 stderr 解析 (上面 content # 里的 err_text 已截 500 字符, 尾部时间串可能被切掉); # isError 文本本身一字不动保留 1308 原文 — 旧 gate 的 diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index 7f01126..07616c0 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -664,6 +664,10 @@ def run_review(cfg, clone, base_ref, head_sha): 命中 1308 才有 (mcp-server 侧已完成北京 naive → unix 转换); refusal: str | None — 安全策略拒审 (issue #49 洞三) 时 mcp-server 护栏写入的原因串 (如 "project_config"), 机器契约只此一处; + bootstrap: dict | None — 审查环境未引导 (issue #55, 模型/provider + 配置缺失) 时 mcp-server 写入的 {"kind": "model_selection"| + "builtin_provider_config"}; process_pr 据此走终态告警 (与 refusal + 同型), 形状校验 (dict + 非空 str kind) 在消费端; total_tokens: int — 成功时 usage.total_tokens 透传, 无则 0。 子进程异常/非 JSON 输出时为空 info。quota_limit 原样透传 (键缺失为 None), 合法性校验全在消费端 (quota_limit_reset_ts), 这里不猜不修。 @@ -679,7 +683,8 @@ def run_review(cfg, clone, base_ref, head_sha): json.dumps(args, ensure_ascii=False)] env = dict(os.environ) env["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = str(ZCODE_REVIEW_TIMEOUT) - info = {"quota_limit": None, "total_tokens": 0, "refusal": None} + info = {"quota_limit": None, "total_tokens": 0, "refusal": None, + "bootstrap": None} try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=REVIEW_TIMEOUT, env=env) @@ -699,6 +704,9 @@ def run_review(cfg, clone, base_ref, head_sha): # 拒审原因串原样透传 (#49): 只有 mcp-server 的项目配置护栏会写这个键, # 正文文本不参与判定 (伪造面, R1 P2-2) info["refusal"] = result.get("refusal") + # 引导失败键原样透传 (#55): 与 refusal 同理只认结构化键 (文本不参与 + # 判定); 形状校验见 process_pr 的消费点 + info["bootstrap"] = result.get("bootstrap") usage = result.get("usage") if isinstance(usage, dict): tokens = usage.get("total_tokens") @@ -747,6 +755,11 @@ def parse_verdict_marker(report): # 终态拒审 (跳过 5 次重试 + 假告警); 结构化键只有 mcp-server 护栏会写。 # 拒审按终态处理 (gave_up + 🚫 告警评论), 不当临时失败退避重试: # 重试不会变, 且 5 次后静默 gave_up 会吞掉"有人在投毒"的信号。 +# +# 引导失败 (#55) 走同一套终态机制: mcp-server 检测到模型/provider 未配置 +# (模型未引导/内置 provider 文件缺失) 时在 isError result 顶层附 +# "bootstrap": {"kind": ...}。同理由: 环境问题重试不可能自愈, 而 5 次退避后 +# 静默 gave_up 会让"闸门从未真正审查过"无人知晓 — 正是 #55 的动机场景。 def build_refusal_comment(head_sha, detail): @@ -768,11 +781,42 @@ def build_refusal_comment(head_sha, detail): ) -def _post_refusal_alert(cfg, token, entry, key, head_sha, owner, repo, - pr_number, text, now): - """发拒审告警评论; 发不出去则缓存原文转 comment_failed 等下轮补发。 +def build_bootstrap_comment(head_sha, detail): + """审查环境未引导告警评论体 (#55; 供人工立即看见, 不参与 verdict 解析)。 - 两个调用点共用 (首发于拒审分支 / 补发于 comment_failed 重试轮), + 与拒审告警同型 (终态 + 立即可见), 文案区分: 这是运维/环境问题, + 非 PR 代码问题; 修复指引在 mcp-server 错误文本与 README 新节。 + 脱敏: builtin_provider_config 的 stderr 特征行带宿主候选绝对 + 路径 ("无法定位 CLI ZCode Built-in Provider Config:<路径1>, <路径2>"), + 评论发到公开 PR 前必须抹掉 — 只保留特征前缀 + 占位符 (原始 detail 仍 + 完整保存在本地 state, 不受影响)。 + """ + detail = re.sub(r"(无法定位 CLI ZCode Built-in Provider Config)[^\n]*", + r"\1:<候选路径已省略>", detail or "") + return ( + "## ⚠️ 审查未执行(审查环境未引导)\n\n" + "审查子进程无法启动模型:宿主机未完成模型/provider 配置,或缺少" + "内置 provider 配置文件(`provider/zcode-builtin.json`)——属**运维/" + "环境问题,非本 PR 代码问题**。修复指引见 zcode-open-bridge README" + "「最小 headless 环境的模型引导」(mcp-server 返回的错误文本中附有" + "同样的指引)。\n\n" + f"- head: `{(head_sha or '')[:12]}`\n" + "- 同 head 不再重试;推送新 commit 会自动重新触发审查。\n" + "- 修复环境后如需立即重审,可推送一个新 commit(或删除 state 中该 " + "PR 的记录)。\n\n" + "
原始错误输出\n\n" + f"```\n{detail[:1500]}\n```\n\n
" + ) + + +def _post_terminal_alert(cfg, token, entry, key, head_sha, owner, repo, + pr_number, text, now, builder): + """发终态告警评论 (拒审/审查环境未引导); 失败则缓存原文转 comment_failed。 + + 调用点共用 (首发于终态判定分支 / 补发于 comment_failed 重试轮), + builder 决定评论文案 (build_refusal_comment / build_bootstrap_comment); + 补发轮凭 entry 的 refusal/bootstrap 字段选回同一 builder — 走通用补评论 + 路径会把终态告警销毁成 verdict "concerns" 评论并错误翻转状态。 失败口径与正常评论路径一致: - RateLimited (R1 P1-1): 仓级事件不烧 attempts, 缓存 + comment_failed 后上抛停本轮 — 早期版本只捕 Retryable/RepoHard, RateLimited 穿透时 @@ -783,12 +827,12 @@ def _post_refusal_alert(cfg, token, entry, key, head_sha, owner, repo, comment_failed, 状态见 entry)。 """ if not (cfg.comment_enabled and token): - log(f"{key}: 拒审告警未发 (评论禁用或无 token), 详见 state/日志") + log(f"{key}: 终态告警未发 (评论禁用或无 token), 详见 state/日志") return False try: resp = post_comment(token, cfg.github_api, owner, repo, pr_number, - build_refusal_comment(head_sha, text)) - log(f"{key}: 拒审告警已发布 {(resp or {}).get('html_url') or ''}") + builder(head_sha, text)) + log(f"{key}: 终态告警已发布 {(resp or {}).get('html_url') or ''}") return True except RateLimited: entry["report"] = text[:cfg.comment_max_body] @@ -797,7 +841,7 @@ def _post_refusal_alert(cfg, token, entry, key, head_sha, owner, repo, raise except (RetryableError, RepoHardError) as e: entry["report"] = text[:cfg.comment_max_body] - mark_failure(entry, f"拒审告警发布失败: {e}", now, cfg, + mark_failure(entry, f"终态告警发布失败: {e}", now, cfg, status="comment_failed") return False @@ -1407,19 +1451,29 @@ def process_pr(cfg, state, token, owner, repo, clone, info): # 审查已成功过、只是评论没发出去: 沿用缓存结果, 只补评论, # 不为一次评论失败重跑昂贵的 zcode 审查 report = entry["report"] + alert_builder = None if entry.get("refusal"): # 拒审告警补发 (R1 P1-2): 缓存的是拒审文案, 补发的必须是拒审 # 评论 — 走下方通用路径会把安全拒审销毁成 "concerns 质量告警" # 并翻成 reviewed, 下游读 state 拿到错误结论。 - if _post_refusal_alert(cfg, token, entry, key, head_sha, owner, - repo, info["number"], report, now): + alert_builder = build_refusal_comment + elif entry.get("bootstrap"): + # 引导失败告警补发 (#55): 与拒审同型 — 缓存的是引导告警文案, + # 补发必须走 build_bootstrap_comment; 走通用路径会对一次从未 + # 运行的审查发 "concerns" 评论并翻成 reviewed, 同 R1 P1-2 的 + # 销毁形态。 + alert_builder = build_bootstrap_comment + if alert_builder is not None: + if _post_terminal_alert(cfg, token, entry, key, head_sha, owner, + repo, info["number"], report, now, + alert_builder): entry["status"] = "gave_up" # 补发成功, 回终态 entry["next_retry_at"] = 0.0 entry["report"] = None elif entry["status"] != "comment_failed": # 未尝试 (评论被禁用/无 token): 烧退避老化, 不无限空转; # 缓存保留, gave_up 后仍可供人工排查 - mark_failure(entry, "拒审告警未发 (评论禁用或无 token)", + mark_failure(entry, "终态告警未发 (评论禁用或无 token)", now, cfg, status="comment_failed") return verdict = entry.get("verdict") or "concerns" @@ -1465,8 +1519,28 @@ def process_pr(cfg, state, token, owner, repo, clone, info): entry["next_retry_at"] = 0.0 log(f"{key}: ⛔ 拒审 (refusal: {refused}), 终态 " f"(head={head_sha[:12]}) — 发告警评论") - _post_refusal_alert(cfg, token, entry, key, head_sha, owner, - repo, info["number"], payload, now) + _post_terminal_alert(cfg, token, entry, key, head_sha, owner, + repo, info["number"], payload, now, + build_refusal_comment) + return + # 引导失败 (#55): 与拒审同型终态 — 环境问题重试不可能自愈, + # 且 5 次退避后静默 gave_up 会让"审查从未运行"无人知晓。只认 + # mcp-server 写的结构化 bootstrap 键 (文本不参与判定, 与 refusal + # 同一伪造面论证); 形状不符按普通失败退避 (fail-safe)。 + bootstrap = rinfo.get("bootstrap") + bootstrap_kind = (bootstrap.get("kind") + if isinstance(bootstrap, dict) else None) + if isinstance(bootstrap_kind, str) and bootstrap_kind: + entry["error"] = payload[:500] + entry["verdict"] = None + entry["bootstrap"] = {"kind": bootstrap_kind} + entry["status"] = "gave_up" + entry["next_retry_at"] = 0.0 + log(f"{key}: ⚠ 审查环境未引导 (bootstrap: {bootstrap_kind}), " + f"终态 (head={head_sha[:12]}) — 发告警评论") + _post_terminal_alert(cfg, token, entry, key, head_sha, owner, + repo, info["number"], payload, now, + build_bootstrap_comment) return # 判据优先级 (#35①): 结构化 quota_limit 键优先 (mcp-server 侧已转 # unix 秒), 键缺失/形状不符退正则兜底 (旧 mcp-server 兼容)。两条 diff --git a/shared/provider_error.py b/shared/provider_error.py index 700bb85..d4e8f54 100644 --- a/shared/provider_error.py +++ b/shared/provider_error.py @@ -17,6 +17,10 @@ 另有 parse_quota_1308: 解析 z.ai 1308 (5 小时额度耗尽) 业务码的 reset 时间, 供 mcp-server 结构化输出 (review-gate 的 1308 额度墙消费)。 +另有 parse_bootstrap_error: 解析「模型/provider 未引导」特征错误 (issue #55), +供 mcp-server 返回结构化 bootstrap 键 + 修复指引。调用时机有硬约束 — +只在 parse_provider_error 判 unknown 后调用 (限流/配额分类优先)。 + 用法: from shared.provider_error import parse_provider_error info = parse_provider_error(stderr_text) @@ -95,6 +99,55 @@ def parse_provider_error(text): "error_kind": "unknown", "raw_hint": raw_hint} +# ============================================================ +# 引导失败解析 (issue #55): 最小 headless 环境里官方 runtime 未拿到模型/ +# provider 配置时, --prompt 调用以特征错误退出 (实测: exit≠0, stderr 单行)。 +# 两类稳定特征: +# - "Model creation failed" → 模型选择缺失 (含 .deb 变体后缀) +# - "无法定位 CLI ZCode Built-in Provider Config" → 入口旁缺内置 provider 文件 +# 调用时机硬约束: 只在 parse_provider_error 判 unknown 后调用 — 限流/配额 +# 分类优先级不变, 杜绝把限流误判成引导问题 (以及反向)。 +# 指引文案为泛称措辞, 指向 README「最小 headless 环境的模型引导」。 +# ============================================================ +BOOTSTRAP_GUIDANCE = { + "model_selection": ( + "模型未引导: 官方 runtime 未读到可用模型 (Model creation failed)。" + "请在宿主机先完成一次模型选择 (桌面 App 登录并选择模型), 或配置 " + "~/.zcode/v2/provider_config.json 的 config.defaultModelSelection; " + "详见 README「最小 headless 环境的模型引导」。" + ), + "builtin_provider_config": ( + "内置 provider 配置缺失: 官方 runtime 未在调用入口旁找到 " + "provider/zcode-builtin.json。请把官方发行物中的该文件 (连同 " + "provider/ 目录) 放到调用入口旁; " + "详见 README「最小 headless 环境的模型引导」。" + ), +} + +_BOOTSTRAP_PATTERNS = ( + (re.compile(r"Model creation failed", re.IGNORECASE), "model_selection"), + (re.compile(r"无法定位 CLI ZCode Built-in Provider Config"), + "builtin_provider_config"), +) + + +def parse_bootstrap_error(text): + """解析「模型/provider 未引导」特征错误, 返回 {"kind", "guidance"} 或 None。 + + 只认上述两类稳定特征串 (特征串匹配, 任意位置; 覆盖 Linux .deb 构建的 + "Model creation failed: Select a model before continuing" 变体); + 不命中返回 None。注意调用时机: 仅在 parse_provider_error 判 unknown 后 + 查 (限流/配额等已分类错误不做引导判定)。 + """ + if not text: + return None + sample = text if len(text) <= 2000 else text[:2000] # 只看头部, 与限流解析同口径 + for pattern, kind in _BOOTSTRAP_PATTERNS: + if pattern.search(sample): + return {"kind": kind, "guidance": BOOTSTRAP_GUIDANCE[kind]} + return None + + def _extract_hint(sample): """从错误文本提取简短上下文 (跳过堆栈行, 脱敏)。""" for line in sample.splitlines(): diff --git a/tests/test_app_server_methods.py b/tests/test_app_server_methods.py index 0ddb8be..377680d 100644 --- a/tests/test_app_server_methods.py +++ b/tests/test_app_server_methods.py @@ -11,6 +11,8 @@ V 信封: ACP 侧保留 jsonrpc (ACP 协议不变), zcode 侧新信封无 jsonrpc 键 (V2 params 净身 / V3 真实 request() 帧构造断言) C session/new → session/create (cwd → workspace{workspacePath,workspaceKey}) + · 可选 model (#52) · toolAllowlist/toolDenylist (#53) + · 空模型目录 stderr 告警 (#55, 行为不变; C4k/C4l) EV 事件/轮询模式选择 · 事件分支: subscribe 必传 deliveryKind → 事件模式 (不轮询); 轮询降级分支见 test_polling_failure.py PF3 PM 协议模式 (legacy/v16): _detect_protocol 三分支探测 + 实锤标志 @@ -525,6 +527,88 @@ def test_c4j_new_model_catalog_malformed_entries(self): {"providerId": "bigmodel-api", "modelId": "GLM-5.3"}, "畸形条目跳过, 合法条目不补 reasoningLevel") + def test_c4k_new_empty_catalog_warns_and_succeeds(self): + """C4k (#55): 未传 model + create 快照 available=[] (最小环境) → + stderr 一行告警, session/new 仍成功并登记 (行为不变); 传了 model + 的路径由「模型目录不可用」-32602 覆盖, 不重复告警""" + empty_catalog = {"sessionId": "sess_empty", + "settings": {"model": {"available": []}}} + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": empty_catalog}}, + }) + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + resp = self._call(bridge, "session/new", {"cwd": "/p"}) + self._assert_ok(resp) + self.assertEqual(resp["result"]["sessionId"], "sess_empty") + self.assertIn("sess_empty", bridge.session_map, "行为不变: 会话照常登记") + self.assertIn("模型目录为空", captured.getvalue()) + self.assertIn("模型引导", captured.getvalue(), "告警应指向 README 新节") + + # 传 model 的调用方已有明确 -32602, 不再叠加空目录告警 + bridge2, _ = self._new_bridge({ + "session/create": {"response": {"result": dict(empty_catalog)}}, + }) + captured2 = io.StringIO() + with contextlib.redirect_stderr(captured2): + resp2 = self._call(bridge2, "session/new", + {"cwd": "/p", "model": "GLM-5.3"}) + self._assert_error_code(resp2, -32602) + self.assertIn("不在本会话的模型目录中", resp2["error"]["message"]) + self.assertNotIn("模型目录为空", captured2.getvalue(), + "model 参数路径不重复告警") + + def test_c4l_new_nonempty_or_missing_catalog_no_warn(self): + """C4l: catalog 非空 / 缺 settings.model.available 键 → 无空目录告警 + (只认空列表这一确定性信号, 缺失/形状漂移不误报)""" + for create_result in (dict(self._CATALOG), + {"sessionId": "sess_nokey"}): + with self.subTest(create_result=create_result): + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": create_result}}, + }) + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + resp = self._call(bridge, "session/new", {"cwd": "/p"}) + self._assert_ok(resp) + self.assertNotIn("模型目录为空", captured.getvalue()) + + def test_c4m_new_malformed_settings_no_crash(self): + """C4m (P3-6): settings 为真值非 dict (list/str/int) → 不 AttributeError, + session/new 照常成功、无空目录告警""" + for settings in (["bogus"], "bogus", 42): + with self.subTest(settings=settings): + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": { + "sessionId": "sess_badsettings", + "settings": settings}}}, + }) + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + resp = self._call(bridge, "session/new", {"cwd": "/p"}) + self._assert_ok(resp) + self.assertIn("sess_badsettings", bridge.session_map) + self.assertNotIn("模型目录为空", captured.getvalue()) + + def test_c4n_new_string_model_malformed_settings_32602(self): + """C4n: 字符串 model + settings 为真值非 dict → 本地 -32602 + 「模型目录不可用」(带已创建 sessionId 供收编), 而非 AttributeError + 打成 -32603 内部错; 该会话不登记""" + bridge, _ = self._new_bridge({ + "session/create": {"response": {"result": { + "sessionId": "sess_badsettings", + "settings": ["bogus"]}}}, + }) + 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) + self.assertIn("sess_badsettings", msg, + "文案应带已创建 sessionId 供客户端收编") + self.assertNotIn("sess_badsettings", bridge.session_map, + "model 未生效的会话不得登记") + # ---------- C5: session/new 工具名单透传 (只读监督的会话级前置手段) ---------- def test_c5_new_toollists_normalized_into_create(self): """C5: 合法 toolAllowlist/toolDenylist → 归一 (逐条 strip) 后并入 diff --git a/tests/test_credentials.py b/tests/test_credentials.py index dc4ce2b..08280dc 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -442,6 +442,15 @@ def setUpClass(cls): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) + # 隔离宿主耦合: ZCODE_PROVIDER_CONFIG_PATH 在 import 时按真实 HOME + # 求值, 未显式传 provider_config_path 的用例 (C12d/e/j) 会读开发者 + # 真实 ~/.zcode/v2/provider_config.json — 统一指向临时目录 (P3-7) + self._saved_pc_path = self.ah.ZCODE_PROVIDER_CONFIG_PATH + self.ah.ZCODE_PROVIDER_CONFIG_PATH = os.path.join( + self._tmp.name, "no-provider-config.json") + self.addCleanup( + lambda: setattr(self.ah, "ZCODE_PROVIDER_CONFIG_PATH", + self._saved_pc_path)) def _run_main(self, argv): """调 agent-help main(), 返回 (rc, stdout, stderr)。""" @@ -571,6 +580,87 @@ def test_c12j_stale_env_diagnosed(self): self.assertEqual(rc, 0) self.assertIn("🚫 残留", out.getvalue()) + # ---------- C12k+: 模型解析面 (#55, agent-help 可观测性最小诚实版) ---------- + + def _run_print_env(self, cfg_path, provider_cfg_path): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = self.ah.print_injected_env( + config_path=cfg_path, provider_config_path=provider_cfg_path) + return rc, out.getvalue() + + def test_c12k_model_resolution_layers(self): + """C12k: 层 1 显示 config.json 注入模型; 层 2 显示 + defaultModelSelection ({providerId}/{modelId}) + providerOrder 首位; + 免责行明说 runtime 不回报实际模型 id""" + cfg_path = _write_isolated_config(self._tmp.name) + _isolated_env(self, self._tmp.name) + pc_path = os.path.join(self._tmp.name, "provider_config.json") + with open(pc_path, "w") as f: + json.dump({"config": { + "defaultModelSelection": {"providerId": "bigmodel-api", + "modelId": "GLM-5.3"}, + "providerOrder": ["bigmodel-api", "other"], + }}, f) + rc, text = self._run_print_env(cfg_path, pc_path) + self.assertEqual(rc, 0) + self.assertIn("模型解析面", text) + self.assertIn("GLM-5.2", text, "层 1 应显示 config.json 的模型") + self.assertIn("bigmodel-api/GLM-5.3", text) + self.assertIn("providerOrder 首位", text) + self.assertIn("runtime 不回报实际使用的模型 id", text) + self.assertIn("env 注入不被读取", text) + + def test_c12m_module_provider_config_path_used(self): + """P3-7: 不传 provider_config_path 时读模块常量 (setUp 已隔离到临时 + 目录) — 写哨兵值到该路径, 断言输出反映哨兵而非宿主真实文件""" + cfg_path = _write_isolated_config(self._tmp.name) + _isolated_env(self, self._tmp.name) + with open(self.ah.ZCODE_PROVIDER_CONFIG_PATH, "w") as f: + json.dump({"config": {"defaultModelSelection": { + "providerId": "sentinel-provider", + "modelId": "sentinel-model"}}}, f) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = self.ah.print_injected_env(config_path=cfg_path) + self.assertEqual(rc, 0) + self.assertIn("sentinel-provider/sentinel-model", out.getvalue()) + + def test_c12l_missing_corrupt_unset_and_malformed(self): + """C12l: 文件缺失/JSON 损坏/形状不符 → 「未配置/不可解析」; 键存在但 + 未设置 → 「未设置」; 四态都不抛错且不写文件""" + cfg_path = _write_isolated_config(self._tmp.name) + _isolated_env(self, self._tmp.name) + missing = os.path.join(self._tmp.name, "no-such-provider.json") + rc, text = self._run_print_env(cfg_path, missing) + self.assertEqual(rc, 0) + self.assertIn("(未配置/不可解析)", text) + self.assertFalse(os.path.exists(missing), "诊断不得写任何文件") + + corrupt = os.path.join(self._tmp.name, "corrupt.json") + with open(corrupt, "w") as f: + f.write("{not valid json") + rc, text = self._run_print_env(cfg_path, corrupt) + self.assertEqual(rc, 0) + self.assertIn("(未配置/不可解析)", text) + with open(corrupt) as f: + self.assertEqual(f.read(), "{not valid json", "诊断不得改文件") + + unset = os.path.join(self._tmp.name, "unset.json") + with open(unset, "w") as f: + json.dump({"config": {"providerOrder": ["p1", "p2"]}}, f) + rc, text = self._run_print_env(cfg_path, unset) + self.assertEqual(rc, 0) + self.assertIn("(未设置)", text) + self.assertIn("p1", text, "providerOrder 首位应显示") + + malformed = os.path.join(self._tmp.name, "malformed.json") + with open(malformed, "w") as f: + json.dump({"config": {"defaultModelSelection": {"providerId": 123}}}, f) + rc, text = self._run_print_env(cfg_path, malformed) + self.assertEqual(rc, 0) + self.assertIn("(未配置/不可解析)", text) + # ============================================================ # C13: models={} 兜底分支对拍 (双审遗漏项) diff --git a/tests/test_mcp_retry_lock.py b/tests/test_mcp_retry_lock.py index 383bde6..188f793 100644 --- a/tests/test_mcp_retry_lock.py +++ b/tests/test_mcp_retry_lock.py @@ -454,6 +454,171 @@ def test_rt8_stdout_size_cap(self): os.environ["ZCODE_BRIDGE_MAX_OUTPUT"] = old_mo +class TestBootstrapGuidance(unittest.TestCase): + """(#55) 引导失败: 特征错误 → isError + 顶层 bootstrap 键 + 指引文本; + 只调用 1 次 (unknown 不重试); 限流分类优先级/合法 JSON 防误杀不回归。""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + # 合成夹具: traceId 全零样式 (不涉真实环境) + _STDERR_MODEL = ("Error: Model creation failed " + "(traceId: 00000000-0000-4000-8000-000000000001)") + # 混合文本: 同时含限流与引导特征 — 钉分类互斥 (限流优先) + _MIXED_RATE_BOOTSTRAP = ( + "429 Too Many Requests: Model creation failed " + "(traceId: 00000000-0000-4000-8000-000000000000)") + + def _run_review(self, procs, max_retries=None): + """patch subprocess.run 返回序列 + sleep no-op + 关锁, 调 zcode_review。 + + 返回 (result, calls); 仿 TestRetryLogic 的 patch 手法, 独立实现以 + 避免继承触发父类全部用例重复执行。 + """ + mod = self.mod + calls = {"n": 0} + saved_run = mod.subprocess.run + saved_sleep = mod.time.sleep + saved = {k: os.environ.get(k) for k in + ("ZCODE_BRIDGE_REVIEW_LOCK", "ZCODE_BRIDGE_MAX_RETRIES")} + + def fake_run(*a, **kw): + i = calls["n"] + calls["n"] += 1 + return procs[min(i, len(procs) - 1)] + + os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0" + if max_retries is not None: + os.environ["ZCODE_BRIDGE_MAX_RETRIES"] = str(max_retries) + mod.subprocess.run = fake_run + mod.time.sleep = lambda _s: None + try: + result = mod.tool_zcode_review({"code": "print('x')"}) + finally: + mod.subprocess.run = saved_run + mod.time.sleep = saved_sleep + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + return result, calls + + def test_bg0_model_creation_failed_terminal(self): + """BG0: exit=1 + stderr 单行 Model creation failed → isError + + bootstrap.kind=model_selection + 文本含指引; 恰调用 1 次 (不重试)""" + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=1, stdout="", + stderr=self._STDERR_MODEL)]) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), {"kind": "model_selection"}) + text = result["content"][0]["text"] + self.assertIn("模型未引导", text) + self.assertIn("README", text) + self.assertIn("原始错误", text, "原始 stderr 摘要应保留") + self.assertEqual(calls["n"], 1, "引导失败不重试") + + def test_bg1_deb_variant(self): + """BG1: Linux .deb 构建变体 'Select a model before continuing' → 同分类""" + result, calls = self._run_review([ + _FakeCompletedProcess( + returncode=1, stdout="", + stderr="Model creation failed: Select a model before continuing")]) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), {"kind": "model_selection"}) + self.assertEqual(calls["n"], 1) + + def test_bg2_builtin_provider_config(self): + """BG2: 无法定位 CLI ZCode Built-in Provider Config → 另一分类 + 指引""" + result, calls = self._run_review([ + _FakeCompletedProcess( + returncode=1, stdout="", + stderr="无法定位 CLI ZCode Built-in Provider Config:" + "/x/provider/zcode-builtin.json, " + "/y/config/provider/zcode-builtin.json")]) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), + {"kind": "builtin_provider_config"}) + self.assertIn("zcode-builtin.json", result["content"][0]["text"]) + self.assertEqual(calls["n"], 1) + + def test_bg3_valid_json_stdout_not_killed(self): + """BG3: 防误杀 — exit=0 + stdout 合法 --json 结果 + stderr 含特征串 + → 成功, 无 bootstrap 键""" + payload = json.dumps({"response": "审查结论: OK"}, ensure_ascii=False) + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=0, stdout=payload, + stderr=self._STDERR_MODEL)]) + self.assertNotIn("isError", result) + self.assertNotIn("bootstrap", result) + self.assertIn("审查结论", result["content"][0]["text"]) + self.assertEqual(calls["n"], 1) + + def test_bg3b_nonjson_stdout_with_feature_is_error(self): + """BG3b (P2-2 行为钉子): exit=0 + stdout 非合法 JSON + stderr 命中 + 引导特征 → 判失败并附 bootstrap 键。这是 stderr_matches 扩展 + (`or bootstrap is not None`) 的静默行为点 — 删掉该扩展此例会退回 + "成功空输出" (isError 缺失), 全套测试不再全绿。""" + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=0, stdout="非 JSON 原始输出", + stderr=self._STDERR_MODEL)]) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), {"kind": "model_selection"}) + self.assertEqual(calls["n"], 1) + + def test_bg4_rate_limit_priority_unchanged(self): + """BG4: 分类互斥 — 429 限流仍走重试路径 (不被误判成引导终态), + 重试后成功且无 bootstrap 键; error_kind 实际值 "rate_limit" (下划线)""" + # 分类器级断言: 限流分类优先, 引导特征不命中 (防两类互吃) + self.assertEqual(self.mod._parse_provider_error("429 Too Many Requests") + ["error_kind"], "rate_limit") + self.assertIsNone( + self.mod._parse_bootstrap_error("429 Too Many Requests")) + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=0, stdout="", + stderr="429 Too Many Requests"), + _FakeCompletedProcess(returncode=0, stdout="审查结论: OK", stderr="")]) + self.assertNotIn("isError", result) + self.assertNotIn("bootstrap", result) + self.assertEqual(calls["n"], 2, "限流应照旧重试 (共 2 次调用)") + + def test_bg4b_mixed_rate_limit_wins_no_bootstrap(self): + """BG4b (P2-1 守卫钉子 a): stderr 同时含限流与引导特征 → 既有分类 + rate_limit 优先; MAX_RETRIES=0 时不重试、无 bootstrap 键、错误文本 + 不得出现「引导失败」前缀 (删掉 unknown 守卫此例会红)。""" + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=0, stdout="", + stderr=self._MIXED_RATE_BOOTSTRAP)], + max_retries=0) + self.assertTrue(result.get("isError")) + self.assertNotIn("bootstrap", result) + self.assertNotIn("引导失败", result["content"][0]["text"], + "限流分类优先, 不得按引导失败渲染错误文本") + self.assertEqual(calls["n"], 1, "max_retries=0 → 仅 1 次调用") + + def test_bg4c_mixed_rate_limit_retries_without_bootstrap(self): + """BG4c (P2-1 守卫钉子 b): 同混合文本在可重试配置下照旧走限流重试 + (共 2 次调用), 仍无 bootstrap 键 (分类互斥不回归)。""" + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=0, stdout="", + stderr=self._MIXED_RATE_BOOTSTRAP), + _FakeCompletedProcess(returncode=0, stdout="审查结论: OK", stderr="")], + max_retries=1) + self.assertNotIn("isError", result) + self.assertNotIn("bootstrap", result) + self.assertEqual(calls["n"], 2, "限流路径应重试 (共 2 次调用)") + + def test_bg5_unknown_error_no_key(self): + """BG5: 其他 unknown 错误 → isError 但不加 bootstrap 键 (形状不变)""" + result, calls = self._run_review([ + _FakeCompletedProcess(returncode=1, stdout="", + stderr="some totally unknown failure")]) + self.assertTrue(result.get("isError")) + self.assertNotIn("bootstrap", result) + self.assertEqual(calls["n"], 1) + + class TestEmbeddedProviderError(unittest.TestCase): """内嵌 _parse_provider_error 与 shared/provider_error.py 权威版对齐 (整体 review P1-4: insufficient 拆成 credit/balance 两条, 漏 quota 形态)""" diff --git a/tests/test_provider_error.py b/tests/test_provider_error.py index 7cdb8cb..eba3256 100644 --- a/tests/test_provider_error.py +++ b/tests/test_provider_error.py @@ -15,6 +15,8 @@ PE8 retry-after 边界 (超大值截断到 300, 含堆栈的长文本只看头部) PQ0-PQ2 (#35) parse_quota_1308: 1308 结构化解析 (北京 naive → unix 对拍 review-gate 正则路径钉死值; 1309/10h/坏日期/空文本不命中) + PB0-PB3 (#55) parse_bootstrap_error: 模型/provider 未引导特征分类 + + 指引文案 + 与 mcp-server 内嵌副本逐 fixture 对拍 (C11/C12/C13 模式) 运行: python3 tests/test_provider_error.py 依赖: 仅 Python 标准库 + shared/provider_error.py @@ -22,10 +24,12 @@ import os import sys +import types import unittest # shared/provider_error.py 是普通 .py, 直接 import sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared")) +from provider_error import parse_bootstrap_error as pb # noqa: E402 from provider_error import parse_provider_error as pe # noqa: E402 from provider_error import parse_quota_1308 as pq # noqa: E402 @@ -227,5 +231,74 @@ def test_pq2_empty_and_none(self): self.assertIsNone(pq(None)) +# ============================================================ +# PB: parse_bootstrap_error (#55) + mcp-server 内嵌副本同步 +# ============================================================ +MCP_SERVER_PATH = os.path.join( + os.path.dirname(__file__), "..", "packages", "mcp-server", "zcode-mcp-server" +) + + +def _load_mcp_module(): + """exec 加载 mcp-server 单文件 (去掉 __main__ 块), 取内嵌副本。""" + mod = types.ModuleType("zcode_mcp_server_pe") + mod.__file__ = MCP_SERVER_PATH + with open(MCP_SERVER_PATH) as f: + code = f.read() + exec(code.split('if __name__ == "__main__":')[0], mod.__dict__) + return mod + + +class TestParseBootstrapError(unittest.TestCase): + """(#55) 引导失败解析: 仅认两类稳定特征; 与 mcp-server 内嵌副本对拍 + (沿用 C11/C12/C13 的权威版 + 副本同步测试模式)。""" + + # 合成夹具: traceId 全零样式, 路径用泛称 /x /y, 不含真实环境信息 + FIXTURES = [ + ("model_selection", "Error: Model creation failed " + "(traceId: 00000000-0000-4000-8000-000000000001)"), + ("model_selection", + "Model creation failed: Select a model before continuing"), + ("builtin_provider_config", + "无法定位 CLI ZCode Built-in Provider Config:" + "/x/provider/zcode-builtin.json, /y/config/provider/zcode-builtin.json"), + ] + NON_MATCHES = [ + "", + None, + "APICallError: Unauthorized", + "429 Too Many Requests", + "quota exceeded", + "这是一段正常的代码审查结论, 没有错误", + ] + + def test_pb0_patterns_and_guidance(self): + """PB0: 两类特征各自分类, 且带非空指引 (指向 README 新节)""" + for kind, text in self.FIXTURES: + r = pb(text) + self.assertIsNotNone(r, f"应命中引导特征: {text!r}") + self.assertEqual(r["kind"], kind, f"分类错误: {text!r}") + self.assertTrue(r["guidance"], "指引文案不得为空") + self.assertIn("模型引导", r["guidance"], "指引应指向 README 新节") + + def test_pb1_non_matches(self): + """PB1: 空/None/限流/配额/普通文本 → None (不误判)""" + for text in self.NON_MATCHES: + self.assertIsNone(pb(text), f"不应命中引导特征: {text!r}") + + def test_pb2_case_insensitive(self): + """PB2: 特征串大小写不敏感 (与限流解析同口径)""" + r = pb("model creation FAILED") + self.assertIsNotNone(r) + self.assertEqual(r["kind"], "model_selection") + + def test_pb3_embedded_copy_parity(self): + """PB3: 权威版与 mcp-server 内嵌副本逐 fixture 同结果 (防副本漂移)""" + mod = _load_mcp_module() + for text in [t for _, t in self.FIXTURES] + self.NON_MATCHES: + self.assertEqual(pb(text), mod._parse_bootstrap_error(text), + f"内嵌副本与权威版漂移: {text!r}") + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index d910a84..bf285d7 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -269,13 +269,15 @@ def _fake_run(self, cmd, *a, **kw): return _CP(returncode=0, stdout=json.dumps(payload), stderr="") raise AssertionError(f"未预期命令: {cmd}") - def _err_stdout(self, text, quota_limit=None, refusal=None): + def _err_stdout(self, text, quota_limit=None, refusal=None, bootstrap=None): """伪造 mcp-server --call 失败输出 (isError result, 可选结构化键)""" result = {"content": [{"type": "text", "text": text}], "isError": True} if quota_limit is not None: result["quota_limit"] = quota_limit if refusal is not None: result["refusal"] = refusal + if bootstrap is not None: + result["bootstrap"] = bootstrap return json.dumps({"ok": False, "result": result}) def _write_config(self, repos=("octo/hello",), quota=None): @@ -754,6 +756,79 @@ def boom(*a, **kw): self.assertIsNone(info["refusal"]) +class TestBootstrapKeyInRunReview(_GateCase): + """引导失败结构化键提取 (#55): 只认 result 顶层 "bootstrap" 键, + 正文文本不参与判定 (与 refusal 同一伪造面论证)。""" + + def _cfg(self): + return self.mod.GateConfig({}) + + def _run_with_stdout(self, payload): + cp = _CP(returncode=2, stdout=json.dumps(payload), stderr="") + with mock.patch.object(self.mod.subprocess, "run", + lambda *a, **kw: cp): + return self.mod.run_review(self._cfg(), "/clone", "main", "sha") + + def test_bootstrap_key_extracted(self): + """isError result 顶层 bootstrap 键原样透传到 info""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", "text": "引导失败: 模型未引导…"}], + "isError": True, + "bootstrap": {"kind": "model_selection"}}}) + self.assertFalse(ok) + self.assertEqual(info["bootstrap"], {"kind": "model_selection"}) + + def test_bootstrap_key_absent(self): + """无 bootstrap 键 → None (普通失败, 照走退避)""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", "text": "炸了"}], + "isError": True}}) + self.assertFalse(ok) + self.assertIsNone(info["bootstrap"]) + + def test_forged_text_marker_not_bootstrap(self): + """正文出现引导特征串但无结构化键 → 不判引导终态 (伪造面): + 否则任意临时失败可被伪造成终态 (跳过退避 + 假告警)。""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", + "text": "mimosa 回显: Model creation failed"}], + "isError": True}}) + self.assertFalse(ok) + self.assertIsNone(info["bootstrap"], + "文本特征不得被解析为引导失败 (只认结构化键)") + + def test_bootstrap_comment_sanitizes_candidate_paths(self): + """告警评论脱敏: builtin_provider_config 的 detail 带宿主候选路径时, + 评论体必须抹成占位符 (评论发到公开 PR, 脱敏红线); 特征前缀保留。""" + detail = ( + "zcode 调用失败: 引导失败 (builtin_provider_config): 内置 provider " + "配置缺失…\n" + "原始错误: 无法定位 CLI ZCode Built-in Provider Config:" + "/x/provider/zcode-builtin.json, " + "/y/config/provider/zcode-builtin.json") + body = self.mod.build_bootstrap_comment("a" * 40, detail) + self.assertIn("<候选路径已省略>", body) + self.assertNotIn("/x/", body, "候选路径不得进入公开评论") + self.assertNotIn("/y/", body, "候选路径不得进入公开评论") + self.assertNotIn("/x/provider/zcode-builtin.json", body) + self.assertIn("无法定位 CLI ZCode Built-in Provider Config", body, + "特征前缀保留 (人可 grep)") + self.assertIn("审查未执行", body) + + def test_subprocess_failure_empty_info(self): + """子进程起不来/非 JSON → 空 info (bootstrap=None, 不误判)""" + def boom(*a, **kw): + raise OSError("gone") + with mock.patch.object(self.mod.subprocess, "run", boom): + ok, _detail, info = self.mod.run_review( + self._cfg(), "/clone", "main", "sha") + self.assertFalse(ok) + self.assertIsNone(info["bootstrap"]) + + class TestCommentBody(_GateCase): SHA = "abcdef1234567890" + "0" * 24 @@ -1544,6 +1619,96 @@ def test_refusal_alert_ratelimited_then_reposted(self): self.assertIn("审查被拒绝", posts[-1][2]["body"]) self.assertEqual(len(self.mcp_calls), 1) + def test_bootstrap_terminal_with_alert_comment(self): + """引导失败 (#55, 结构化 bootstrap 键): 终态 gave_up + 告警评论, + 文案区分「环境」问题; 同 head 不重试不重复评论。""" + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "zcode 调用失败: 引导失败 (model_selection): 模型未引导…\n" + "原始错误: Error: Model creation failed", + bootstrap={"kind": "model_selection"}) + + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up", + "引导失败应终态, 不进失败退避") + self.assertIsNone(entry["verdict"]) + self.assertEqual(entry.get("bootstrap"), {"kind": "model_selection"}) + posts = [c for c in self.api_calls if c[0] == "POST"] + self.assertEqual(len(posts), 1, "引导失败必须发告警评论") + body = posts[0][2]["body"] + self.assertIn("环境", body, "文案应点明环境问题") + self.assertIn("未引导", body) + self.assertNotIn("concerns", body, "不得发成通用 verdict 评论") + + # 同 head 第二轮: 终态不处理 (无第二次审查调用, 也不重复评论) + self.assertEqual(self._run_once(cfg_path), 0) + self.assertEqual(len(self.mcp_calls), 1, "同 head 引导失败不应重试") + self.assertEqual(len([c for c in self.api_calls if c[0] == "POST"]), 1) + + def test_bootstrap_comment_failure_cached_and_reposted(self): + """引导告警发不出去 (Retryable): 转 comment_failed 缓存原文; + 下一轮补发的仍是引导告警 (不是通用 verdict 评论), 补发成功回 + gave_up 终态 — 补发路径必须识别 bootstrap entry (#55 评审意见 2, + 与 refusal 的 R1 P1-2 同型)。""" + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "zcode 调用失败: 引导失败 (builtin_provider_config): 内置 provider " + "配置缺失…\n原始错误: 无法定位 CLI ZCode Built-in Provider Config", + bootstrap={"kind": "builtin_provider_config"}) + self.comment_behavior = "retryable" + + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "comment_failed") + self.assertEqual(entry.get("bootstrap"), {"kind": "builtin_provider_config"}) + self.assertTrue(entry.get("report"), "引导告警原文须缓存, 供补发使用") + + # 下一轮 (退避到点): 只补发引导告警, 不重跑审查, 回 gave_up 终态 + self._force_retry_due() + self.comment_behavior = "ok" + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up", "补发成功应回引导终态") + self.assertIsNone(entry["verdict"], "不得翻成 verdict 状态") + self.assertEqual(len(self.mcp_calls), 1, "补评论不得重跑 mcp 审查") + posts = [c for c in self.api_calls if c[0] == "POST"] + # 第一轮失败尝试也被 fake 记录: 1 (失败) + 1 (补发成功) + self.assertEqual(len(posts), 2) + self.assertIn("审查未执行", posts[-1][2]["body"], + "补发的必须是引导告警评论") + self.assertNotIn("concerns", posts[-1][2]["body"], + "不得发成通用 verdict 评论") + + def test_bootstrap_alert_ratelimited_then_reposted(self): + """引导告警撞限流: 与 refusal 同口径 — 缓存 + comment_failed + (不烧 attempts) + 上抛停轮; 下一轮补发引导告警, 回 gave_up 终态""" + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "zcode 调用失败: 引导失败 (model_selection): 模型未引导…", + bootstrap={"kind": "model_selection"}) + self.comment_behavior = "ratelimited" + + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "comment_failed", + "限流不得让引导告警丢失 (须转可补发状态)") + self.assertEqual(entry.get("bootstrap"), {"kind": "model_selection"}) + self.assertEqual(entry.get("attempts") or 0, 0, "限流不烧 attempts") + self.assertEqual(entry.get("next_retry_at"), 0) + + self.comment_behavior = "ok" + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up") + posts = [c for c in self.api_calls if c[0] == "POST"] + self.assertEqual(len(posts), 2, "限流失败一次 + 补发成功一次") + self.assertIn("审查未执行", posts[-1][2]["body"]) + self.assertEqual(len(self.mcp_calls), 1) + def test_comment_retryable_failure_caches_and_comment_only_retry(self): """审查成功+评论 RetryableError → comment_failed+退避+缓存; 退避到点后的下一轮: 只补评论, 不重跑 mcp 审查""" diff --git a/tests/test_security_review.py b/tests/test_security_review.py index de59b64..bd2c9e8 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -20,6 +20,8 @@ - 超时结构化标因 (issue #54): _review_timeout 默认 1200 可配; 子进程超时 附 timeout 键/锁等待超时附 lock_timeout 键 (均仅 structured_output); 超时不进限流重试循环 (调用次数=1); 普通路径 result 形状不变 + - 引导失败结构化键 (#55): Model creation failed / 无法定位 CLI ZCode + Built-in Provider Config → bootstrap 键 (三 tool 无条件附加, 不重试) 运行: python3 tests/test_security_review.py 依赖: 仅 Python 标准库 + zcode-mcp-server 模块 @@ -2072,6 +2074,68 @@ def test_sq5_call_once_serializes_extra_keys(self): self.assertEqual(data["result"]["usage"], {"total_tokens": 42}) +class TestBootstrapKeyAllTools(_EnvGuard): + """(#55) 引导失败结构化键: 与 structured_output 无关 (不设门控), 三个 + review tool 形状一致 (对齐 refusal 先例) — gate 只认此键判终态告警。""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + # 合成夹具: traceId 全零样式 (不涉真实环境) + _STDERR = ("Error: Model creation failed " + "(traceId: 00000000-0000-4000-8000-000000000001)") + + def test_bs0_pr_review_attaches_bootstrap(self): + """BS0: zcode_pr_review 引导失败 → bootstrap 键 + 指引文本, 不重试""" + mod, saved, proj, captured = TestPrReview._patch( + self, changed=["app.py"], zcode_rc=1, zcode_stderr=self._STDERR) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + TestPrReview._restore(self, mod, saved) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), {"kind": "model_selection"}) + self.assertIn("模型未引导", result["content"][0]["text"]) + self.assertEqual(captured["zcode_calls"], 1, "引导失败不应重试") + + def test_bs1_security_review_attaches_bootstrap(self): + """BS1: zcode_security_review 同一路径附加 (三 tool 形状一致)""" + mod, saved, proj = TestSecurityReviewTool._patch_common(self) + saved_run = mod.subprocess.run + try: + mod.subprocess.run = lambda *a, **kw: _FakeCompletedProcess( + 1, "", self._STDERR) + result = mod.tool_zcode_security_review({"path": proj}) + finally: + mod.subprocess.run = saved_run + TestSecurityReviewTool._restore_common(self, mod, saved) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("bootstrap"), {"kind": "model_selection"}) + + def test_bs2_call_once_serializes_bootstrap(self): + """BS2: --call 一次性模式 (gate 消费路径) 的信封不丢 bootstrap 键""" + import contextlib + import io + mod = self.mod + stub = {"content": [{"type": "text", "text": "引导失败"}], + "isError": True, + "bootstrap": {"kind": "builtin_provider_config"}} + saved_handler = mod.TOOL_HANDLERS["zcode_pr_review"] + mod.TOOL_HANDLERS["zcode_pr_review"] = lambda args: dict(stub) + buf = io.StringIO() + try: + with contextlib.redirect_stdout(buf): + rc = mod.call_once(["zcode_pr_review", "{}"]) + finally: + mod.TOOL_HANDLERS["zcode_pr_review"] = saved_handler + self.assertEqual(rc, 2) # isError → exit 2 + data = json.loads(buf.getvalue()) + self.assertFalse(data["ok"]) + self.assertEqual(data["result"]["bootstrap"], + {"kind": "builtin_provider_config"}) + + class TestFindingFingerprint(_EnvGuard): """_finding_fingerprint / 基线路径: 指纹稳定性 (issue #27 基线键)"""