diff --git a/README.md b/README.md index f3b04319..1f8e9b18 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ On startup we scan `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebre | **Qwen Coder** | `qwen` | `qwen --yolo -` | | **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` | | **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` | +| **ZCode** | `node` | `node -p --attach --output-format stream-json` | > The detection strategy and per-CLI adapter shape are borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin adapter in [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts). diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f72f0a9..ec35a6da 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -203,6 +203,7 @@ pnpm -F @html-anything/e2e test | **Qwen Coder** | `qwen` | `qwen --yolo -` | | **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` | | **IBM Bob** | `bob` | `bob --output-format stream-json --hide-intermediary-output` | +| **ZCode** | `node` | `node -p --attach --output-format stream-json` | > 这一层的设计直接借鉴了 [`nexu-io/open-design`](https://github.com/nexu-io/open-design) 和 [`multica-ai/multica`](https://github.com/multica-ai/multica) 的 agent 检测策略:唯一被 spawn 子进程的进程是 server route,业务进程不直接 spawn;CLI 的 stdin / stdout 用 JSON-line 协议复用,每个 CLI 一个轻 adapter,统一在 [`next/src/lib/agents/argv.ts`](next/src/lib/agents/argv.ts)。 diff --git a/cli/src/__tests__/__fixtures__/zcode-builtin.fixture.json b/cli/src/__tests__/__fixtures__/zcode-builtin.fixture.json new file mode 100644 index 00000000..a29f7413 --- /dev/null +++ b/cli/src/__tests__/__fixtures__/zcode-builtin.fixture.json @@ -0,0 +1,2507 @@ +{ + "schemaVersion": 1, + "revision": 30, + "config": { + "providerConfigRules": { + "templateRules": [ + { + "templateId": "bigmodel-api", + "templateNameMap": { + "zh-CN": "BigModel Coding Plan", + "en-US": "BigModel Coding Plan" + }, + "config": { + "access": { + "type": "zhipu-coding-plan-api-key", + "apiKeyManagementUrl": "https://bigmodel.cn/coding-plan/personal/overview" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "templateId": "bigmodel-standard-api", + "templateNameMap": { + "zh-CN": "BigModel API", + "en-US": "BigModel API" + }, + "config": { + "access": { + "type": "api-key", + "apiKeyManagementUrl": "https://bigmodel.cn/usercenter/proj-mgmt/apikeys" + }, + "api": { + "type": "openai-chat-completions", + "baseUrl": "https://open.bigmodel.cn/api/paas/v4" + }, + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash", + "GLM-5V-Turbo", + "GLM-5.1", + "GLM-5.1-Highspeed", + "GLM-5", + "GLM-5-Turbo", + "GLM-4.7", + "GLM-4.7-FlashX", + "GLM-4.7-Flash", + "GLM-4.6", + "GLM-4.5-Air", + "GLM-4.5", + "GLM-4.6V", + "GLM-4.6V-Flash", + "GLM-4.6V-FlashX", + "GLM-4.1V-Thinking-FlashX", + "GLM-4.1V-Thinking-Flash", + "GLM-4-FlashX-250414", + "GLM-4-Flash-250414", + "GLM-4V-Flash", + "codegeex-4", + "charglm-4", + "emohaa" + ], + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "templateId": "deepseek", + "templateNameMap": { + "zh-CN": "DeepSeek", + "en-US": "DeepSeek" + }, + "config": { + "access": { + "type": "api-key", + "apiKeyManagementUrl": "https://platform.deepseek.com/api_keys" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.deepseek.com/anthropic" + }, + "builtinModelIds": [ + "deepseek-flash", + "deepseek-v4-pro" + ], + "logo": { + "type": "builtin", + "key": "deepseek" + } + } + } + ], + "providerRules": [ + { + "providerId": "account:zai-individual-coding-plan", + "providerName": "Z.AI Individual Coding Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "individual-coding-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.z.ai/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:zai-team-coding-plan", + "providerName": "Z.AI Team Coding Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "team-coding-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.z.ai/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:zai-start-plan", + "providerName": "Start Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3-Flash", + "GLM-5.2", + "GLM-5-Turbo" + ], + "access": { + "type": "zhipu-account", + "mode": "start-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/zcode-plan/anthropic" + }, + "logo": { + "type": "builtin", + "key": "start-plan" + } + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "providerName": "BigModel Individual Coding Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "individual-coding-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "providerName": "BigModel Team Coding Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "team-coding-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "providerId": "account:bigmodel-start-plan", + "providerName": "Start Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3-Flash", + "GLM-5.2", + "GLM-5-Turbo" + ], + "access": { + "type": "zhipu-account", + "mode": "start-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/zcode-plan/anthropic" + }, + "logo": { + "type": "builtin", + "key": "start-plan" + } + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "providerName": "Z.AI Idle plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "visibility": "hidden", + "access": { + "type": "zhipu-account", + "mode": "off-peak", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/off-peak/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "providerName": "BigModel Idle plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "visibility": "hidden", + "access": { + "type": "zhipu-account", + "mode": "off-peak", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/off-peak/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + } + ] + }, + "modelConfigRules": { + "modelRules": [ + { + "modelMatch": ".*", + "config": { + "enabled": true, + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsText": true, + "supportsImage": false, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "outputFormat": { + "supportsText": true + }, + "supportsToolCall": true, + "supportsJsonSchemaOutput": false, + "supportsNativeWebSearch": false, + "supportsMidConversationSystem": false, + "requiresMfjsToolSchema": false + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + }, + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ], + "map": "{}" + } + } + } + }, + { + "modelMatch": ".*\\[1m\\]", + "config": { + "properties": { + "contextWindow": 1000000 + } + } + }, + { + "modelMatch": ".*(?:ox-alpha|glm-x-preview-f|x-preview-f-free).*", + "config": { + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*glm-5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*GLM-5\\.2(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*GLM-5-Turbo(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.3(?:-flash)?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.3-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsPdf": true + } + } + } + }, + { + "modelMatch": ".*glm-5\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.1-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5v-turbo(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.5-air(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.1v-thinking-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.1v-thinking-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4-flashx-250414(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*glm-4-flash-250414(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*glm-4v-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 16384, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 1024 + } + } + } + }, + { + "modelMatch": ".*codegeex-4(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*charglm-4(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 4096 + } + } + } + }, + { + "modelMatch": ".*emohaa(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 4096 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.6(?:-(?:sol|terra|luna))?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.3-codex(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-6-astra(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-pro(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-mini(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-nano(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*claude-(?:fable|opus|sonnet)-5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*claude-haiku-4[.-]5(?:-20251001)?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*claude-(?:fable|mythos)-5[.-]1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-4\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 500000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-build-0\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 256000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-4\\.3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*kimi-k3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1048576, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*k3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1048576, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*k3-256k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.7-code(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-8k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-32k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 32768, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-128k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-8k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-32k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 32768, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-128k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.7(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.7-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.5-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.1-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4[.-]1-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.8-(?:max|flash)(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.5-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.5-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3-max(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen3-vl-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-max(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-plus(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.6-plus(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.6-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-max-2026-06-08(?:[:/\\[].*)?", + "config": { + "properties": { + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + } + } + }, + { + "modelMatch": ".*qwen3\\.8-omni-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsAudio": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*mimo-v2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsAudio": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2\\.5-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false, + "supportsAudio": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-omni(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + } + ], + "templateModelRules": [ + { + "modelId": "GLM-5-Turbo", + "templateId": "bigmodel-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.3", + "templateId": "bigmodel-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3-Flash", + "templateId": "bigmodel-api", + "config": { + "enabled": true + } + }, + { + "modelId": "deepseek-v4-pro", + "templateId": "deepseek", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5V-Turbo", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.1", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.1-Highspeed", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5-Turbo", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.5-Air", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.5", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.1V-Thinking-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.1V-Thinking-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4-FlashX-250414", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4-Flash-250414", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4V-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "codegeex-4", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "charglm-4", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "emohaa", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "templateId": "deepseek", + "modelId": "deepseek-flash", + "config": { + "enabled": true + } + } + ], + "builtinProviderModelRules": [ + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + } + ] + } + } +} diff --git a/cli/src/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl b/cli/src/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl new file mode 100644 index 00000000..4ba5cf74 --- /dev/null +++ b/cli/src/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl @@ -0,0 +1,2 @@ +{"eventId":"ca7955e8-76b3-478c-a00d-ccfb2ad41bc7","payload":{"response":"PROBE_OK","tokenCount":131409,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":131405,"outputTokens":4,"totalTokens":131409,"cacheReadTokens":256,"cacheWriteTokens":0,"reasoningTokens":0,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":24160,"resultType":"success","cacheStats":{"totalMessages":8,"cachedMessages":7,"lastCacheHit":true,"cacheReadTokens":256}},"seq":35,"sessionId":"sess_b23229a4-fd9d-4d40-a44d-9cb6a0b9ce69","timestamp":1790060846779,"traceId":"50bd4735-2e5c-4e46-8aa2-647940965779","turnId":"turn_c5647dfb-cf22-4740-b713-6e6441ea496b","type":"turn.completed"} +{"type":"result","sessionId":"sess_b23229a4-fd9d-4d40-a44d-9cb6a0b9ce69","traceId":"50bd4735-2e5c-4e46-8aa2-647940965779","turnId":"turn_c5647dfb-cf22-4740-b713-6e6441ea496b","response":"PROBE_OK","usage":{"source":"provider","modelRequestCount":1,"inputTokens":131405,"outputTokens":4,"totalTokens":131409,"cacheReadTokens":256,"cacheWriteTokens":0,"reasoningTokens":0,"webFetchRequests":0,"webSearchRequests":0},"eventCount":12,"projection":{"status":"idle","turnCount":1,"totalTokenCount":131409,"contextUsed":131409,"contextWindow":200000}} diff --git a/cli/src/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt b/cli/src/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt new file mode 100644 index 00000000..753e5a06 --- /dev/null +++ b/cli/src/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt @@ -0,0 +1,11 @@ +ProviderBusinessError: 1 validation error: {'type': 'literal_error', 'loc': ('body', 'reasoning_effort'), 'msg': "Input should be 'low', 'medium' or 'high'", 'input': 'max', 'ctx': {'expected': "'low', 'medium' or 'high'"}} File "/home/user/envs/ray/lib/python3.10/site-packages/sglang/srt/entrypoints/http_server.py", line 1141, in openai_v1_chat_completions POST /v1/chat/completions [{'type': 'literal_error', 'loc': ('body', 'reasoning_effort'), 'msg': "Input should be 'low', 'medium' or 'high'", 'input': 'max', 'ctx': {'expected': "'low', 'medium' or 'high'"}}] + at detectProviderBusinessError (C:\Program Files\ZCode\resources\glm\zcode.cjs:2047:10939) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Object.fetch (C:\Program Files\ZCode\resources\glm\zcode.cjs:2047:10435) + at async postToApi (C:\Program Files\ZCode\resources\glm\zcode.cjs:2028:19657) + at async OpenAICompatibleChatLanguageModel.doStream (C:\Program Files\ZCode\resources\glm\zcode.cjs:2038:6883) + code: 'PROVIDER_BUSINESS_ERROR', + isProviderBusinessError: true, + providerCode: 400, + providerId: '00000000-1111-4222-8333-444444444444', + providerKind: 'openai-compatible', diff --git a/cli/src/__tests__/agents-detect.test.ts b/cli/src/__tests__/agents-detect.test.ts index 5136038d..8292355a 100644 --- a/cli/src/__tests__/agents-detect.test.ts +++ b/cli/src/__tests__/agents-detect.test.ts @@ -1,15 +1,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { homedir } from "node:os"; +import { join, posix } from "node:path"; -const { existsSyncMock } = vi.hoisted(() => ({ +const { existsSyncMock, readFileSyncMock, bindingPickerState } = vi.hoisted(() => ({ existsSyncMock: vi.fn((_path?: string) => false), + // discoverZcodeAppImage reads the XDG `.desktop` entry. Mocked so + // the discover tests don't touch disk; the pure parser (parseZcodeDesktopExec) + // is exercised separately with string fixtures. + readFileSyncMock: vi.fn((_path?: string, _enc?: string) => ""), + // The ZCode detect data (registry picker + ready state) comes from + // zcode-model-binding. Mutated per test below; the module itself + // (registry enumeration, catalog resolution, level tables) is tested in + // its own file. + bindingPickerState: { + ready: true as boolean, + reason: null as null | "no-usable-provider", + options: [ + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ], + defaultChoice: { + providerId: "account:bigmodel-individual-coding-plan", + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "high", + } as null | { + providerId: string; + providerName: string; + modelId: string; + reasoningLevel: string | null; + }, + }, })); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); - return { ...actual, existsSync: existsSyncMock }; + return { ...actual, existsSync: existsSyncMock, readFileSync: readFileSyncMock }; }); -import { detectAgents } from "../agents-detect.js"; +// Detect reads the registry picker via the binding module's one composed +// call. Mock only that; everything else stays the real implementation +// (keep the module's exports real so this mock keeps working). +vi.mock("../zcode-model-binding.js", async () => { + const actual = await vi.importActual< + typeof import("../zcode-model-binding.js") + >("../zcode-model-binding.js"); + return { + ...actual, + readZcodePickerState: () => bindingPickerState, + }; +}); + +import { detectAgents, resolveZcodeBin, resolveZcodeNodeBin, defaultZcodeElectronExePaths, defaultZcodeCjsPaths, discoverZcodeAppImage, parseZcodeDesktopExec, AGENTS, DEFAULT_MODEL, type AgentDef, type AgentProtocol } from "../agents-detect.js"; function findAgent( agents: ReturnType, @@ -23,12 +66,41 @@ function findAgent( beforeEach(() => { existsSyncMock.mockReset(); existsSyncMock.mockReturnValue(false); + readFileSyncMock.mockReset(); + readFileSyncMock.mockReturnValue(""); + // Reset the binding mock to the ready-registry fixture (tests mutate it). + bindingPickerState.ready = true; + bindingPickerState.reason = null; + bindingPickerState.options = [ + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ]; + bindingPickerState.defaultChoice = { + providerId: "account:bigmodel-individual-coding-plan", + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "high", + }; }); +// Real value captured once at module load; restore after each platform-stubbing +// test so later tests run against the host platform again. +const REAL_PLATFORM = process.platform; afterEach(() => { vi.unstubAllEnvs(); + Object.defineProperty(process, "platform", { + value: REAL_PLATFORM, + configurable: true, + }); }); +// ZCode install discovery probes platform-specific absolute paths. Stub the +// platform so a single host (win32 here) can exercise the macOS/Linux branches. +function stubPlatform(p: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: p, configurable: true }); +} + describe("detectAgents", () => { describe("*_BIN env override with absolute path that exists", () => { it("finds claude via absolute CLAUDE_BIN path", () => { @@ -340,4 +412,672 @@ describe("detectAgents", () => { } }); }); + + describe("type-only extension surface", () => { + it("AgentProtocol accepts \"argv-attach\"", () => { + // Compile-time proof: if "argv-attach" is missing from the union this + // assignment fails typecheck. The runtime echo keeps the test meaningful. + const p: AgentProtocol = "argv-attach"; + expect(p).toBe("argv-attach"); + }); + + it("AgentDef accepts optional binArgs?: string[]", () => { + // Compile-time proof of the new optional field. Omitting it must also + // stay valid (every existing AgentDef entry omits it). + const withBinArgs: AgentDef = { + id: "type-probe", + label: "Type Probe", + bin: "type-probe", + vendor: "probe", + protocol: "argv-attach", + binArgs: [""], + fallbackModels: [DEFAULT_MODEL], + }; + expect(withBinArgs.binArgs).toEqual([ + "", + ]); + }); + + it("AgentDef.binArgs is optional (existing entries omit it)", () => { + // binArgs stays optional so existing AgentDef literals keep typechecking + // unchanged. Check the whole array, not a sample, so a future entry that + // accidentally sets a required-looking binArgs is caught here. ZCode + // is the one intentional exception; its node-script spawn needs it. + for (const def of AGENTS) { + if (def.id === "zcode") continue; + expect(def.binArgs).toBeUndefined(); + } + expect( + AGENTS.find((a) => a.id === "zcode")?.binArgs, + ).toEqual([""]); + }); + + it("detectAgents stays green across the existing protocol set", () => { + // Regression guard: the "argv-attach" union member must not flip the + // unsupported flag for any of the protocols already in use. + existsSyncMock.mockReturnValue(false); + const agents = detectAgents(); + const protocols = new Set(agents.map((a) => a.protocol)); + for (const expected of [ + "stdin", + "argv", + "argv-message", + "acp", + "pi-rpc", + ] as const) { + expect(protocols.has(expected)).toBe(true); + } + }); + }); + + // ZCode install discovery. Only the discovery function is under test; + // registration in AGENTS is covered by the detectAgents tests above. Cases + // follow the same existsSync-mock pattern. + describe("resolveZcodeBin", () => { + it("ZCODE_BIN absolute path that exists wins over all defaults (win32)", () => { + vi.stubEnv("ZCODE_BIN", "C:\\custom\\zcode.cjs"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => p === "C:\\custom\\zcode.cjs"); + + expect(resolveZcodeBin()).toBe("C:\\custom\\zcode.cjs"); + }); + + it("ZCODE_BIN command name resolves on PATH when not an absolute path", () => { + const dir = "/opt/zcode-shim"; + const expected = join(dir, "my-zcode"); + vi.stubEnv("ZCODE_BIN", "my-zcode"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + // Absolute lookup misses; only the PATH-resolved location exists. + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeBin()).toBe(expected); + }); + + it("Windows: ZCODE_WINDOWS_APP_INSTALL_DIR .cjs path probed", () => { + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "D:\\ZCode\\resources\\glm\\zcode.cjs", + ); + + expect(resolveZcodeBin()).toBe( + "D:\\ZCode\\resources\\glm\\zcode.cjs", + ); + }); + + it("Windows: falls back to C:\\Program Files\\ZCode when install-dir unset", () => { + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs", + ); + + expect(resolveZcodeBin()).toBe( + "C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs", + ); + }); + + it("Windows: install-dir env is preferred over the Program Files fallback", () => { + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => + p === "D:\\ZCode\\resources\\glm\\zcode.cjs" + ? true + : p === "C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs", + ); + + expect(resolveZcodeBin()).toBe( + "D:\\ZCode\\resources\\glm\\zcode.cjs", + ); + }); + + it("macOS: probes /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", () => { + stubPlatform("darwin"); + existsSyncMock.mockImplementation( + (p) => + p === + "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + ); + + expect(resolveZcodeBin()).toBe( + "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + ); + }); + + // On Linux the canonical AppImage guess is NO LONGER probed: + // discovery goes through the .desktop entry (resolveZcodeBin step 3 calls + // discoverZcodeAppImage). Without a .desktop, resolveZcodeBin returns null + // (no silent fallback to ~/Applications/ZCode.AppImage). The positive case + // (.desktop present → AppImage found) is covered in the AppImage block below. + it("Linux: returns null without a .desktop entry (no canonical-path guess)", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + expect(resolveZcodeBin()).toBeNull(); + }); + + it("Linux: `zcode` on PATH is found before the AppImage default", () => { + const dir = "/opt/zcode-deb"; + const expected = join(dir, "zcode"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeBin()).toBe(expected); + }); + + it("returns null when nothing is found (no throw)", () => { + stubPlatform("darwin"); + existsSyncMock.mockReturnValue(false); + + expect(resolveZcodeBin()).toBeNull(); + }); + + it("ZCODE_BIN override takes precedence over a present platform default", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/override.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation( + (p) => + p === "/opt/zcode/override.cjs" || + p === "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + ); + + expect(resolveZcodeBin()).toBe("/opt/zcode/override.cjs"); + }); + }); + + // Resolve the node binary that drives `node -p …` for + // an EXTERNAL caller. html-anything is an external process; on a clean + // Windows host `where node` finds nothing (only ZCode.exe exists). The + // resolveZcodeBin() discovery above locates the .cjs bundle; this layer + // locates the NODE the .cjs is run with. Strategy (chosen against a live + // clean-host probe, not guessed): prefer a real node on PATH; fall back to + // the ZCode Electron executable itself, which under ELECTRON_RUN_AS_NODE=1 + // (set by envFor's zcode line) behaves as node. A live spawn of + // `ZCode.exe …` with ELECTRON_RUN_AS_NODE=1 booted and + // answered JSON-RPC on this very host (no separate node.exe ships in the + // install tree). + describe("resolveZcodeNodeBin", () => { + it("ZCODE_NODE_BIN absolute path that exists wins over all defaults", () => { + vi.stubEnv("ZCODE_NODE_BIN", "C:\\custom\\node.exe"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => p === "C:\\custom\\node.exe"); + + expect(resolveZcodeNodeBin()).toBe("C:\\custom\\node.exe"); + }); + + it("ZCODE_NODE_BIN command name resolves on PATH when not absolute", () => { + const dir = "/opt/node-shim"; + const expected = join(dir, "my-node"); + vi.stubEnv("ZCODE_NODE_BIN", "my-node"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + it("prefers a real `node` on PATH over the Electron fallback", () => { + // System Node is the simplest, most portable driver when present: it + // needs no ELECTRON_RUN_AS_NODE env. So it wins over the Electron exe. + const dir = "/opt/realnode"; + const expected = join(dir, "node"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + // Both the PATH node AND the Electron exe exist; PATH node must win. + existsSyncMock.mockImplementation((p) => p === expected || p === "/opt/zcode/ZCode"); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + it("Windows: falls back to ZCode.exe next to the resolved .cjs install", () => { + // Clean host: no node on PATH, no ZCODE_NODE_BIN. The Electron exe at the + // install root (C:\Program Files\ZCode\ZCode.exe) is the only node-like + // binary on the box, driven under ELECTRON_RUN_AS_NODE=1 by the spawn + // branch. existsSync admits the .exe only. + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "C:\\Program Files\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + expect(resolveZcodeNodeBin()).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); + + it("Windows: honours a custom ZCODE_WINDOWS_APP_INSTALL_DIR for the .exe", () => { + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => p === "D:\\ZCode\\ZCode.exe"); + + expect(resolveZcodeNodeBin()).toBe("D:\\ZCode\\ZCode.exe"); + }); + + it("macOS: falls back to ZCode.app/Contents/MacOS/ZCode", () => { + stubPlatform("darwin"); + existsSyncMock.mockImplementation( + (p) => p === "/Applications/ZCode.app/Contents/MacOS/ZCode", + ); + + expect(resolveZcodeNodeBin()).toBe( + "/Applications/ZCode.app/Contents/MacOS/ZCode", + ); + }); + + it("Linux: falls back to the AppImage mount", () => { + // On Linux the AppImage doubles as the Electron driver (run under + // ELECTRON_RUN_AS_NODE=1 it acts as node). + stubPlatform("linux"); + const expected = `${homedir()}/Applications/ZCode.AppImage`; + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + // The Electron-exe fallback is the + // terminal step of the probe chain. detectAgents() reports zcode available + // only when zcode.cjs was found ⟺ ZCode is installed ⟺ the sibling + // Electron exe exists, so any caller reaching this code sees a hit here. + // The return type is therefore `string` (not `string | null`); this case + // pins that the fallback yields a non-empty path, and the type itself is + // the compile-time proof the old null outcome is gone. + it("returns a non-empty string when the Electron-exe fallback exists", () => { + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + const result = resolveZcodeNodeBin(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); + }); + + // ZCode is registered as a first-class agent. Unlike the *_BIN/PATH + // agents, ZCode's availability is driven by resolveZcodeBin() (its CLI is a + // .cjs bundle, not a standalone exec found on PATH). protocol "argv-attach" + // is implemented (generic invoke trunk), so it must NOT be marked unsupported. + describe("ZCode agent registration", () => { + it("AGENTS contains a zcode entry with the spec fields", () => { + const def = AGENTS.find((a) => a.id === "zcode"); + expect(def).toBeDefined(); + expect(def!.label).toBe("ZCode"); + expect(def!.vendor).toBe("Z.AI"); + expect(def!.envOverride).toBe("ZCODE_BIN"); + expect(def!.protocol).toBe("argv-attach"); + expect(def!.bin).toBe("node"); + // binArgs holds the node-script leading argv: + // just the resolved .cjs (no subcommand anymore). + // The placeholder is filled at detect time from + // resolveZcodeBin(); the literal here is the sentinel on the AgentDef. + expect(def!.binArgs).toEqual([""]); + }); + + it("detectAgents() returns available=true when resolveZcodeBin() hits", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.path).toBe("/opt/zcode/zcode.cjs"); + // resolveZcodeNodeBin() now returns + // `string` (not `string | null`), so detectAgents() no longer coalesces + // to the literal `node`. resolvedBin is the node driver the spawn will + // use, a non-empty string (the resolver never returns null). We don't + // pin the exact path here: existsSync admits only the .cjs in this + // scenario, so the resolver falls through to its terminal Electron-exe + // candidate, whose exact value is an install-layout detail covered by + // the dedicated resolveZcodeNodeBin cases above. + expect(typeof zcode.resolvedBin).toBe("string"); + expect((zcode.resolvedBin ?? "").length).toBeGreaterThan(0); + expect(zcode.protocol).toBe("argv-attach"); + // argv-attach is implemented (generic invoke trunk) and must not be + // flagged unsupported, unlike the acp/pi-rpc family. + expect(zcode.unsupported).toBeUndefined(); + }); + + // Detection reports the ACTUAL node driver when one resolves, + // rather than the literal "node". On a clean host where only the ZCode + // Electron exe exists, resolvedBin must point at it, so the UI can show + // the user what will really spawn, and there is no conflicting assumption + // (detect says "node" while invoke spawns ZCode.exe). Reconciles the + // detection layer with the invoke layer's resolveZcodeNodeBin(). + it("detectAgents() reports the resolved Electron exe as resolvedBin when no system node", () => { + vi.stubEnv("ZCODE_BIN", "/resolved/zcode.cjs"); + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "C:\\Program Files\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => + p === "/resolved/zcode.cjs" || + p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.path).toBe("/resolved/zcode.cjs"); + // The Electron exe is what the spawn will actually use as "node". + expect(zcode.resolvedBin).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); + + it("detectAgents() returns available=false when no install is found (no throw)", () => { + stubPlatform("darwin"); + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(false); + expect(zcode.path).toBeUndefined(); + expect(zcode.resolvedBin).toBeUndefined(); + expect(zcode.protocol).toBe("argv-attach"); + expect(zcode.unsupported).toBeUndefined(); + }); + + // The picker mirrors the registry (catalog + personal config + credential + // key names via zcode-model-binding), encoded as provider-namespaced + // "/[/]" ids with the service name in the + // label, with a Default entry first. The Default chip LABEL names what + // Default resolves to right now (the resolved default service, model, and + // level), so it is never a stale promise. + it("picker models are the registry's provider-namespaced chips; Default chip labelled with the resolved default", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.ready).toBe(true); + expect(zcode.notReadyReason).toBeUndefined(); + expect(zcode.models).toEqual([ + { id: "default", label: "Default (BigModel Individual Coding Plan GLM-5.3, high)" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ]); + }); + + // When the default choice cannot be resolved (empty registry at the label + // read), the Default chip falls back to the generic label rather than + // promising a model it cannot name. + it("unresolvable default choice → generic Default (ZCode default) label", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + bindingPickerState.defaultChoice = null; + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.models[0]).toEqual({ id: "default", label: "Default (ZCode default)" }); + expect(zcode.models.length).toBe(4); // chips still listed + }); + + // Installed is not ready (no usable keyed provider): ready:false + the + // single reason, and the registry holds nothing so the picker keeps the + // static floor. + it("not-ready install: ready=false + single reason surfaced, static floor picker", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + bindingPickerState.ready = false; + bindingPickerState.reason = "no-usable-provider"; + bindingPickerState.options = []; + bindingPickerState.defaultChoice = null; + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.ready).toBe(false); + expect(zcode.notReadyReason).toBe("no-usable-provider"); + expect(zcode.models).toEqual([{ id: "default", label: "Default (ZCode default)" }]); + }); + + // When ZCode is NOT available (no install found), the picker still surfaces + // the static floor. The config read is gated on the same availability as + // the rest of ZCode's detection. + it("picker falls back to the static floor when ZCode is unavailable (config read gated)", () => { + stubPlatform("darwin"); + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(false); + expect(zcode.models).toEqual([{ id: "default", label: "Default (ZCode default)" }]); + }); + }); +}); + +// On Linux ZCode ships as a `.deb` or an AppImage. The AppImage is a single +// compressed file with zcode.cjs packed inside, unreachable without mounting; +// the adapter discovers it via the XDG .desktop entry ZCode writes on first +// GUI launch, not by guessing a version-less filename. The pure parser +// (parseZcodeDesktopExec) holds the parsing+validation logic; the discover +// function is a thin I/O glue over it. resolveZcodeBin / resolveZcodeNodeBin +// wire the discovery into the existing probe chain (step 3 on Linux). +describe("parseZcodeDesktopExec (.desktop Exec= parsing + validation)", () => { + it("extracts the AppImage path from a well-formed Exec= line", () => { + const desktop = [ + "[Desktop Entry]", + "Name=ZCode", + "Exec=/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage --no-sandbox %U", + "Icon=zcode", + "Type=Application", + ].join("\n"); + expect(parseZcodeDesktopExec(desktop)).toBe( + "/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage", + ); + }); + + it("strips surrounding quotes a launcher might write", () => { + const desktop = `[Desktop Entry]\nExec="/opt/ZCode.AppImage" %U\n`; + expect(parseZcodeDesktopExec(desktop)).toBe("/opt/ZCode.AppImage"); + }); + + it("preserves a quoted path that contains spaces (no truncation)", () => { + // A path with an interior space MUST be quoted per the freedesktop spec; + // splitting on whitespace first would truncate it at the space. This is + // the regression guard for the quote-aware tokenizer. + const desktop = `[Desktop Entry]\nExec="/home/user/My Apps/ZCode-3.7.5.AppImage" --no-sandbox %U\n`; + expect(parseZcodeDesktopExec(desktop)).toBe( + "/home/user/My Apps/ZCode-3.7.5.AppImage", + ); + }); + + it("returns null when no Exec= line is present", () => { + const desktop = `[Desktop Entry]\nName=ZCode\nIcon=zcode\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("does not match TryExec= (a different field)", () => { + const desktop = `[Desktop Entry]\nTryExec=/opt/ZCode.AppImage\nExec=/real/ZCode.AppImage\n`; + expect(parseZcodeDesktopExec(desktop)).toBe("/real/ZCode.AppImage"); + }); + + it("returns null for a malformed Exec= that is just a field code", () => { + const desktop = `[Desktop Entry]\nExec=%U\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("returns null for an empty Exec= value", () => { + const desktop = `[Desktop Entry]\nExec=\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("returns null for empty content", () => { + expect(parseZcodeDesktopExec("")).toBeNull(); + }); +}); + +describe("discoverZcodeAppImage (.desktop I/O glue)", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("reads the .desktop entry and returns the parsed AppImage path", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode-3.7.5.AppImage --no-sandbox\n", + ); + + expect(discoverZcodeAppImage()).toBe("/opt/ZCode-3.7.5.AppImage"); + expect(readFileSyncMock).toHaveBeenCalledWith(desktopPath, "utf8"); + }); + + it("returns null when the .desktop file is missing", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + expect(discoverZcodeAppImage()).toBeNull(); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + + it("returns null (never throws) when readFileSync throws (e.g. EACCES)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + + expect(discoverZcodeAppImage()).toBeNull(); + }); + + it("returns null when the Exec= line is malformed", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockReturnValue("[Desktop Entry]\nName=ZCode\n"); + + expect(discoverZcodeAppImage()).toBeNull(); + }); +}); + +// On Linux, resolveZcodeBin() step 3 discovers the +// AppImage via the .desktop entry. The returned path is the AppImage binary, +// NOT a .cjs; the .cjs lives inside the mount and is resolved post-mount in +// the invoke layer. +describe("resolveZcodeBin / resolveZcodeNodeBin on Linux", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("resolveZcodeBin() step 3 returns the .desktop AppImage when no env/PATH hit", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation( + (p) => p === desktopPath || p === "/opt/ZCode.AppImage", + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeBin()).toBe("/opt/ZCode.AppImage"); + }); + + it("resolveZcodeNodeBin() step 3 returns the discovered AppImage (Electron driver)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation( + (p) => p === desktopPath || p === "/opt/ZCode.AppImage", + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeNodeBin()).toBe("/opt/ZCode.AppImage"); + }); + + it("resolveZcodeNodeBin() terminal fallback is the canonical guess when discovery misses", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + expect(resolveZcodeNodeBin()).toBe( + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ); + }); + + it("defaultZcodeCjsPaths() returns the .deb .cjs candidate on Linux", () => { + stubPlatform("linux"); + expect(defaultZcodeCjsPaths()).toEqual([ + "/opt/ZCode/resources/glm/zcode.cjs", + ]); + }); + + it("defaultZcodeElectronExePaths() lists the .deb driver before the AppImage on Linux", () => { + stubPlatform("linux"); + expect(defaultZcodeElectronExePaths()).toEqual([ + "/opt/ZCode/zcode", + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ]); + }); + + it("defaultZcodeCjsPaths() Windows/macOS branches are unchanged", () => { + stubPlatform("win32"); + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + expect(defaultZcodeCjsPaths()).toEqual([ + "D:\\ZCode\\resources\\glm\\zcode.cjs", + "C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs", + ]); + stubPlatform("darwin"); + expect(defaultZcodeCjsPaths()).toEqual([ + "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + ]); + }); +}); + +// The official .deb install lays a loose zcode.cjs at +// /opt/ZCode/resources/glm/zcode.cjs next to the Electron driver /opt/ZCode/zcode +// (verified against the unpacked ZCode-3.7.6-linux-x64.deb). This is +// the same loose-file layout as Windows/macOS, so the .deb is probed the same +// way: resolveZcodeBin() checks the on-disk .cjs BEFORE the AppImage (a loose +// .cjs cleanly distinguishes a .deb from an AppImage, whose .cjs is packed in +// the squashfs), and resolveZcodeNodeBin() probes /opt/ZCode/zcode so a .deb- +// only host without a system node can still drive the .cjs with no mount needed. +describe("resolveZcodeBin / resolveZcodeNodeBin Linux .deb install", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("resolveZcodeBin() returns the on-disk .deb .cjs before AppImage discovery", () => { + stubPlatform("linux"); + // The .deb .cjs exists. An AppImage is ALSO discoverable here; the on-disk + // .cjs probe must win so a .deb install never needlessly mounts. + existsSyncMock.mockImplementation( + (p) => + p === "/opt/ZCode/resources/glm/zcode.cjs" || + p === "/opt/ZCode.AppImage" || + p === desktopPath, + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeBin()).toBe("/opt/ZCode/resources/glm/zcode.cjs"); + }); + + it("resolveZcodeNodeBin() returns /opt/ZCode/zcode when no system node (.deb-only)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/ZCode/zcode"); + + expect(resolveZcodeNodeBin()).toBe("/opt/ZCode/zcode"); + }); }); diff --git a/cli/src/__tests__/agents-invoke.test.ts b/cli/src/__tests__/agents-invoke.test.ts index 3cfad7d2..c2e7f1eb 100644 --- a/cli/src/__tests__/agents-invoke.test.ts +++ b/cli/src/__tests__/agents-invoke.test.ts @@ -1,11 +1,35 @@ -import { vi, describe, it, expect, afterEach } from "vitest"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; -const { mockSpawn, existsSyncDelegate } = vi.hoisted(() => ({ - mockSpawn: vi.fn(), - existsSyncDelegate: vi.fn((p: string) => p === "/bin/sh"), -})); +const { mockSpawn, existsSyncDelegate, mockMkdtempSync, mockWriteFileSync, mockRmSync, mockPrepareBinding, bindingOkResult } = vi.hoisted(() => { + return { + mockSpawn: vi.fn(), + existsSyncDelegate: vi.fn((p: string) => p === "/bin/sh"), + // argv-attach (zcode) prompt-delivery path: the invoke layer mkdtemps a temp dir, writes prompt.md into it, and rmSyncs the dir on every exit + // path. Mocked so the tests assert the write/cleanup contract without + // touching the real temp filesystem. + mockMkdtempSync: vi.fn((prefix: string) => `${prefix}TEST`), + mockWriteFileSync: vi.fn(), + mockRmSync: vi.fn(), + // The per-turn model binding boundary. invoke calls one function from + // the binding module; everything else in that module stays real (keep + // the module's exports real so this mock keeps working). The ok-result + // is the shape a real prepared binding returns; refusal tests swap + // `mockPrepareBinding.mockReturnValue`. + mockPrepareBinding: vi.fn(), + bindingOkResult: { + ok: true as const, + selection: { + providerId: "account:bigmodel-individual-coding-plan", + modelId: "GLM-5.2", + reasoningLevel: "high", + }, + clonePath: "/tmp/attach/provider-config.clone.json", + builtinCatalogPath: "/install/resources/config/provider/zcode-builtin.json", + }, + }; +}); vi.mock("node:child_process", () => ({ spawn: mockSpawn, @@ -13,16 +37,34 @@ vi.mock("node:child_process", () => ({ vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); - return { ...actual, existsSync: existsSyncDelegate }; + return { + ...actual, + existsSync: existsSyncDelegate, + mkdtempSync: mockMkdtempSync, + writeFileSync: mockWriteFileSync, + rmSync: mockRmSync, + }; }); +vi.mock("../zcode-model-binding.js", async () => { + const actual = await vi.importActual< + typeof import("../zcode-model-binding.js") + >("../zcode-model-binding.js"); + return { ...actual, prepareZcodeModelBinding: mockPrepareBinding }; +}); + +import path from "node:path"; import { invokeAgent, type InvokeEvent } from "../agents-invoke.js"; function makeFakeChild() { const stdout = new PassThrough(); const stderr = new PassThrough(); + // Capture stdin writes so the argv-attach tests can prove the prompt is + // NOT piped to stdin (it travels in the --attach temp file). + const stdinWrites: string[] = []; const stdin = new Writable({ - write(_chunk: unknown, _enc: unknown, cb: () => void) { + write(chunk: unknown, _enc: unknown, cb: () => void) { + stdinWrites.push(typeof chunk === "string" ? chunk : String(chunk)); cb(); }, }); @@ -34,7 +76,7 @@ function makeFakeChild() { pid: 99999, }); - return { child, stdout, stderr, stdin }; + return { child, stdout, stderr, stdin, stdinWrites }; } async function collectStream( @@ -82,6 +124,53 @@ describe("invokeAgent", () => { vi.restoreAllMocks(); }); + // Per-element quoting applies only to zcode's shell-fallback corner. The + // shared argv spawn must pass argv verbatim, matching the `main` baseline. + // cli's shared branch was already bare (only next's was touched), so this + // pins that correctness against future regressions. Mirrors next's quoting case. + describe("argv branch without per-element quoting", () => { + it("argv-protocol agent (deepseek-tui) spawns with bare argv elements on win32 without per-element quoting", async () => { + // Force win32 so the shared branch takes the useShell path. Restore in + // finally so a mid-assertion throw can't poison sibling tests. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + existsSyncDelegate.mockImplementation((p: string) => p === BIN_OVERRIDE); + + const stream = invokeAgent({ + agent: "deepseek-tui", + prompt: "make a page", + binOverride: BIN_OVERRIDE, + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + // spawn called once; argv (2nd arg) is bare, no element wrapped in + // quotes (the `main` baseline). + expect(mockSpawn).toHaveBeenCalledTimes(1); + const call = mockSpawn.mock.calls[0]; + const spawnedArgv = call[1] as string[]; + for (const el of spawnedArgv) { + expect(el.startsWith('"')).toBe(false); + expect(el.endsWith('"')).toBe(false); + } + expect(spawnedArgv).toEqual( + expect.arrayContaining(["exec", "--auto", "make a page"]), + ); + expect(call[2]).toMatchObject({ shell: true }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + }); + describe("error cases", () => { it("returns error stream for unknown agent", async () => { const stream = invokeAgent({ @@ -527,4 +616,1060 @@ describe("invokeAgent", () => { }); }); + + // ─── zcode CLI one-shot (argv-attach) + Linux AppImage self-mount ──── + // + // Drives `invokeAgent` end-to-end for the registered `protocol: + // "argv-attach"` agent. The AgentDef's binArgs carry the + // `` sentinel; invoke-time substitution fills it from + // resolveZcodeBin(), which honours ZCODE_BIN (stubbed here to + // `/resolved/zcode.cjs` so the spawn runs `node /resolved/zcode.cjs -p …` + // without depending on a real install. spawn is mocked; the fake CLI child + // emits the captured real NDJSON stream on stdout, and notifications + // on stdout. We collect the resulting InvokeEvent[] and assert delta/done/error. + // Real NDJSON lines captured from the installed ZCode CLI (3.14.1 desktop + // bundle, zcode 0.16.9) running `-p … --output-format stream-json --mode + // yolo` headless. The adapter's parser and invoke plumbing are pinned against + // these real bytes, captured from a real CLI run, not a hand-written sketch. + // Includes the full noise vocabulary observed on a + // single turn: session.titleUpdated / session.resumed / session.updated + // (plugin hook descriptors), turn.started, the model.streaming kinds, the + // turn.completed envelope, and the bare result terminator line. + const ZCODE_STREAM_JSON = String.raw`{"eventId":"ba62ed88-8c55-4431-83ca-32a57b0b2395","payload":{"previousTitle":"","source":"first_input","title":"Reply with exactly the token: PROBE_OK"},"seq":1,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034226,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.titleUpdated"} + {"eventId":"081fe5f4-0d82-493a-be2d-fb69dff0168f","payload":{"directory":"C:\\Users\\dev\\Git\\html-anything","interruptedToolCount":0,"messageCount":15,"partCount":31,"recoveredCompactTimelineCount":0,"recoveredSteerInputCount":0,"resumedTodoCount":0},"seq":2,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034228,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.resumed"} + {"eventId":"704f78d3-0c2d-4566-afd8-c94f6f0906c6","payload":{"descriptor":{"clientVisible":true,"commandDisplay":"node \"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1/hooks/session-start-seen-skills.mjs\"","executionMode":"foreground","executionType":"command","pluginId":"vercel@claude-plugins-official","pluginName":"vercel","sourceKind":"plugin","sourcePath":"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1\\hooks\\hooks.json","timeoutMs":60000},"hookEventName":"SessionStart","hookIndex":0,"hookCount":4,"hookInvocationId":"b5547636-0e41-4c9b-b95d-32941f50b6aa","hookRunId":"4699df10-ce5a-42c2-bfde-f7213e1a95a6","hookSource":"plugin.vercel@claude-plugins-official.SessionStart.1.0","matcher":"startup|resume|clear|compact","startedAt":1790039034230},"seq":3,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034230,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.updated"} + {"eventId":"2bf2eb12-0196-4448-9546-fa9bdbbdfd2e","payload":{"executionStartedAt":1790039035583.7253,"turnNumber":7,"input":"Reply with exactly the token: PROBE_OK","messageId":"msg_mubz0vk6_f9084d10-58cb-4fb2-b226-98373bfe8cec","foregroundExecutionId":"runtime_command_1","queryId":"query_cf24b136-0c0f-4afc-a83d-ced3669c001a"},"seq":11,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039035590,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.started"} + {"eventId":"dbd9ae00-db4f-4710-ae4b-c4b66ee1600f","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"start"},"seq":20,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046426,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"2dee4cce-ca91-4521-95e3-b4b68ceaf5d39","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"text_start"},"seq":21,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046426,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"d20d9e75-af8b-4d70-8b12-750a98416233","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"PRO","done":false,"kind":"text_delta"},"seq":22,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046427,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"70e217a9-3d1a-49d0-9049-8411f226dff5","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"BE","done":false,"kind":"text_delta"},"seq":23,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046428,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"43884742-953d-4b45-8496-7e5773bc9996","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"_OK","done":false,"kind":"text_delta"},"seq":24,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046428,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"005c69cb-1785-447f-8f42-6ab57ac111a8","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"text_end"},"seq":25,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046429,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"84413ce1-a3ef-49af-864b-4a81605d8e2f","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":true,"kind":"finish"},"seq":26,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046429,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} + {"eventId":"af34daea-c765-4083-9fa5-2f88eba64782","payload":{"response":"PROBE_OK","tokenCount":134374,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":11229,"resultType":"success","cacheStats":{"totalMessages":23,"cachedMessages":22,"lastCacheHit":true,"cacheReadTokens":64}},"seq":33,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046811,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.completed"} + {"type":"result","sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","response":"PROBE_OK","usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"eventCount":13,"projection":{"status":"idle","turnCount":1,"totalTokenCount":134374,"contextUsed":134374,"contextWindow":200000}}`; + + function zcodeLines(): string[] { + return ZCODE_STREAM_JSON.split("\n").filter((l) => l.trim()); + } + + + /** + * Script the fake AppImage mount child for the Linux self-mount tests. + * The real `AppImage --appimage-mount` prints the FUSE mount point to stdout + * (first line) and stays alive holding the mount. Here the mount point is + * written on a setTimeout(0) so the helper's stdout listener is attached + * before the data arrives (matching real async I/O). Pass `write: false` to + * simulate a mount that never produces a point (timeout / early-exit paths). + */ + function makeMountChild(mountPoint: string, write = true) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdin = new Writable({ write(_c: unknown, _e: unknown, cb: () => void) { cb(); } }); + const child = Object.assign(new EventEmitter(), { + stdin, + stdout, + stderr, + pid: 7777, + }); + if (write) { + setTimeout(() => { stdout.write(`${mountPoint}\n`); }, 0); + } + return { child, stdout, stderr }; + } + + describe("invokeAgent zcode CLI one-shot (argv-attach)", () => { + beforeEach(() => { + // resolveZcodeBin() must return the test .cjs so the binArgs sentinel + // resolves to it (invoke-time substitution). The resolved node driver is + // an absolute `.exe`-style path, so the spawn takes the DIRECT (no-shell) + // Windows path and argv passes verbatim on every host platform. + vi.stubEnv("ZCODE_BIN", "/resolved/zcode.cjs"); + // The host may carry the GUI-inherited provider-config pair (running the + // tests inside a ZCode-spawned terminal exports ZCODE_PERSONAL/_BUILTIN_ + // PROVIDER_CONFIG_FILE to children). Force it absent so the + // binding-active tests are deterministic on every host; the passthrough + // test stubs the pair back on. + delete process.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE; + delete process.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE; + existsSyncDelegate.mockImplementation((p: string) => + p === "/resolved/node.exe" || + p === "/resolved/zcode.cjs" || + p === "/bin/sh", + ); + mockSpawn.mockReset(); + mockMkdtempSync.mockClear(); + mockWriteFileSync.mockClear(); + mockRmSync.mockClear(); + // Default happy-path binding (refusal tests override the return). + mockPrepareBinding.mockReset(); + mockPrepareBinding.mockReturnValue(bindingOkResult); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + existsSyncDelegate.mockImplementation((p: string) => p === "/bin/sh"); + }); + + const attachDir = () => mockMkdtempSync.mock.results[0]?.value as string; + const attachFile = () => path.join(attachDir(), "prompt.md"); + // Fake-timer tests drive start() to completion through microtasks only + // (process.nextTick is NOT faked); the same helper the mount describe + // uses for its 5s-timeout test. + const flushMicrotasks = async (iterations = 200) => { + for (let i = 0; i < iterations; i++) { + await Promise.resolve(); + await new Promise((r) => process.nextTick(r)); + } + }; + + it("spawns `node -p --attach --output-format stream-json --mode yolo` and reports start.argv", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stdout.write(`${zcodeLines().join("\n")}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [spawnBin, spawnArgv, spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + Record, + ]; + // Absolute `.exe` bin → DIRECT spawn on win32 (no cmd.exe), plain spawn + // elsewhere: argv elements verbatim on every host, so spaced install paths + // survive with no quoting games. + expect(spawnBin).toBe("/resolved/node.exe"); + expect(spawnOpts.shell ?? false).toBeFalsy(); + // argv[0] is the resolved .cjs (argv[1] of the real process; a bare + // executable would be rejected by Node as an arg), never `app-server`. + expect(spawnArgv[0]).toBe("/resolved/zcode.cjs"); + expect(spawnArgv).not.toContain("app-server"); + const pIdx = spawnArgv.indexOf("-p"); + expect(pIdx).toBe(1); + // `-p` holds the fixed short guide, NOT the prompt. + expect(spawnArgv[pIdx + 1]).not.toBe("build it"); + expect(spawnArgv[pIdx + 1]!.length).toBeLessThan(200); + // The prompt travels as the --attach temp file. + const aIdx = spawnArgv.indexOf("--attach"); + expect(aIdx).toBeGreaterThan(-1); + expect(spawnArgv[aIdx + 1]).toBe(attachFile()); + expect(spawnArgv).toEqual( + expect.arrayContaining(["--output-format", "stream-json", "--mode", "yolo"]), + ); + + const start = events.find((e) => e.type === "start"); + expect(start).toMatchObject({ + type: "start", + bin: "/resolved/node.exe", + argv: spawnArgv, + }); + }); + + it("spawns with ELECTRON_RUN_AS_NODE=1 merged into the env", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockSpawn).toHaveBeenCalledWith( + "/resolved/node.exe", + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: "1" }), + }), + ); + }); + + // The per-turn model binding: the paired provider-config env vars ride + // the spawn env (personal → the temp clone, builtin → the catalog file the + // selection was validated against), and the bound model surfaces as a meta + // event so the log shows what the turn was pinned to. + it("delivers the paired ZCODE_*_PROVIDER_CONFIG_FILE env vars + a bound-model meta event", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + model: "GLM-5.2/high", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + const events = await eventsPromise; + + // The binding got the picker id and the attach temp dir (clone lives there). + expect(mockPrepareBinding).toHaveBeenCalledWith( + expect.objectContaining({ cjsPath: "/resolved/zcode.cjs", model: "GLM-5.2/high", attachDir: attachDir() }), + ); + const [, , spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + { env: Record }, + ]; + expect(spawnOpts.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE).toBe( + bindingOkResult.clonePath, + ); + expect(spawnOpts.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE).toBe( + bindingOkResult.builtinCatalogPath, + ); + expect(spawnOpts.env.ELECTRON_RUN_AS_NODE).toBe("1"); + // Bound model meta right after start (ZCode's own stream has no model meta). + const meta = events.find((e) => e.type === "meta" && e.key === "model"); + expect(meta).toMatchObject({ type: "meta", key: "model", value: "GLM-5.2/high" }); + }); + + // Fail-refuse: a broken link in the resolution chain refuses the spawn + // with the refusal error rather than silently falling back to whatever the + // CLI would pick on its own. + it("refuses to spawn when the binding resolution fails (error event, no spawn, temp cleaned)", async () => { + mockPrepareBinding.mockReturnValue({ + ok: false, + code: "no-usable-provider", + message: "ZCode: no usable model provider. Open ZCode, log in or add a model provider, then retry.", + }); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + const events = await collectStream(stream); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Open ZCode"), + }); + // The attach temp dir (holding the would-be clone) is cleaned on refusal. + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), expect.anything()); + }); + + // Escape hatch: a user who pre-set both provider-config vars keeps + // them verbatim (zero-code reroute); the adapter neither overrides them nor + // prepares its own binding. + it("passes a user-set env pair through untouched and skips its own binding", async () => { + vi.stubEnv("ZCODE_PERSONAL_PROVIDER_CONFIG_FILE", "/user/personal.json"); + vi.stubEnv("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE", "/user/builtin.json"); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockPrepareBinding).not.toHaveBeenCalled(); + const [, , spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + { env: Record }, + ]; + expect(spawnOpts.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE).toBe("/user/personal.json"); + expect(spawnOpts.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE).toBe("/user/builtin.json"); + }); + + it("bridges the full stream-json surface: deltas, usage/duration/result, session; noise lines produce nothing and usage is emitted exactly once", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stdout.write(`${ZCODE_STREAM_JSON}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + const deltas = events.filter((e) => e.type === "delta"); + expect(deltas.map((d) => (d as { text: string }).text)).toEqual(["PRO", "BE", "_OK"]); + // Noise envelope lines (session.updated hook frames …) and turn.started + // produce nothing: dropped, not forwarded as raw / error events. The + // meta events are exactly: the bound-model line after start, then + // turn.completed's usage (snake_case) + duration_ms + resultType, then + // the result terminator's sessionId. usage appears exactly once; the + // terminator's duplicate cumulative numbers are never re-emitted. + expect(events.filter((e) => e.type === "raw")).toEqual([]); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect( + events.filter((e) => e.type === "meta" && e.key !== "model"), + ).toEqual([ + { + type: "meta", + key: "usage", + value: { + input_tokens: 134370, + output_tokens: 4, + cache_read_input_tokens: 64, + cache_creation_input_tokens: 0, + }, + }, + { type: "meta", key: "duration_ms", value: 11229 }, + { type: "meta", key: "result", value: "success" }, + { type: "meta", key: "session", value: "sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2" }, + ]); + expect(events.filter((e) => e.type === "meta" && e.key === "usage")).toHaveLength(1); + expect(events[events.length - 1]).toMatchObject({ type: "done", code: 0 }); + }); + + it("bridges reasoning_delta to thinking meta and tool phases to status lines + HTML rescue", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + // Envelope fields (sessionId/turnId/eventId/…) verbatim from the real + // capture above; the reasoning_delta / tool_call / tool.updated payload + // shapes are pinned in the ZCode open-source contracts + // (session.events.ts ModelStreamingPayload / ToolCallResultPayload). + stdout.write( + [ + JSON.stringify({ seq: 1, sessionId: "s", type: "model.streaming", payload: { kind: "reasoning_delta", delta: "先想清楚布局" } }), + JSON.stringify({ seq: 2, sessionId: "s", type: "model.streaming", payload: { kind: "tool_call", toolCallId: "tc_1", toolName: "WebSearch", input: { query: "css" } } }), + JSON.stringify({ seq: 3, sessionId: "s", type: "tool.updated", payload: { kind: "result", toolCallId: "tc_1", result: { success: true, content: "…" }, duration: 42 } }), + JSON.stringify({ seq: 4, sessionId: "s", type: "model.streaming", payload: { kind: "tool_call", toolCallId: "tc_2", toolName: "Write", input: { file_path: "C:\\ws\\out.html", content: "r" } } }), + JSON.stringify({ seq: 5, sessionId: "s", type: "tool.updated", payload: { kind: "result", toolCallId: "tc_9_unknown", result: { success: true, content: "…" }, duration: 1 } }), + ].join("\n") + "\n", + ); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + expect( + events.filter((e) => e.type === "meta" && e.key !== "model"), + ).toEqual([ + { type: "meta", key: "thinking", value: "先想清楚布局" }, + { type: "meta", key: "status", value: "调用工具 WebSearch" }, + { type: "meta", key: "status", value: "工具 WebSearch 完成" }, + ]); + // The Write tool's input is the deliverable, forwarded as a canonical + // html event (replaces streamed text), not a status line. + expect(events.filter((e) => e.type === "html")).toEqual([ + { type: "html", text: "r" }, + ]); + // The nameless result (no preceding tool_call for tc_9_unknown) emits + // no status line at all; a bare ✓ with no context is noise. + }); + + it("maps turn.failed to an error event (consumer-side red-error gate fires on it)", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + // Verbatim-captured failure line from a real failed run: + // fresh-config turn whose model binding never resolved. + stdout.write( + `{"eventId":"d66c6937-6bd7-4f3d-a38a-43d252a1e052","payload":{"error":{"type":"unknown_error","attribution":{"retryable":false},"code":"CONFIGURATION_ERROR","message":"Select a model before continuing","detail":"Model creation failed"},"turnPhase":"model_creation"},"seq":1,"sessionId":"sess_b154be6b-7148-4098-8d80-6f717ce7bdb8","timestamp":1790041331523,"turnId":"turn_28ed2805-a161-450f-a426-9f3182f003bb","type":"turn.failed"}\n`, + ); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 1); + + const events = await eventsPromise; + const errors = events.filter((e) => e.type === "error"); + // The stream's own turn.failed error comes first; the exit handling then maps the + // non-zero exit to a second error right before done. + expect(errors[0]).toEqual({ type: "error", message: "Select a model before continuing" }); + expect(errors[1]).toMatchObject({ + type: "error", + message: expect.stringMatching(/exited with code 1/), + }); + }); + + it("writes the full prompt to the attach temp file (not on argv, not on stdin)", async () => { + const { child, stdout, stdinWrites } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "FULL PROMPT BODY", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockMkdtempSync).toHaveBeenCalledTimes(1); + expect(mockWriteFileSync).toHaveBeenCalledWith(attachFile(), "FULL PROMPT BODY", "utf8"); + expect(stdinWrites.join("")).not.toContain("FULL PROMPT BODY"); + }); + + it("removes the attach temp dir on normal close", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir when the child errors", async () => { + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + child.emit("error", new Error("spawn ENOENT")); + await collectStream(stream); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir on abort", async () => { + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + const controller = new AbortController(); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + await new Promise((r) => setTimeout(r, 0)); + controller.abort(); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir when the consumer cancels the stream", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + await stream.cancel(); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + // ─── Failure & teardown hardening ──────────────────────────────────── + + it("errors and tears the turn down after 180s of zero parsed events", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + const killSpy = vi.fn(); + (child as unknown as { kill: unknown }).kill = killSpy; + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + // A NOISE line (session.*, parses to zero events) arrives mid-window. + // It must NOT reset the clock: the watchdog counts PARSED events only. + vi.advanceTimersByTime(90_000); + stdout.write(`${zcodeLines()[0]}\n`); + await flushMicrotasks(); + vi.advanceTimersByTime(90_000); // 180s since the spawn, 90s since the noise + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect((errors[0] as { message?: string }).message).toMatch(/no stream events for 180s/); + // Child killed, attach temp dir removed, stream closed, and NO done + // event: the turn failed, it did not complete. + expect(killSpy).toHaveBeenCalled(); + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + expect(events.some((e) => e.type === "done")).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("resets the silence clock on every parsed event", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + // t+100s: a PARSED text_delta arrives → the clock restarts from here. + vi.advanceTimersByTime(100_000); + stdout.write( + `{"payload":{"delta":"BE","done":false,"kind":"text_delta"},"seq":1,"type":"model.streaming"}\n`, + ); + await flushMicrotasks(); + // t+200s (100s since the delta): without the reset the spawn-time timer + // would have fired at t=180s. The clock restarted → still armed. + vi.advanceTimersByTime(100_000); + await flushMicrotasks(); + expect(vi.getTimerCount()).toBe(1); + + // 180s of silence counted from the delta → fires. + vi.advanceTimersByTime(80_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect((errors[0] as { message?: string }).message).toMatch(/no stream events for 180s/); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not arm the watchdog for non-zcode agents", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "claude", + prompt: "p", + binOverride: "/bin/sh", + }); + + await flushMicrotasks(); + // Never armed for a sibling agent… + expect(vi.getTimerCount()).toBe(0); + + // …so a full silence window + close produces no watchdog error and the + // close path still reaches done (historical behavior intact). + vi.advanceTimersByTime(180_500); + await flushMicrotasks(); + stdout.end(); + await flushMicrotasks(); + child.emit("close", 0); + + const events = await collectStream(stream); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(true); + }); + + it("never arms the watchdog before the spawn (binding-refusal window)", async () => { + vi.useFakeTimers(); + mockPrepareBinding.mockReturnValue({ ok: false, message: "no model plan found" }); + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + + const events = await collectStream(stream); + expect(events.some((e) => e.type === "error")).toBe(true); + expect(mockSpawn).not.toHaveBeenCalled(); // refused before the spawn + // No orphaned timer survives the refusal. + expect(vi.getTimerCount()).toBe(0); + }); + + it("maps a non-zero exit to an error BEFORE done, carrying the stderr essence", async () => { + const { child, stdout, stderr } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stderr.write("node:internal/modules/cjs/loader:1145\nError: Cannot find module 'zcode.cjs'\n"); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 3); + + const events = await eventsPromise; + const idxErr = events.findIndex((e) => e.type === "error"); + const idxDone = events.findIndex((e) => e.type === "done"); + expect(idxErr).toBeGreaterThan(-1); + expect(idxDone).toBeGreaterThan(idxErr); + const message = (events[idxErr] as { message: string }).message; + expect(message).toMatch(/exited with code 3/); + expect(message).toContain("Cannot find module 'zcode.cjs'"); + expect((events[idxDone] as { code: number }).code).toBe(3); + }); + + it("caps the stderr essence carried by the exit error", async () => { + const { child, stdout, stderr } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stderr.write(`HEAD_MARKER${"x".repeat(3_000)}TAIL_MARKER`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 1); + + const events = await eventsPromise; + const errors = events.filter((e) => e.type === "error"); + const message = (errors[0] as { message?: string }).message ?? ""; + expect(message).toContain("TAIL_MARKER"); + expect(message).not.toContain("HEAD_MARKER"); + }); + + it("keeps the exit-0 close shape with no exit error", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.write(`${zcodeLines().join("\n")}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(true); + }); + + it("clears the watchdog on abort; nothing fires after the cancel", async () => { + vi.useFakeTimers(); + const { child } = makeFakeChild(); + const killSpy = vi.fn(); + (child as unknown as { kill: unknown }).kill = killSpy; + mockSpawn.mockReturnValue(child); + const controller = new AbortController(); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + await flushMicrotasks(); + controller.abort(); + await flushMicrotasks(); + // Advancing past the whole silence window must not fire anything: the + // abort path already killed the child, cleaned the temp dir, and closed + // the stream. + vi.advanceTimersByTime(180_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(false); + expect(killSpy).toHaveBeenCalled(); + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + describe("invokeAgent Linux AppImage self-mount (CLI one-shot form)", () => { + const mountCjs = (mp: string) => `${mp}/resources/glm/zcode.cjs`; + const REAL_PLATFORM = process.platform; + const flushMicrotasks = async (iterations = 200) => { + for (let i = 0; i < iterations; i++) { + await Promise.resolve(); + await new Promise((r) => process.nextTick(r)); + } + }; + + beforeEach(() => { + mockSpawn.mockReset(); + mockMkdtempSync.mockClear(); + mockWriteFileSync.mockClear(); + mockRmSync.mockClear(); + existsSyncDelegate.mockImplementation((p: string) => + p === "/opt/ZCode.AppImage" || + p === "/resolved/node.exe" || + p === "/bin/sh", + ); + vi.stubEnv("ZCODE_BIN", "/opt/ZCode.AppImage"); + }); + afterEach(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); + vi.unstubAllEnvs(); + vi.useRealTimers(); + existsSyncDelegate.mockImplementation((p: string) => p === "/bin/sh"); + }); + + const isMountSpawn = (argv: string[]) => argv.includes("--appimage-mount"); + + // (1) The mount child is spawned BEFORE the CLI child, and the CLI argv + // holds the mount-point cjs path (not the AppImage path). + it("mounts the AppImage before spawning the CLI; cjs argv is the mount-point path", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mountPoint = "/tmp/.mount_ZCode-xxxx"; + const mount = makeMountChild(mountPoint); + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + // Let the mount resolve (setTimeout(0) write + microtasks + CLI spawn). + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + await eventsPromise; + + // Two spawns: mount first, CLI second. + expect(mockSpawn).toHaveBeenCalledTimes(2); + const [mountCall, appCall] = mockSpawn.mock.calls as unknown as [string, string[]][]; + expect(mountCall[0]).toBe("/opt/ZCode.AppImage"); + expect(mountCall[1]).toEqual(["--appimage-mount"]); + expect(appCall[0]).toBe("/resolved/node.exe"); + // CLI argv: mount-point cjs in argv[0], the one-shot flags, and no + // `app-server` subcommand anywhere. + expect(appCall[1][0]).toBe(mountCjs(mountPoint)); + expect(appCall[1]).not.toContain("app-server"); + expect(appCall[1]).toEqual( + expect.arrayContaining(["-p", "--attach", "--output-format", "stream-json", "--mode", "yolo"]), + ); + }); + + // (2) The start event reports the mount-point cjs argv (proves the cjs path + // flows through to the caller, not only to the spawn). + it("reports the mount-point cjs path in the start event argv", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mountPoint = "/tmp/.mount_ZCode-yyyy"; + const mount = makeMountChild(mountPoint); + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + + const events = await eventsPromise; + const startEv = events.find((e) => e.type === "start"); + expect(startEv).toMatchObject({ + type: "start", + bin: "/resolved/node.exe", + }); + expect((startEv as { argv: string[] }).argv[0]).toBe(mountCjs(mountPoint)); + }); + + // (3) When the CLI child dies mid-turn, teardown kills the mount child + // too (per-turn mount+unmount, no leak across turns). + it("kills the mount child when the CLI child dies mid-turn", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/tmp/.mount_ZCode-zzz"); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + // CLI child dies → the close handler's cleanup kills the mount holder. + app.child.emit("close", 1); + await eventsPromise; + + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (4) When the stream consumer cancels, cancel() (a ReadableStream sibling + // of start()) kills the mount child (it can't reach start()'s locals). + it("kills the mount child when the stream consumer cancels", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/tmp/.mount_ZCode-cancel"); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + await stream.cancel(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (5) A mount child that exits non-zero (FUSE missing / corrupt AppImage) + // produces a clear error event and never spawns the CLI child. + it("emits a clear error and never spawns the CLI when the mount child exits non-zero", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 10)); + // Mount child exits with an error before printing a mount point. + mount.child.emit("close", 1); + await new Promise((r) => setTimeout(r, 10)); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect((errors[0] as { message?: string }).message).toMatch(/mount/i); + // Only the mount child was spawned; the CLI never reached. + expect(mockSpawn).toHaveBeenCalledTimes(1); + // Mount child was cleaned up on the failure path. + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (6) Mount setup has a bounded ~5s timeout. A mount that never + // prints a point fires the timeout, emits a clear error, and cleans up. + it("fires a clear error after the 5s mount timeout and cleans up the mount child", async () => { + vi.useFakeTimers(); + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + // Drive start() to the mount await (spawn done, listeners attached, 5s + // timer armed). No stdout → the await blocks until the timer fires. + await flushMicrotasks(); + vi.advanceTimersByTime(5_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect((errors[0] as { message?: string }).message).toMatch(/mount timed out/i); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (7) An abort during the mount window (before the outer onAbort is + // registered) is honored by the signal wired into mountZcodeAppImage: the + // mount child is killed and a clear error surfaces, without waiting for the + // 5s timeout. + it("honors an abort during the mount window (signal wired into the mount helper)", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const controller = new AbortController(); + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + // Abort during the mount window (mount child never prints a point). + await new Promise((r) => setTimeout(r, 10)); + controller.abort(); + await new Promise((r) => setTimeout(r, 10)); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // resolveZcodeBin() honours a ZCODE_BIN that + // points directly at the zcode.cjs bundle (the detect tests set + // ZCODE_BIN=<…>.cjs and assert available=true). For a `.cjs` the AppImage + // self-mount would spawn `<.cjs> --appimage-mount` and fail; a JS bundle is + // not an executable AppImage. So on Linux the `.cjs` is used directly: + // exactly ONE spawn (the CLI child), argv holds the override in argv[0], + // and no spawn argv contains `--appimage-mount`. + it("uses a ZCODE_BIN .cjs override directly on Linux with no AppImage mount", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + vi.stubEnv("ZCODE_BIN", "/opt/zcode/override.cjs"); + existsSyncDelegate.mockImplementation((p: string) => + p === "/opt/zcode/override.cjs" || + p === "/resolved/node.exe" || + p === "/bin/sh", + ); + const app = makeFakeChild(); + mockSpawn.mockReturnValue(app.child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + + const events = await eventsPromise; + + // Exactly ONE spawn, the CLI child. No mount child is spawned. + expect(mockSpawn).toHaveBeenCalledTimes(1); + const calls = mockSpawn.mock.calls as unknown as [string, string[]][]; + const [appCall] = calls; + expect(appCall[0]).toBe("/resolved/node.exe"); + // argv uses the .cjs override verbatim, NOT a `/resources/glm/...` path. + expect(appCall[1][0]).toBe("/opt/zcode/override.cjs"); + // No spawn argv contains `--appimage-mount` (the mount flow never ran). + for (const [, argv] of calls) { + expect(argv).not.toContain("--appimage-mount"); + } + // The .cjs override propagates to the start event too. + const startEv = events.find((e) => e.type === "start"); + expect((startEv as { argv: string[] }).argv[0]).toBe("/opt/zcode/override.cjs"); + }); + }); + }); diff --git a/cli/src/__tests__/zcode-model-binding.test.ts b/cli/src/__tests__/zcode-model-binding.test.ts new file mode 100644 index 00000000..55d85eb9 --- /dev/null +++ b/cli/src/__tests__/zcode-model-binding.test.ts @@ -0,0 +1,1172 @@ +// Unit tests for the registry-mirror model binding (v3): enumeration of +// the headless CLI's provider registry (account individual plans paired by +// credential keys ⊕ keyed personal providers), ordering, the modelRules +// level table, the enabled overlays, the default chain, the six-link +// refusal chain, the add-only identity bridge with the zero-write contract +// on the user's real files, and the verified binding discriminator. +// +// Fixture families (scrubbed from two real machines; structure verbatim): +// - F3 catalog: next to this file, __fixtures__/zcode-builtin.fixture.json — +// the install's rule tables (all-vendor modelRules verbatim, the three +// templates the personal fixtures instantiate, their templateModelRules, +// all account providerRules, builtinProviderModelRules). Keys, account +// ids, and gateway hosts in the personal fixtures are placeholders. +// - F1 dual-form machine: 4 keyed personal providers (two templates with +// empty personalModelIds, one custom local gateway, one custom uuid +// gateway) + credentials holding the paired individual keys, the GUI's +// team shell api-key key, and the oauth keys. +// - F2 pure API-key machine: a single keyed bigmodel-api template instance +// with empty personalModelIds/modelOrder and NO credentials.json — the +// shape the v2 "plan parsing" rejected wholesale (regression anchor). +// +// No setting.json is ever written in these tests: v3 reads none, and the +// one explicit test below proves a stale one cannot influence anything. +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + decodeZcodeModelChoice, + encodeZcodeModelChoice, + enumerateZcodeRegistry, + migrateLegacyZcodeProviderId, + prepareZcodeModelBinding, + readZcodePickerState, + resolveZcodeCatalogFile, + resolveZcodeDefaultSelection, + zcodeBundledCatalogPathForCjs, + zcodeCachePlatform, + zcodeProviderEnvPairSet, + stripZcodeHostProviderEnv, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, +} from "../zcode-model-binding.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// ─── F3: the install catalog fixture (structure verbatim) ─────────────── + +function catalogFixture(): Record { + return JSON.parse( + readFileSync(join(here, "__fixtures__", "zcode-builtin.fixture.json"), "utf8"), + ) as Record; +} + +/** Deep-clone + mutate helper for the catalog variants below. */ +function catalogVariant(mutate: (catalog: Record) => void): Record { + const catalog = JSON.parse(JSON.stringify(catalogFixture())) as Record; + mutate(catalog); + return catalog; +} + +// ─── F1: the dual-form machine (personal config + credentials) ────────── + +const WECHAT_PROVIDER_ID = "00000000-1111-4222-8333-444444444444"; +const ACCOUNT_ID = "10000000000000001"; +const ACCOUNT_PROVIDER_ID = "account:bigmodel-individual-coding-plan"; + +function dualFormPersonalConfig(extraConfig: Record = {}): Record { + return { + schemaVersion: 1, + config: { + providerOrder: ["deepseek", "bigmodel-standard-api", "new-provider", WECHAT_PROVIDER_ID], + providerConfigRules: { + providerRules: [ + { + providerId: WECHAT_PROVIDER_ID, + providerName: "WeChat CodingPlan Token", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-wechat-token" }, + api: { type: "openai-chat-completions", baseUrl: "https://gateway.example.com/openai/v1" }, + personalModelIds: ["GLM-5.2", "Deepseek-v4-flash"], + modelOrder: ["GLM-5.2", "Deepseek-v4-flash"], + }, + }, + { + providerId: "bigmodel-standard-api", + templateId: "bigmodel-standard-api", + providerName: "BigModel API", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-bigmodel-key" }, + api: { type: "openai-chat-completions" }, + personalModelIds: [], + modelOrder: [ + "GLM-5.3", "GLM-5.3-Flash", "GLM-5V-Turbo", "GLM-5.1", "GLM-5.1-Highspeed", + "GLM-5", "GLM-5-Turbo", "GLM-4.7", "GLM-4.7-FlashX", "GLM-4.7-Flash", + "GLM-4.6", "GLM-4.5-Air", "GLM-4.5", "GLM-4.6V", "GLM-4.6V-Flash", + "GLM-4.6V-FlashX", "GLM-4.1V-Thinking-FlashX", "GLM-4.1V-Thinking-Flash", + "GLM-4-FlashX-250414", "GLM-4-Flash-250414", "GLM-4V-Flash", "codegeex-4", + "charglm-4", "emohaa", + ], + }, + }, + { + providerId: "deepseek", + templateId: "deepseek", + providerName: "DeepSeek", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-deepseek-key" }, + api: { type: "openai-responses", baseUrl: "https://api.deepseek.com" }, + personalModelIds: [], + modelOrder: ["deepseek-flash", "deepseek-v4-pro"], + }, + }, + { + providerId: "new-provider", + providerName: "llama.cpp", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "sk-local" }, + api: { type: "openai-chat-completions", baseUrl: "http://127.0.0.1:8027/v1" }, + personalModelIds: ["Qwen3.6-35B-A3B", "Qwen3.8-27B"], + modelOrder: ["Qwen3.6-35B-A3B", "Qwen3.8-27B"], + }, + }, + ], + }, + modelConfigRules: { + providerModelRules: [ + { + modelId: "GLM-5.2", + config: { properties: { contextWindow: 200000 } }, + providerId: WECHAT_PROVIDER_ID, + }, + { + modelId: "Deepseek-v4-flash", + config: { properties: { contextWindow: 1000000 } }, + providerId: WECHAT_PROVIDER_ID, + }, + { + modelId: "Qwen3.6-35B-A3B", + config: { + enabled: true, + properties: { contextWindow: 262144, inputFormat: { supportsImage: true } }, + supportsJsonSchemaOutput: true, + supportsMidConversationSystem: true, + }, + optionSpecs: { maxOutputTokens: { max: 16384 } }, + providerId: "new-provider", + }, + { + modelId: "Qwen3.8-27B", + config: { + enabled: false, + properties: { contextWindow: 16384, inputFormat: { supportsImage: true } }, + supportsJsonSchemaOutput: true, + supportsMidConversationSystem: true, + }, + optionSpecs: { maxOutputTokens: { max: 8192 } }, + providerId: "new-provider", + }, + ], + manualProviderModelRules: [], + }, + ...extraConfig, + }, + }; +} + +/** F1 credential key names (values are opaque fakes; only names and the + * plaintext identity value matter to the adapter). The team api-key key is + * the GUI's auto-laid shell: no identity key exists for it. */ +function dualFormCredentials(): Record { + return { + "oauth:bigmodel:access_token": "enc:v1:fixture", + "oauth:bigmodel:user_info": "enc:v1:fixture", + zcodejwttoken: "enc:v1:fixture", + "oauth:active_provider": "enc:v1:fixture", + [`account-provider:coding-plan:${ACCOUNT_PROVIDER_ID}:account:${ACCOUNT_ID}:api-key`]: + "enc:v1:fixture", + [`account-provider:coding-plan:account:bigmodel-team-coding-plan:account:${ACCOUNT_ID}:api-key`]: + "enc:v1:fixture", + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: ACCOUNT_ID, + }; +} + +// ─── F2: the pure API-key machine ──────────────────────────────────────── + +function pureApiKeyPersonalConfig(): Record { + return { + schemaVersion: 1, + config: { + providerOrder: ["bigmodel-api"], + providerConfigRules: { + providerRules: [ + { + providerId: "bigmodel-api", + templateId: "bigmodel-api", + providerName: "BigModel Coding Plan", + config: { + group: "standard-personal", + access: { type: "zhipu-coding-plan-api-key", apiKey: "fixture-coding-plan-key" }, + personalModelIds: [], + modelOrder: [], + }, + }, + ], + }, + modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] }, + }, + }; +} + +// ─── Fixture tree (install + personal + credentials on temp disk) ──────── + +interface FixtureTree { + cjs: string; + catalogPath: string; + personalPath: string; + credentialsPath: string; + attachDir: string; + dir: string; +} + +function writeFixtureTree( + dir: string, + opts: { + catalog?: unknown; + personal?: Record; + credentials?: Record | null; + } = {}, +): FixtureTree { + const catalog = opts.catalog ?? catalogFixture(); + const install = join(dir, "install"); + mkdirSync(join(install, "resources", "glm"), { recursive: true }); + mkdirSync(join(install, "resources", "config", "provider"), { recursive: true }); + const cjs = join(install, "resources", "glm", "zcode.cjs"); + writeFileSync(cjs, ""); + const catalogPath = join(install, "resources", "config", "provider", "zcode-builtin.json"); + writeFileSync(catalogPath, JSON.stringify(catalog)); + const personalPath = join(dir, "provider_config.json"); + writeFileSync(personalPath, JSON.stringify(opts.personal ?? dualFormPersonalConfig())); + const credentialsPath = join(dir, "credentials.json"); + const credentials = opts.credentials === undefined ? dualFormCredentials() : opts.credentials; + if (credentials !== null) writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2)); + const attachDir = join(dir, "attach"); + mkdirSync(attachDir, { recursive: true }); + return { cjs, catalogPath, personalPath, credentialsPath, attachDir, dir }; +} + +// ─── Registry enumeration ───────────────────────────────────────────────── + +describe("enumerateZcodeRegistry (the registry mirror)", () => { + it("F1 dual-form: account individual first (catalog order), then personal by providerOrder; team shell keys produce no entry", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + dualFormCredentials(), + ); + expect(providers.map((p) => [p.providerId, p.providerName, p.kind])).toEqual([ + [ACCOUNT_PROVIDER_ID, "BigModel Individual Coding Plan", "account"], + ["deepseek", "DeepSeek", "personal"], + ["bigmodel-standard-api", "BigModel API", "personal"], + ["new-provider", "llama.cpp", "personal"], + [WECHAT_PROVIDER_ID, "WeChat CodingPlan Token", "personal"], + ]); + // The GUI's team shell api-key key names a team provider (no identity + // key, and team mode is never expanded): no team entry anywhere. + expect(providers.some((p) => p.providerId.includes("team"))).toBe(false); + expect(providers.some((p) => p.providerId.includes("start"))).toBe(false); + expect(providers.some((p) => p.providerId.includes("offpeak"))).toBe(false); + // The paired identity rides along for the account entry. + expect(providers[0]!.accountIdentity).toBe(ACCOUNT_ID); + expect(providers.slice(1).every((p) => p.accountIdentity === undefined)).toBe(true); + }); + + it("F1 model tables: template builtin lists ∪ personalModelIds under the enabled overlays, with modelRules levels", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + dualFormCredentials(), + ); + const modelsOf = (p: (typeof providers)[number]) => p.models.map((m) => [m.modelId, m.levels]); + // Account plan: catalog builtinModelIds, levels from the glm-5.3 rule. + expect(modelsOf(providers[0]!)).toEqual([ + ["GLM-5.3", ["low", "high", "max"]], + ["GLM-5.3-Flash", ["low", "high", "max"]], + ]); + // deepseek template: personalModelIds empty → template builtinModelIds + // in modelOrder, templateModelRules entries enabled. + expect(modelsOf(providers[1]!)).toEqual([ + ["deepseek-flash", ["disabled", "low", "high", "max"]], + ["deepseek-v4-pro", ["disabled", "low", "high", "max"]], + ]); + // bigmodel-standard-api: 24 template ids, but templateModelRules + // disables all but GLM-5.3 / GLM-5.3-Flash. + expect(modelsOf(providers[2]!)).toEqual([ + ["GLM-5.3", ["low", "high", "max"]], + ["GLM-5.3-Flash", ["low", "high", "max"]], + ]); + // llama.cpp custom provider: Qwen3.8-27B dropped by the personal + // providerModelRules enabled:false entry; Qwen3.6-35B-A3B hits no + // specific rule → the base .* table only. + expect(modelsOf(providers[3]!)).toEqual([["Qwen3.6-35B-A3B", ["disabled", "enabled"]]]); + // WeChat custom gateway: personalModelIds as-is. + expect(modelsOf(providers[4]!)).toEqual([ + ["GLM-5.2", ["disabled", "high", "max"]], + ["Deepseek-v4-flash", ["disabled", "low", "high", "max"]], + ]); + }); + + it("F1 without the credential pair: account drops out, personal providers remain", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + // identity key present but naming a DIFFERENT account: the CLI's own + // entitlement check pairs identity value → api-key key name, so this + // does not materialize either. + { + ...dualFormCredentials(), + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: "10000000000000002", + }, + ); + expect(providers.map((p) => p.providerId)).toEqual([ + "deepseek", + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + ]); + }); + + it("F2 pure API-key machine: single template instance enumerates the template builtinModelIds with levels; no credentials.json at all", () => { + // Regression anchor: the v2 plan-parsing rejected this shape wholesale + // ("not logged in"); the registry mirror enumerates it. + const providers = enumerateZcodeRegistry(catalogFixture(), pureApiKeyPersonalConfig(), null); + expect(providers.map((p) => [p.providerId, p.kind])).toEqual([["bigmodel-api", "personal"]]); + expect(providers[0]!.models).toEqual([ + { modelId: "GLM-5.3", levels: ["low", "high", "max"] }, + { modelId: "GLM-5.3-Flash", levels: ["low", "high", "max"] }, + ]); + }); + + it("a keyed provider missing from providerOrder follows after the ordered ones (declaration order)", () => { + const personal = dualFormPersonalConfig(); + const config = personal.config as { providerOrder: string[] }; + config.providerOrder = ["bigmodel-standard-api", "new-provider", WECHAT_PROVIDER_ID]; + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + expect(providers.map((p) => p.providerId)).toEqual([ + ACCOUNT_PROVIDER_ID, + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + "deepseek", + ]); + }); + + it("keyless / hidden / disabled personal entries are not enumerated (an explicit pick of them refuses later)", () => { + const personal = dualFormPersonalConfig(); + const rules = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules; + // Strip deepseek's key, hide llama.cpp, disable the WeChat entry. + const deepseek = rules.find((r) => r.providerId === "deepseek") as { + config: { access: { apiKey?: string } }; + }; + delete deepseek.config.access.apiKey; + const llama = rules.find((r) => r.providerId === "new-provider") as { + config: Record; + }; + llama.config.visibility = "hidden"; + const wechat = rules.find((r) => r.providerId === WECHAT_PROVIDER_ID) as { + config: Record; + }; + wechat.config.enabled = false; + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + expect(providers.map((p) => p.providerId)).toEqual([ + ACCOUNT_PROVIDER_ID, + "bigmodel-standard-api", + ]); + }); + + it("a catalog whose modelRules match nothing leaves models level-less (still enumerable)", () => { + const catalog = catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = []; + }); + const providers = enumerateZcodeRegistry(catalog, dualFormPersonalConfig(), dualFormCredentials()); + expect(providers[0]!.models).toEqual([ + { modelId: "GLM-5.3", levels: [] }, + { modelId: "GLM-5.3-Flash", levels: [] }, + ]); + }); + + it("a personal enabled:true re-enables a template-disabled model (last-write-wins overlay)", () => { + // The catalog's templateModelRules disables GLM-5V-Turbo on + // bigmodel-standard-api; a personal providerModelRules entry with + // enabled:true re-enables it, mirroring composeEffective's + // [...builtin, ...personal] overlay order. + const personal = dualFormPersonalConfig(); + (personal.config as { modelConfigRules: { providerModelRules: unknown[] } }).modelConfigRules + .providerModelRules.push({ + modelId: "GLM-5V-Turbo", + config: { enabled: true }, + providerId: "bigmodel-standard-api", + }); + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + const standard = providers.find((p) => p.providerId === "bigmodel-standard-api")!; + expect(standard.models.map((m) => m.modelId)).toEqual([ + "GLM-5.3", + "GLM-5.3-Flash", + "GLM-5V-Turbo", + ]); + expect(standard.models[2]!.levels).toEqual(["disabled", "enabled"]); + }); + + it("malformed inputs degrade to an empty registry (never throws)", () => { + expect(enumerateZcodeRegistry(null, null, null)).toEqual([]); + expect(enumerateZcodeRegistry({}, {}, {})).toEqual([]); + expect( + enumerateZcodeRegistry({ schemaVersion: 1, config: {} }, { config: {} }, {}), + ).toEqual([]); + }); +}); + +// ─── Catalog resolution (unchanged mechanics) ───────────────────────────── + +describe("zcodeBundledCatalogPathForCjs", () => { + it("derives /resources/config/provider/zcode-builtin.json from the cjs", () => { + const win = zcodeBundledCatalogPathForCjs("C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs"); + expect(win).toBe( + join("C:\\Program Files\\ZCode", "resources", "config", "provider", "zcode-builtin.json"), + ); + const linux = zcodeBundledCatalogPathForCjs("/opt/ZCode/resources/glm/zcode.cjs"); + expect(linux.endsWith(join("config", "provider", "zcode-builtin.json"))).toBe(true); + expect(dirname(dirname(dirname(linux))).replace(/\\/g, "/")).toBe("/opt/ZCode/resources"); + }); +}); + +describe("resolveZcodeCatalogFile", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-catalog-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Minimal schema-valid body (the shape gate only needs schemaVersion+config). */ + const minimalCatalog = { schemaVersion: 1, config: {} }; + + function writeInstall(dirName: string, body: unknown): { cjs: string } { + const install = join(dir, dirName); + mkdirSync(join(install, "resources", "glm"), { recursive: true }); + mkdirSync(join(install, "resources", "config", "provider"), { recursive: true }); + const cjs = join(install, "resources", "glm", "zcode.cjs"); + writeFileSync(cjs, ""); + writeFileSync(join(install, "resources", "config", "provider", "zcode-builtin.json"), JSON.stringify(body)); + return { cjs }; + } + + function writeCache(version: string, endpoint: string, body: unknown): void { + const p = join(dir, "cache", zcodeCachePlatform(), version, endpoint); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, "zcode-builtin.json"), JSON.stringify(body)); + } + + it("prefers the install-bundled file (version-consistent with the cjs)", () => { + writeCache("3.14.3", "endpoint-a", minimalCatalog); + const { cjs } = writeInstall("install", minimalCatalog); + const catalog = resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") }); + expect(catalog!.source).toBe("install"); + expect(catalog!.path).toBe(join(dirname(dirname(cjs)), "config", "provider", "zcode-builtin.json")); + }); + + it("falls back to the NEWEST cache version by freshness (3.14.10 > 3.14.9 > 3.9.2)", () => { + const { cjs } = writeInstall("install-empty", {}); + writeCache("3.9.2", "endpoint-a", minimalCatalog); + writeCache("3.14.9", "endpoint-a", minimalCatalog); + writeCache("3.14.10", "endpoint-b", minimalCatalog); + const catalog = resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") }); + expect(catalog!.source).toBe("cache"); + expect(catalog!.path).toContain("3.14.10"); + }); + + it("a schemaVersion other than 1 is rejected (schema gate)", () => { + const { cjs } = writeInstall("install", { ...minimalCatalog, schemaVersion: 2 }); + expect(resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") })).toBeNull(); + }); + + it("nothing readable → null (catalog-unreadable refusal link)", () => { + const { cjs } = writeInstall("install", { schemaVersion: 1 }); + // config object missing → invalid shape + expect(resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") })).toBeNull(); + }); +}); + +// ─── Picker id codec ────────────────────────────────────────────────────── + +describe("picker id codec", () => { + it("encodes provider-namespaced ids with and without a level", () => { + expect(encodeZcodeModelChoice("deepseek", "deepseek-flash", "max")).toBe( + "deepseek/deepseek-flash/max", + ); + expect(encodeZcodeModelChoice("new-provider", "Qwen3.6-35B-A3B")).toBe( + "new-provider/Qwen3.6-35B-A3B", + ); + expect(encodeZcodeModelChoice("p", "m", null)).toBe("p/m"); + }); + + it("decode is structural: the first segment is the providerId; empty segments fail", () => { + expect(decodeZcodeModelChoice("deepseek/deepseek-flash/max")).toEqual({ providerId: "deepseek" }); + // Model ids containing "/" (openrouter-style) still decode to the provider. + expect(decodeZcodeModelChoice("my-router/anthropic/claude-fable-5.1")).toEqual({ + providerId: "my-router", + }); + expect(decodeZcodeModelChoice("deepseek")).toBeNull(); + expect(decodeZcodeModelChoice("deepseek/")).toBeNull(); + expect(decodeZcodeModelChoice("/deepseek-flash")).toBeNull(); + expect(decodeZcodeModelChoice("a//b")).toBeNull(); + }); + + it("migrateLegacyZcodeProviderId maps the legacy coding-plan ids one-way", () => { + expect(migrateLegacyZcodeProviderId("builtin:bigmodel-coding-plan")).toBe(ACCOUNT_PROVIDER_ID); + expect(migrateLegacyZcodeProviderId("builtin:zai-coding-plan")).toBe( + "account:zai-individual-coding-plan", + ); + expect(migrateLegacyZcodeProviderId("deepseek")).toBe("deepseek"); + }); +}); + +// ─── Default chain ──────────────────────────────────────────────────────── + +describe("resolveZcodeDefaultSelection", () => { + const registry = () => + enumerateZcodeRegistry(catalogFixture(), dualFormPersonalConfig(), dualFormCredentials()); + + it("no configured default → first registry provider, first model, highest level (the CLI's fallback)", () => { + expect(resolveZcodeDefaultSelection(registry(), dualFormPersonalConfig())).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("a valid configured default wins, with the legacy providerId migrated", () => { + const personal = dualFormPersonalConfig({ + defaultModelSelection: { + providerId: "builtin:bigmodel-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "low" }, + }, + }); + expect(resolveZcodeDefaultSelection(registry(), personal)).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3-Flash", + reasoningLevel: "low", + }); + }); + + it("a configured default on another registry provider is honoured (it still selects)", () => { + const personal = dualFormPersonalConfig({ + defaultModelSelection: { providerId: "deepseek", modelId: "deepseek-flash" }, + }); + expect(resolveZcodeDefaultSelection(registry(), personal)).toEqual({ + providerId: "deepseek", + providerName: "DeepSeek", + modelId: "deepseek-flash", + // CLI-login-written defaults carry no level; the chain completes the + // model's highest level. + reasoningLevel: "max", + }); + }); + + it("a configured default whose provider or level is invalid falls back (never half-applies)", () => { + const foreign = dualFormPersonalConfig({ + defaultModelSelection: { providerId: "gone", modelId: "GLM-5.3", options: { reasoningLevel: "low" } }, + }); + expect(resolveZcodeDefaultSelection(registry(), foreign)!.modelId).toBe("GLM-5.3"); + const badLevel = dualFormPersonalConfig({ + defaultModelSelection: { + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + options: { reasoningLevel: "xhigh" }, + }, + }); + expect(resolveZcodeDefaultSelection(registry(), badLevel)!.reasoningLevel).toBe("max"); + }); + + it("a level-less first model defaults without a reasoningLevel; an empty registry yields null", () => { + const catalog = catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = []; + }); + const providers = enumerateZcodeRegistry(catalog, dualFormPersonalConfig(), dualFormCredentials()); + expect(resolveZcodeDefaultSelection(providers, dualFormPersonalConfig())).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: null, + }); + expect(resolveZcodeDefaultSelection([], dualFormPersonalConfig())).toBeNull(); + }); +}); + +// ─── Picker state composition ───────────────────────────────────────────── + +describe("readZcodePickerState", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-picker-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("F1: ready, provider-namespaced chips with service-name labels, and the resolved Default", () => { + const f = writeFixtureTree(dir); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state.ready).toBe(true); + expect(state.reason).toBeNull(); + expect(state.defaultChoice).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + // Same-name model across providers: distinct ids, service name in label. + const glm53Chips = state.options.filter((o) => + o.id.endsWith("/GLM-5.3/low"), + ); + expect(glm53Chips.map((o) => [o.id, o.label])).toEqual([ + [`${ACCOUNT_PROVIDER_ID}/GLM-5.3/low`, "GLM-5.3 (BigModel Individual Coding Plan, low)"], + ["bigmodel-standard-api/GLM-5.3/low", "GLM-5.3 (BigModel API, low)"], + ]); + // Level-less chips carry no suffix (llama.cpp's Qwen model here has only + // the base .* table, so it does have levels; assert its chip shape via + // the disabled level instead). + expect(state.options).toContainEqual({ + id: "new-provider/Qwen3.6-35B-A3B/disabled", + label: "Qwen3.6-35B-A3B (llama.cpp, disabled)", + providerId: "new-provider", + }); + // Team / start / off-peak never appear in any chip id. + expect(state.options.every((o) => !/team|start-plan|offpeak/.test(o.id))).toBe(true); + }); + + it("level-less models produce chips without a level suffix", () => { + const f = writeFixtureTree(dir, { + catalog: catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = + []; + }), + }); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state.ready).toBe(true); + expect(state.options[0]).toEqual({ + id: `${ACCOUNT_PROVIDER_ID}/GLM-5.3`, + label: "GLM-5.3 (BigModel Individual Coding Plan)", + providerId: ACCOUNT_PROVIDER_ID, + }); + }); + + it("F2 pure API-key machine: ready with the template's models (regression anchor: v2 rejected this shape)", () => { + const f = writeFixtureTree(dir, { personal: pureApiKeyPersonalConfig(), credentials: null }); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state).toMatchObject({ ready: true, reason: null }); + expect(state.options.map((o) => o.id)).toEqual([ + "bigmodel-api/GLM-5.3/low", + "bigmodel-api/GLM-5.3/high", + "bigmodel-api/GLM-5.3/max", + "bigmodel-api/GLM-5.3-Flash/low", + "bigmodel-api/GLM-5.3-Flash/high", + "bigmodel-api/GLM-5.3-Flash/max", + ]); + expect(state.defaultChoice).toEqual({ + providerId: "bigmodel-api", + providerName: "BigModel Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("no usable provider anywhere → the single not-ready state with an empty picker", () => { + // Keyless personal entries + no credentials: nothing keyed remains. + const personal = pureApiKeyPersonalConfig(); + const rules = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules; + delete (rules[0]!.config as { access: { apiKey?: string } }).access.apiKey; + const f = writeFixtureTree(dir, { personal, credentials: null }); + expect( + readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }), + ).toEqual({ ready: false, reason: "no-usable-provider", options: [], defaultChoice: null }); + }); + + it("an unreadable catalog → the same single not-ready state (the CLI cannot boot without it)", () => { + const f = writeFixtureTree(dir); + rmSync(f.catalogPath, { force: true }); + expect( + readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }), + ).toEqual({ ready: false, reason: "no-usable-provider", options: [], defaultChoice: null }); + }); +}); + +// ─── prepareZcodeModelBinding: the per-turn contract ────────────────────── + +describe("prepareZcodeModelBinding", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-prepare-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const baseOverrides = (f: FixtureTree) => ({ + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + + it("explicit account pick: exact selection into a temp clone; both real files byte-identical (zero-write)", () => { + const f = writeFixtureTree(dir); + const personalBefore = readFileSync(f.personalPath, "utf8"); + const credentialsBefore = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + reasoningLevel: "high", + }); + expect(result.builtinCatalogPath).toBe(f.catalogPath); + const clone = JSON.parse(readFileSync(result.clonePath, "utf8")) as { + config: { defaultModelSelection: unknown; providerOrder: string[] }; + }; + expect(clone.config.defaultModelSelection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + options: { reasoningLevel: "high" }, + }); + // The clone keeps the rest of the personal config (provider rules ride along). + expect(clone.config.providerOrder).toEqual([ + "deepseek", + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + ]); + // Zero-write contract: the user's real files are untouched (the paired + // identity key already exists, so the bridge writes nothing). + expect(readFileSync(f.personalPath, "utf8")).toBe(personalBefore); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(credentialsBefore); + }); + + it("default pick → the registry default (first provider, first model, highest level)", () => { + const f = writeFixtureTree(dir); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("default pick honours a valid CLI-written configured default (legacy id migrated)", () => { + const f = writeFixtureTree(dir, { + personal: dualFormPersonalConfig({ + defaultModelSelection: { + providerId: "builtin:bigmodel-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "low" }, + }, + }), + }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3-Flash", + reasoningLevel: "low", + }); + }); + + it("a level-less model binds without a reasoningLevel; the clone omits the option", () => { + const f = writeFixtureTree(dir, { + catalog: catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = + []; + }), + }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "new-provider/Qwen3.6-35B-A3B", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: "new-provider", + modelId: "Qwen3.6-35B-A3B", + reasoningLevel: null, + }); + const clone = JSON.parse(readFileSync(result.clonePath, "utf8")) as { + config: { defaultModelSelection: unknown }; + }; + expect(clone.config.defaultModelSelection).toEqual({ + providerId: "new-provider", + modelId: "Qwen3.6-35B-A3B", + }); + }); + + it("a model id containing '/' splits by validation (openrouter-style custom provider)", () => { + const personal = dualFormPersonalConfig(); + (personal.config as { providerConfigRules: { providerRules: unknown[] } }).providerConfigRules + .providerRules.push({ + providerId: "my-router", + providerName: "My OpenRouter", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-router-key" }, + personalModelIds: ["anthropic/claude-fable-5.1", "deepseek/deepseek-v4-pro"], + }, + }); + const f = writeFixtureTree(dir, { personal }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "my-router/anthropic/claude-fable-5.1/high", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: "my-router", + modelId: "anthropic/claude-fable-5.1", + reasoningLevel: "high", + }); + // Level-less variant of the same slash id. + const levelless = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "my-router/anthropic/claude-fable-5.1", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(levelless.ok && levelless.selection.reasoningLevel).toBeNull(); + }); + + it("identity bridge (add-only): an explicit account pick with the api-key key but no identity key gains exactly that key", () => { + const credentials = dualFormCredentials(); + delete credentials[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]; + const f = writeFixtureTree(dir, { credentials }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + const after = JSON.parse(readFileSync(f.credentialsPath, "utf8")) as Record; + expect(after).toEqual({ + ...credentials, + // The identity value is the account id embedded in the GUI's own + // api-key key name; nothing else changed. + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: ACCOUNT_ID, + }); + // Idempotent: a second preparation finds the key and writes nothing. + const second = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(second.ok).toBe(true); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(JSON.stringify(after, null, 2)); + }); + + it("personal targets never touch the credential store (snapshot compare)", () => { + const f = writeFixtureTree(dir); + const before = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "deepseek/deepseek-v4-pro/max", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(before); + }); + + it("a stale identity value (names no api-key key) refuses with provider-no-access and writes nothing", () => { + const credentials = dualFormCredentials(); + credentials[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`] = "10000000000000009"; + const f = writeFixtureTree(dir, { credentials }); + const before = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result).toMatchObject({ ok: false, code: "provider-no-access" }); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(before); + }); + + it("each refusal link fires with an actionable message", () => { + const f = writeFixtureTree(dir); + const base = { + cjsPath: f.cjs, + attachDir: f.attachDir, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }; + // 1. Catalog unreadable (install file removed, no cache). + rmSync(f.catalogPath, { force: true }); + const noCatalog = prepareZcodeModelBinding({ ...base }); + expect(noCatalog).toMatchObject({ ok: false, code: "catalog-unreadable" }); + writeFileSync(f.catalogPath, JSON.stringify(catalogFixture())); + // 2. Provider config unreadable. + const noPersonal = prepareZcodeModelBinding({ + ...base, + personalConfigPath: join(dir, "no-personal.json"), + }); + expect(noPersonal).toMatchObject({ ok: false, code: "provider-config-unreadable" }); + // 3. No usable provider at all (keyless F2 + no credentials). + const keyless = pureApiKeyPersonalConfig(); + delete ( + (keyless.config as { providerConfigRules: { providerRules: Array> } }) + .providerConfigRules.providerRules[0]!.config as { access: { apiKey?: string } } + ).access.apiKey; + const keylessPath = join(dir, "keyless-personal.json"); + writeFileSync(keylessPath, JSON.stringify(keyless)); + const none = prepareZcodeModelBinding({ + ...base, + personalConfigPath: keylessPath, + credentialsPath: join(dir, "no-credentials.json"), + }); + expect(none).toMatchObject({ ok: false, code: "no-usable-provider" }); + // 4. Account provider known, api-key credential missing. + const noApiKey = dualFormCredentials(); + delete noApiKey[`account-provider:coding-plan:${ACCOUNT_PROVIDER_ID}:account:${ACCOUNT_ID}:api-key`]; + delete noApiKey[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]; + const noApiKeyPath = join(dir, "no-apikey.json"); + writeFileSync(noApiKeyPath, JSON.stringify(noApiKey)); + const unauthorized = prepareZcodeModelBinding({ + ...base, + credentialsPath: noApiKeyPath, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + }); + expect(unauthorized).toMatchObject({ ok: false, code: "provider-no-access" }); + // 5. Model not on the provider (and a bare v2-era stale id). + const notOnProvider = prepareZcodeModelBinding({ + ...base, + model: `${ACCOUNT_PROVIDER_ID}/GLM-9/high`, + }); + expect(notOnProvider).toMatchObject({ ok: false, code: "model-not-on-provider" }); + const staleBare = prepareZcodeModelBinding({ ...base, model: "GLM-5.3" }); + expect(staleBare).toMatchObject({ ok: false, code: "model-not-on-provider" }); + // 6. Level unsupported for the model. + const badLevel = prepareZcodeModelBinding({ + ...base, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/medium`, + }); + expect(badLevel).toMatchObject({ ok: false, code: "level-unsupported" }); + // Every refusal message carries an action and the ZCode: prefix. + for (const r of [noCatalog, noPersonal, none, unauthorized, notOnProvider, staleBare, badLevel]) { + expect(r.ok).toBe(false); + if (r.ok) continue; + expect(r.message).toMatch(/[Rr]etry\.|[Rr]escan|[Pp]ick again|log in/); + expect(r.message.startsWith("ZCode:")).toBe(true); + } + }); + + it("a personal provider that exists but is not usable refuses with provider-no-access (not a stale pick)", () => { + const personal = dualFormPersonalConfig(); + const deepseek = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules.find((r) => r.providerId === "deepseek") as { + config: { access: { apiKey?: string } }; + }; + delete deepseek.config.access.apiKey; + const f = writeFixtureTree(dir, { personal }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "deepseek/deepseek-flash/max", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result).toMatchObject({ ok: false, code: "provider-no-access" }); + }); + + it("setting.json is not part of the contract: a stale plan-era file changes nothing", () => { + const f = writeFixtureTree(dir); + // v2-era keys; v3 never reads this file. + writeFileSync( + join(dir, "setting.json"), + JSON.stringify({ + providerFamilyDomain: "zai", + modelProviderFamilySelectedKeys: { bigmodel: "coding-plan:builtin:bigmodel-coding-plan" }, + }), + ); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + // The zai keys in setting.json cannot steer anything: the registry + // default is still the paired bigmodel plan. + expect(result.selection.providerId).toBe(ACCOUNT_PROVIDER_ID); + }); +}); + +// ─── The binding discriminator ──────────────────────────────────────────── +// +// A mis-bound fresh turn silently reroutes to the first personal provider +// and fails at its gateway (ProviderBusinessError 400, exit 1); a correctly +// bound turn completes (resultType success, exit 0). Both arms are pinned +// below with fixtures captured from real CLI runs. + +describe("binding discriminator (verified)", () => { + const wrongBindingStderr = readFileSync( + join(here, "__fixtures__", "zcode-wrong-binding.stderr.txt"), + "utf8", + ); + // The exit-0 arm's captured evidence: the turn.completed + result lines of + // a run that completed under the paired redirect. + const rightBindingStdout = readFileSync( + join(here, "__fixtures__", "zcode-right-binding.stdout.jsonl"), + "utf8", + ); + + it("the wrong-binding evidence: gateway 400 on a reasoning level routed to a non-plan provider", () => { + expect(wrongBindingStderr).toContain("providerCode: 400"); + expect(wrongBindingStderr).toContain("'input': 'max'"); + expect(wrongBindingStderr).toContain(`providerId: '${WECHAT_PROVIDER_ID}'`); + }); + + it("the right-binding evidence: a bound turn completes (resultType success, exit 0)", () => { + const completed = rightBindingStdout + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as { type?: string; payload?: { resultType?: string; response?: string }; response?: string }); + const turn = completed.find((l) => l.type === "turn.completed"); + expect(turn?.payload?.resultType).toBe("success"); + const result = completed.find((l) => l.type === "result"); + expect(result?.response).toBe("PROBE_OK"); + }); + + it("the binding we write is the verified-good shape: registry provider + explicit level, never the fallback provider", () => { + const dir = mkdtempSync(join(tmpdir(), "zcode-discriminator-")); + try { + const f = writeFixtureTree(dir); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: encodeZcodeModelChoice(ACCOUNT_PROVIDER_ID, "GLM-5.3", "high"), + attachDir: f.attachDir, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + // The registry triple (provider, model, explicit level)… + expect(result.selection.providerId).toBe(ACCOUNT_PROVIDER_ID); + expect(result.selection.modelId).toBe("GLM-5.3"); + expect(result.selection.reasoningLevel).toBe("high"); + // …and never the 400-ing fallback provider from the stderr fixture. + expect(result.selection.providerId).not.toBe(WECHAT_PROVIDER_ID); + expect(wrongBindingStderr).not.toContain(result.selection.providerId); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("zcodeProviderEnvPairSet", () => { + it("true only when both provider-config vars are set (user's zero-code reroute)", () => { + expect( + zcodeProviderEnvPairSet({ + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/tmp/a.json", + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: "/tmp/b.json", + } as unknown as NodeJS.ProcessEnv), + ).toBe(true); + expect( + zcodeProviderEnvPairSet({ + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/tmp/a.json", + } as unknown as NodeJS.ProcessEnv), + ).toBe(false); + expect(zcodeProviderEnvPairSet({} as NodeJS.ProcessEnv)).toBe(false); + }); +}); + +describe("stripZcodeHostProviderEnv", () => { + it("removes both host-injected vars and reports what was present", () => { + const env = { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/host/personal.json", + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: "/host/builtin.json", + PATH: "/usr/bin", + } as unknown as NodeJS.ProcessEnv; + const removed = stripZcodeHostProviderEnv(env); + expect(removed).toEqual([ + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, + ]); + expect(env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + expect(env[ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("leaves a clean environment untouched (empty removal list)", () => { + const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv; + expect(stripZcodeHostProviderEnv(env)).toEqual([]); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("drops only the present half of the pair", () => { + const env = { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/host/personal.json", + } as unknown as NodeJS.ProcessEnv; + const removed = stripZcodeHostProviderEnv(env); + expect(removed).toEqual([ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]); + expect(env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + }); +}); diff --git a/cli/src/agents-detect.ts b/cli/src/agents-detect.ts index f4813b43..6947d2d8 100644 --- a/cli/src/agents-detect.ts +++ b/cli/src/agents-detect.ts @@ -1,17 +1,61 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import path, { delimiter, join } from "node:path"; +import path, { delimiter, join, posix, win32 } from "node:path"; +import { + readZcodePickerState, + type ZcodeNotReadyReason, +} from "./zcode-model-binding.js"; /** - * Agent detection — adapted from next/src/lib/agents/detect.ts + * Agent detection, adapted from next/src/lib/agents/detect.ts */ -export type AgentProtocol = "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc"; +/** + * Per-agent invocation protocol. Determines how `invokeAgent` delivers the + * prompt and parses output: + * - "stdin" : pipe prompt → child stdin, parse stdout via parseLine + * - "argv" : pass prompt as positional argv (deepseek-tui/codewhale) + * - "argv-message" : prompt goes via `--message ` (openclaw) + * - "argv-attach" : ZCode's headless one-shot CLI. The full prompt travels + * in a temp-file attachment (`--attach `, written + * by the invoke layer), `-p` carries only a fixed short + * guide, and stdout is an NDJSON event stream + * (`--output-format stream-json`). + * - "acp" : ACP JSON-RPC over stdio (hermes/kimi/devin/kiro/kilo/vibe) + * - "pi-rpc" : pi's custom JSON-RPC mode + */ +export type AgentProtocol = "stdin" | "argv" | "argv-message" | "argv-attach" | "acp" | "pi-rpc"; -export type ModelOption = { id: string; label: string }; +/** + * A model picker entry. `id`/`label` are the two fields every agent's + * picker reads. `providerId` is optional and ZCode-only: ZCode's chips are + * namespaced per registry provider (`/[/]`), + * and the per-turn binding writes that + * `{ providerId, modelId, reasoningLevel }` into the provider-config clone + * the spawn hands the CLI. Absent for every other agent (their picker ids + * map to a single provider implicitly, or go to `--model `). + */ +export type ModelOption = { id: string; label: string; providerId?: string }; export const DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (CLI config)" }; +/** + * ZCode's static picker floor. Distinct from {@link DEFAULT_MODEL}: ZCode + * has no `--model` flag and no "CLI config picks" semantics. Default always + * means "whatever ZCode's own default model resolves to", so even the + * floor label must not imply a CLI-side default. + */ +const ZCODE_DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (ZCode default)" }; + +/** + * Sentinel placed in ZCode's `AgentDef.binArgs` where the resolved path to + * the `zcode.cjs` bundle belongs. Filled at detect time (availability) and + * invoke time (the actual spawn) by {@link resolveZcodeBin}. Exported so the + * invoke layer substitutes this same token; a duplicated string literal + * could typo and silently spawn the literal. + */ +export const ZCODE_CJS_SENTINEL = ""; + export type AgentDef = { id: string; label: string; @@ -20,6 +64,14 @@ export type AgentDef = { envOverride?: string; vendor: string; protocol?: AgentProtocol; + /** + * Extra leading argv spliced between the bin and the protocol argv. Needed + * for node-script CLIs (e.g. ZCode's `zcode.cjs`) that must be spawned as + * `node -p …` rather than as a standalone exec: argv[1] + * has to be the cjs (Node rejects a bare executable passed as an arg). + * Optional and defaults to absent, so existing adapters are unaffected. + */ + binArgs?: string[]; fallbackModels: ModelOption[]; }; @@ -281,6 +333,30 @@ export const AGENTS: AgentDef[] = [ { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, ], }, + // ZCode (Z.AI), a headless one-shot CLI agent and the only "argv-attach" + // protocol entry today. The CLI is a node bundle (zcode.cjs) spawned as + // `node -p --attach --output-format + // stream-json --mode yolo`, so bin: "node" and binArgs carries just the + // resolved .cjs; argv[1] must be the cjs (Node rejects a bare executable + // passed as an arg). The `` sentinel is filled by + // resolveZcodeBin() at detect time (availability) and invoke time (the + // actual spawn). Never marked unsupported: the adapter rides the generic + // invoke trunk like every argv-family agent. + // + // fallbackModels is the ZCODE_DEFAULT_MODEL static floor: the CLI has no + // --model flag, so per-turn model selection travels as a + // defaultModelSelection clone (see detectAgents' argv-attach branch + + // the invoke layer's binding). + { + id: "zcode", + label: "ZCode", + bin: "node", + envOverride: "ZCODE_BIN", + vendor: "Z.AI", + protocol: "argv-attach", + binArgs: [ZCODE_CJS_SENTINEL], + fallbackModels: [ZCODE_DEFAULT_MODEL], + }, ]; function userToolchainDirs(): string[] { @@ -345,6 +421,287 @@ export function resolveOnPath(bin: string): string | null { return null; } +// ─── Linux install discovery (.deb + AppImage) ──────────────────────── +// +// ZCode's official Linux distributions are the `.deb` package and the AppImage. +// The two are told apart by a loose `zcode.cjs`: +// - `.deb` installs to /opt/ZCode/ with a loose resources/glm/zcode.cjs next +// to the Electron binary (/opt/ZCode/zcode), the same layout as the +// Windows/macOS installs, so it is probed the same way (on-disk .cjs). +// - AppImage is a single compressed squashfs with the .cjs packed inside, +// reachable only while mounted. There is no loose `.cjs` and no fixed +// filename (`ZCode--linux-.AppImage`), so the AppImage path +// is read from the XDG `.desktop` entry ZCode writes on first GUI launch +// (`~/.local/share/applications/zcode.desktop`); its `Exec=` line holds +// the real path whatever the user named/placed the file. The same bar as +// login: a user must have run ZCode once. Freedesktop.org standard +// honoured by every Linux desktop. + +/** Path to the XDG `.desktop` entry ZCode generates on first GUI launch. */ +const ZCODE_DESKTOP_PATH = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", +); + +/** + * Parse the ZCode AppImage path out of a `.desktop` entry's `Exec=` line. + * Pure (no I/O) so it can be unit-tested with string fixtures. Returns the + * AppImage path, or `null` when the line is absent or malformed. + * + * ZCode writes an entry like: + * + * [Desktop Entry] + * Name=ZCode + * Exec=/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage --no-sandbox %U + * Icon=zcode + * ... + * + * The first token after `Exec=` is the AppImage path (whatever the user + * named/placed the file); trailing flags (`--no-sandbox`, `%U`, …) and the + * freedesktop field codes are stripped. + */ +export function parseZcodeDesktopExec(content: string): string | null { + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.startsWith("Exec=")) continue; + const rest = line.slice("Exec=".length).trim(); + if (!rest) return null; + // The path is the first token. Per the freedesktop Desktop Entry Spec a + // path containing a reserved character (space) MUST be quoted, so + // tokenize with quote-awareness; splitting on whitespace first would + // truncate `"/home/u/My Apps/ZCode.AppImage"` at the interior space. A + // quoted token (double or single) yields its inner content; otherwise + // the leading non-whitespace run is the path. Trailing flags + // (--no-sandbox, %U, …) and field codes are dropped. Reject a bare + // field-code token (malformed entry). + const m = rest.match(/^"([^"]*)"|^'([^']*)'|^(\S+)/); + const token = m?.[1] ?? m?.[2] ?? m?.[3] ?? ""; + if (!token || token.startsWith("%")) return null; + return token; + } + return null; +} + +/** + * Discover the ZCode AppImage on Linux via the XDG `.desktop` entry ZCode + * generates on first GUI launch. The `Exec=` line holds the real path + * whatever the user named/placed the file, so no filename guessing or glob is + * needed. Returns `null` (never throws) when the entry is missing or + * malformed; the caller treats that as "ZCode not found". + */ +export function discoverZcodeAppImage(): string | null { + try { + if (!existsSync(ZCODE_DESKTOP_PATH)) return null; + return parseZcodeDesktopExec(readFileSync(ZCODE_DESKTOP_PATH, "utf8")); + } catch { + return null; + } +} + +/** + * Locate the ZCode CLI binary. Probe order, first match wins: + * 1. `ZCODE_BIN` env var, a user override (absolute path, else PATH lookup) + * 2. `zcode` on PATH, a defensive probe only. ZCode's official Linux + * distributions are the `.deb` and the AppImage, neither known to ship a + * PATH shim, so this rarely hits and is not a supported install shape. + * 3. Platform default: + * Windows : `%ZCODE_WINDOWS_APP_INSTALL_DIR%\resources\glm\zcode.cjs` + * → `C:\Program Files\ZCode\resources\glm\zcode.cjs` + * macOS : `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs` + * Linux : on-disk `zcode.cjs` at + * `/opt/ZCode/resources/glm/zcode.cjs` (the `.deb` install, + * same loose-file layout as Windows/macOS); else the AppImage + * path from the `.desktop` `Exec=` line (the `.cjs` lives + * inside the mount, so the AppImage binary doubles as the + * availability signal and the Electron driver). + * + * On Windows/macOS (and a Linux `.deb`) the returned path is the `.cjs` bundle. + * On a Linux AppImage install the platform default is the AppImage binary (the + * `.cjs` path is computed post-mount in the invoke layer). A `ZCODE_BIN` or + * on-disk `.cjs` is returned as-is and the invoke layer then uses it directly + * with no mount. + * + * Discovery only. Returns `null` (never throws) when nothing is found. + */ +export function resolveZcodeBin(): string | null { + const env = process.env; + // 1. Explicit user override. + const override = env.ZCODE_BIN?.trim(); + if (override) { + if (existsSync(override)) return override; + const onPath = resolveOnPath(override); + if (onPath) return onPath; + } + // 2. `zcode` on PATH, defensive probe only (ZCode ships as a `.deb` or + // AppImage); kept so a hand-placed link still resolves. The override (1) + // and platform default (3) are the real paths. + const pathHit = resolveOnPath("zcode"); + if (pathHit) return pathHit; + // 3. Platform default. + if (process.platform === "linux") { + // Probe the on-disk `.cjs` before the AppImage mount. A + // loose resources/glm/zcode.cjs at the install root cleanly distinguishes + // a `.deb` (or unpacked) install from an AppImage (the `.cjs` is packed in + // the squashfs, so no loose file sits next to it). The invoke layer's + // `.cjs`-direct branch then runs it without mounting. + for (const candidate of defaultZcodeCjsPaths()) { + if (existsSync(candidate)) return candidate; + } + // AppImage: the `.cjs` lives inside the mount; discover the binary via + // the `.desktop` entry (availability + Electron driver + mount source). + const appImage = discoverZcodeAppImage(); + if (appImage && existsSync(appImage)) return appImage; + return null; + } + for (const candidate of defaultZcodeCjsPaths()) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Per-platform candidate default paths for ZCode's `zcode.cjs`. Exported only + * so tests can assert the exact paths probed; callers should use + * `resolveZcodeBin()`. + * + * macOS/Linux paths are built with `posix` separators so they stay + * forward-slash regardless of the *host* running the probe. These are + * fixed install locations and must not be rewritten when the host is, say, + * Windows running a cross-platform unit test. Windows paths keep backslash + * separators. + */ +export function defaultZcodeCjsPaths(): string[] { + const platform = process.platform; + const env = process.env; + if (platform === "win32") { + const installDir = env.ZCODE_WINDOWS_APP_INSTALL_DIR?.trim(); + const out: string[] = []; + if (installDir) { + out.push(win32.join(installDir, "resources", "glm", "zcode.cjs")); + } + out.push( + win32.join("C:\\Program Files\\ZCode", "resources", "glm", "zcode.cjs"), + ); + return out; + } + if (platform === "darwin") { + return [posix.join("/Applications/ZCode.app", "Contents", "Resources", "glm", "zcode.cjs")]; + } + // The official `.deb` install lays a loose + // resources/glm/zcode.cjs at /opt/ZCode/ (same layout as Windows/macOS), so + // it is probed the same way. An AppImage packs the `.cjs` inside its + // squashfs (no loose file), so when only the AppImage is installed this + // candidate misses and resolveZcodeBin() falls back to `.desktop` discovery. + return [posix.join("/opt/ZCode", "resources", "glm", "zcode.cjs")]; +} + +/** + * Resolve the node binary that drives `node -p …`. Distinct from + * `resolveZcodeBin()` (which locates the `.cjs` bundle): html-anything is an + * external process, and on a clean Windows host `where node` finds nothing + * (only `ZCode.exe` exists). + * + * Probe order: + * 1. `ZCODE_NODE_BIN` env, an explicit user override (absolute path, else + * PATH). + * 2. `node` on PATH, the system Node. Preferred when present: it needs no + * ELECTRON_RUN_AS_NODE env, and is the simplest portable driver. + * 3. The ZCode Electron executable itself (the only node-like binary on a + * clean host) under `ELECTRON_RUN_AS_NODE=1` (merged into the spawn env + * by envFor's zcode line) it behaves as node; no separate `node.exe` + * ships in the install tree. See {@link defaultZcodeElectronExePaths} + * for the per-platform exe locations. + * + * Returns a non-empty path (never `null`, never throws): step 3, the bundled + * Electron exe, is terminal and present whenever detect passed (`zcode.cjs` + * found ⟺ ZCode installed ⟺ the sibling Electron exe exists). The one caller + * that can still miss all three probes is a hand-crafted `ZCODE_BIN` at an + * orphaned `.cjs`; the resolver then returns the canonical install location + * and lets the spawn's own ENOENT surface the real problem, rather than a + * misleading "install Node.js" message. + */ +export function resolveZcodeNodeBin(): string { + const env = process.env; + const override = env.ZCODE_NODE_BIN?.trim(); + if (override) { + if (existsSync(override)) return override; + const onPath = resolveOnPath(override); + if (onPath) return onPath; + } + const pathNode = resolveOnPath("node"); + if (pathNode) return pathNode; + // Step 3: the bundled Electron exe is the terminal fallback. The last + // candidate in defaultZcodeElectronExePaths() is the canonical install + // location, present whenever the caller reached this code via detectAgents() + // (zcode.cjs found ⟺ ZCode installed ⟺ exe exists). Return it on the miss + // path too; see the doc comment above for the orphaned-.cjs rationale. + if (process.platform === "linux") { + // Probe the `.deb` Electron driver (/opt/ZCode/zcode) before + // the AppImage. Same role as ZCode.exe on Windows: under + // ELECTRON_RUN_AS_NODE=1 it acts as node, and a `.deb`-only host has no + // AppImage to mount, so without this probe it could not drive the `.cjs`. + // defaultZcodeElectronExePaths() lists the `.deb` driver first; the + // AppImage is discovered via its `.desktop` entry (the real user-named + // path, not the canonical guess), and the last candidate is the terminal + // fallback so the contract (`string`, never null) holds even for a + // hand-crafted caller that bypassed detect; the spawn's own ENOENT then + // surfaces the real problem. + const electronPaths = defaultZcodeElectronExePaths(); + if (existsSync(electronPaths[0])) return electronPaths[0]; + const appImage = discoverZcodeAppImage(); + if (appImage && existsSync(appImage)) return appImage; + return electronPaths[electronPaths.length - 1]; + } + const electronPaths = defaultZcodeElectronExePaths(); + for (const candidate of electronPaths) { + if (existsSync(candidate)) return candidate; + } + return electronPaths[electronPaths.length - 1]; +} + +/** + * Per-platform candidate default paths for the ZCode Electron executable, + * the node-driver fallback when no system `node` is on PATH. Exported only so + * tests can assert the exact paths probed; callers should use + * `resolveZcodeNodeBin()`. + * + * Mirrors {@link defaultZcodeCjsPaths}'s separator discipline: macOS/Linux + * paths use `posix` separators so they stay forward-slash regardless of the + * host running a cross-platform unit test; Windows paths keep backslashes. + * The exe sits at the install root (not under `resources/glm/` like the `.cjs`): + * Windows : `\ZCode.exe` → `C:\Program Files\ZCode\ZCode.exe` + * macOS : `/Applications/ZCode.app/Contents/MacOS/ZCode` + * Linux : `/opt/ZCode/zcode` (the `.deb` Electron binary) and + * `~/Applications/ZCode.AppImage` (the AppImage is the executable) + */ +export function defaultZcodeElectronExePaths(): string[] { + const platform = process.platform; + const env = process.env; + if (platform === "win32") { + const installDir = env.ZCODE_WINDOWS_APP_INSTALL_DIR?.trim(); + const out: string[] = []; + if (installDir) { + out.push(win32.join(installDir, "ZCode.exe")); + } + out.push(win32.join("C:\\Program Files\\ZCode", "ZCode.exe")); + return out; + } + if (platform === "darwin") { + return [posix.join("/Applications/ZCode.app", "Contents", "MacOS", "ZCode")]; + } + // The `.deb` install ships /opt/ZCode/zcode (the Electron + // binary, driven under ELECTRON_RUN_AS_NODE=1 as node); the AppImage is + // itself the executable. The `.deb` driver is listed first so + // resolveZcodeNodeBin() probes it before AppImage discovery. + return [ + posix.join("/opt/ZCode", "zcode"), + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ]; +} + export type DetectedAgent = { id: string; label: string; @@ -355,11 +712,22 @@ export type DetectedAgent = { protocol: AgentProtocol; models: ModelOption[]; unsupported?: boolean; + /** + * ZCode-only: installed is not the same as ready. True when the registry + * mirror holds at least one usable provider (a paired Coding Plan or a + * keyed personal provider); false with `notReadyReason` when the user + * still needs to log in or add a provider in ZCode. Absent for every + * other agent. + */ + ready?: boolean; + notReadyReason?: ZcodeNotReadyReason; }; export function detectAgents(): DetectedAgent[] { return AGENTS.map((a): DetectedAgent => { const protocol = a.protocol ?? "stdin"; + // "argv-attach" (ZCode) is implemented on the generic invoke trunk and is + // not unsupported, unlike the acp/pi-rpc family which is detection-only. const unsupported = protocol === "acp" || protocol === "pi-rpc"; const base = { id: a.id, @@ -369,6 +737,66 @@ export function detectAgents(): DetectedAgent[] { models: a.fallbackModels, unsupported: unsupported || undefined, }; + + // ZCode's CLI is a .cjs bundle, not a standalone exec on PATH, so its + // availability is driven by resolveZcodeBin() (which already honours + // ZCODE_BIN, PATH, and platform defaults). The generic PATH branch below + // would wrongly report `node` (bin) as the install, so ZCode gets its own + // detection: available iff the .cjs resolves. resolvedBin is the node + // driver the spawn path will use (node -p …): + // resolveZcodeNodeBin() (the real node or Electron-exe fallback). That + // resolver's return is tightened to `string`: the Electron-exe fallback is + // terminal and present whenever detect passed (zcode.cjs found ⟺ ZCode + // installed ⟺ exe exists), so there is no null to coalesce here. + // + // The picker mirrors ZCode's headless provider registry (the install- + // bundled catalog + the personal provider config + credential key + // names — the same sources the invoke layer binds against; see + // zcode-model-binding.ts). Chips carry provider-namespaced ids + // "/[/]" with the service name in the label; + // the "default" entry binds the registry default at invoke time. The + // Default chip label names what Default currently resolves to (the same + // chain the invoke layer applies), so the chip never promises something + // stale. Reads are gated on availability: an unavailable install keeps + // the static floor (ZCODE_DEFAULT_MODEL), and a not-ready install (no + // usable keyed provider) reports ready:false + notReadyReason while + // keeping the static floor. + if (protocol === "argv-attach") { + const cjs = resolveZcodeBin(); + if (cjs) { + const picker = readZcodePickerState({ cjsPath: cjs }); + return { + ...base, + available: true, + path: cjs, + resolvedBin: resolveZcodeNodeBin(), + ready: picker.ready, + notReadyReason: picker.reason ?? undefined, + // Registry chips when they resolved (Default chip first, labelled + // with the resolved default service, model, and level); otherwise + // the static floor. + models: picker.options.length + ? [ + { + id: "default", + label: picker.defaultChoice + ? `Default (${picker.defaultChoice.providerName} ${ + picker.defaultChoice.modelId + }${ + picker.defaultChoice.reasoningLevel + ? `, ${picker.defaultChoice.reasoningLevel}` + : "" + })` + : ZCODE_DEFAULT_MODEL.label, + }, + ...picker.options, + ] + : [ZCODE_DEFAULT_MODEL], + }; + } + return { ...base, available: false }; + } + const override = a.envOverride ? process.env[a.envOverride] : undefined; if (override) { if (existsSync(override)) { diff --git a/cli/src/agents-invoke.ts b/cli/src/agents-invoke.ts index 2416e634..3c12aeb8 100644 --- a/cli/src/agents-invoke.ts +++ b/cli/src/agents-invoke.ts @@ -1,7 +1,13 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { resolveOnPath, AGENTS, type AgentDef, type AgentProtocol } from "./agents-detect.js"; +import { resolveOnPath, resolveZcodeBin, resolveZcodeNodeBin, ZCODE_CJS_SENTINEL, AGENTS } from "./agents-detect.js"; +import { + prepareZcodeModelBinding, + zcodeBindingEnv, + zcodeProviderEnvPairSet, +} from "./zcode-model-binding.js"; export type InvokeOpts = { agent: string; @@ -60,6 +66,26 @@ export type InvokeEvent = | { type: "done"; code: number | null } | { type: "error"; message: string }; +/** + * Silence watchdog (zcode only): a healthy stream-json turn produces + * parsed events continuously (deltas, thinking fragments, tool status), so + * 180s with zero parsed events means the model or CLI hung and the turn + * would hang forever with no teardown. Any stdout line that parses to at + * least one event resets the clock; when it fires the turn errors out, the + * child is killed, and the stream closes. Sibling agents keep their + * historical behavior. + */ +const ZCODE_SILENCE_TIMEOUT_MS = 180_000; + +/** + * Cap on the stderr tail kept for the non-zero-exit error message. + * The FULL stderr keeps streaming to the log as `stderr` events; this buffer + * only feeds the one-line exit error with its most recent content. + */ +const STDERR_TAIL_CAP = 2_000; +/** How much of that tail the exit-error message actually quotes. */ +const STDERR_HINT_MAX = 500; + // ─── argv builder ──────────────────────────────────────────────────── type AgentArgvOpts = { @@ -67,6 +93,28 @@ type AgentArgvOpts = { openclawAgentId?: string; }; +/** + * Quote a single argv element for cmd.exe when `spawn(..., { shell: true })` is + * used on Windows. cmd.exe splits the argv array on whitespace, so an element + * containing a space (notably ZCode's resolved `C:\Program + * Files\ZCode\resources\glm\zcode.cjs`) must be double-quoted. + * Already-quoted elements are left alone; empty elements become `""`. Mirrors + * next/src/lib/agents/invoke.ts's helper. + */ +function quoteWindowsArg(arg: string): string { + if (arg.length > 0 && arg.startsWith('"') && arg.endsWith('"')) return arg; + return `"${arg}"`; +} + +/** + * The fixed short guide ZCode's `-p` flag carries. The attachment holds the + * real task, so this only points the model at it and pins the deliverable + * shape (final HTML as the reply body; ZCode is agentic and would otherwise + * reach for file-write tools). Keep it short: `-p` takes an argv value. + */ +const ZCODE_PROMPT_GUIDE = + "附件是完整的任务说明。请严格按其中的要求执行,并将最终结果(完整 HTML)直接作为你的回复正文输出。"; + class UnsupportedAgentProtocolError extends Error { constructor(public readonly agent: string, public readonly protocol: string) { super( @@ -165,6 +213,22 @@ function buildArgv(agent: string, opts: AgentArgvOpts = {}): string[] { case "codewhale": case "deepseek-tui": return ["exec", "--auto", ...(model ? ["--model", model] : [])]; + case "zcode": + // Headless one-shot. `-p` carries only this fixed short guide; the + // full prompt (shared directives + template + user content, 20-30KB+) + // travels in the `--attach` temp file invokeAgent writes, since argv + // length limits would truncate it. `--mode yolo` is `-p`'s default + // anyway; passing it is self-documentation. There is no `--model` + // flag: the model default comes from ZCode's own provider config + // (opts.model is deliberately ignored). + return [ + "-p", + ZCODE_PROMPT_GUIDE, + "--output-format", + "stream-json", + "--mode", + "yolo", + ]; case "hermes": case "kimi": case "devin": @@ -182,6 +246,12 @@ function buildArgv(agent: string, opts: AgentArgvOpts = {}): string[] { function envFor(agent: string): NodeJS.ProcessEnv { const base = { ...process.env }; if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true"; + // ZCode's zcode.cjs is an Electron-hosted bundle; without + // ELECTRON_RUN_AS_NODE=1 it boots the full Electron app rather than the CLI + // and the prompt is never executed (verified). Merged into the env (not + // a replacement) so PATH, ZCODE_*, and the provider-config escape-hatch + // vars survive. Scoped to zcode; other agents must not inherit it. + if (agent === "zcode") base.ELECTRON_RUN_AS_NODE = "1"; return base; } @@ -191,9 +261,26 @@ type AgentParse = | { kind: "delta"; text: string } | { kind: "meta"; key: string; value: unknown } | { kind: "html"; text: string } + /** + * Turn-level failure surfaced by the stream itself (e.g. ZCode's + * `turn.failed` event). Carries the human-readable message only; the invoke + * layer forwards it as `{type:"error"}` so the consumer-side failure gate + * (red error state) can fire on it. + */ + | { kind: "error"; message: string } | { kind: "noise" }; -type ParseState = { sawStreamEventText?: boolean }; +type ParseState = { + sawStreamEventText?: boolean; + /** + * ZCode: toolCallId → toolName, filled when the `model.streaming` + * tool_call event names the tool. `tool.updated` result events carry only a + * toolCallId, so the "工具 X 完成" status line resolves the name through + * this map; a result whose id maps to nothing emits no line at all + * (a nameless status line is noise). + */ + zcodeToolNamesById?: Map; +}; function rescueHtmlFromToolUse( content: Array<{ type?: string; name?: string; input?: unknown }> | undefined, @@ -229,6 +316,136 @@ function rescueHtmlFromToolUse( return parts.join(""); } +/** + * ZCode CLI one-shot (`-p --output-format stream-json`) stdout, one NDJSON + * event envelope per line: + * + * {"eventId":…,"payload":{…},"seq":…,"sessionId":…,"timestamp":…,"type":"model.streaming"} + * + * Events (types observed live + pinned in the ZCode open-source + * contracts, apps/zcode-cli/packages/contracts/src/events/session.events.ts): + * + * - `model.streaming`, the model's own stream, dispatched on `payload.kind`: + * text_delta → streamed text (delta channel) + * reasoning_delta → thinking meta, forwarded fragment by fragment under + * the same `thinking` key the Claude path emits + * tool_call → the fully-assembled tool invocation ({toolCallId, + * toolName, input}). A file-write tool's input may hold the generated + * HTML, so run the shared rescue; otherwise surface a natural-language + * status line so the stream keeps flowing during the tool window. + * Records toolCallId→toolName for the result event, which is nameless. + * (start/text_start/text_end/reasoning_start/reasoning_end/ + * tool_input_start/tool_input_delta/tool_input_end/finish → no output) + * - `tool.updated`, tool execution lifecycle, dispatched on an injected + * `payload.kind` (scheduled/started/progress/result/error/batch). Only + * `result` matters here: emit "工具 X 完成" with the name resolved via + * the tool_call map. A nameless result emits nothing (a bare ✓ with no + * context is noise). + * - `turn.completed`, end of turn: usage (remapped to the snake_case keys + * the consumer reads), duration, and resultType ("success", + * "cancelled", …). This is the only place usage is emitted. + * - `turn.failed`, turn-level failure (payload.error.message) → error part. + * - `result`, the bare terminator line (top-level fields, no payload + * envelope). Holds the sessionId (→ session meta). Its usage is the + * SAME cumulative numbers turn.completed already reported; never emit + * it again. + * - everything else: session.titleUpdated / session.resumed / + * session.updated (20+ plugin hook descriptors per turn), turn.started, + * message.upserted, the permission / checkpoint families, … are noise, + * dropped. So are non-JSON lines: ZCode plugins can print arbitrary + * stdout. + */ +function parseZcodeLine(line: string, state: ParseState): AgentParse[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (!parsed || typeof parsed !== "object") return []; + const obj = parsed as Record; + const payload = obj.payload && typeof obj.payload === "object" ? (obj.payload as Record) : null; + + if (obj.type === "model.streaming" && payload) { + const kind = typeof payload.kind === "string" ? payload.kind : ""; + if (kind === "text_delta" || kind === "reasoning_delta") { + const delta = typeof payload.delta === "string" ? payload.delta : ""; + if (delta.length === 0) return []; + return [ + kind === "text_delta" + ? { kind: "delta", text: delta } + : { kind: "meta", key: "thinking", value: delta }, + ]; + } + if (kind === "tool_call") { + const id = typeof payload.toolCallId === "string" ? payload.toolCallId : ""; + const name = typeof payload.toolName === "string" ? payload.toolName : ""; + if (id && name) { + (state.zcodeToolNamesById ??= new Map()).set(id, name); + } + // A file-write tool call may hold the generated HTML; reuse the same + // rescue logic the other adapters apply to Claude-style tool_use + // blocks (ZCode's Write tool is {file_path, content}, same shape). + const html = rescueHtmlFromToolUse([{ type: "tool_use", name, input: payload.input }]); + if (html) return [{ kind: "html", text: html }]; + if (name) return [{ kind: "meta", key: "status", value: `调用工具 ${name}` }]; + } + return []; + } + + if (obj.type === "tool.updated" && payload) { + if (payload.kind === "result") { + const id = typeof payload.toolCallId === "string" ? payload.toolCallId : ""; + const name = (id && state.zcodeToolNamesById?.get(id)) || ""; + if (name) return [{ kind: "meta", key: "status", value: `工具 ${name} 完成` }]; + } + return []; + } + + if (obj.type === "turn.completed" && payload) { + const out: AgentParse[] = []; + const usage = mapZcodeUsage(payload.usage); + if (usage) out.push({ kind: "meta", key: "usage", value: usage }); + if (typeof payload.duration === "number") { + out.push({ kind: "meta", key: "duration_ms", value: payload.duration }); + } + if (typeof payload.resultType === "string") { + out.push({ kind: "meta", key: "result", value: payload.resultType }); + } + return out; + } + + if (obj.type === "turn.failed") { + const err = payload && typeof payload.error === "object" ? (payload.error as Record) : null; + const message = + err && typeof err.message === "string" && err.message.length > 0 ? err.message : "zcode turn failed"; + return [{ kind: "error", message }]; + } + + if (obj.type === "result") { + if (typeof obj.sessionId === "string" && obj.sessionId.length > 0) { + return [{ kind: "meta", key: "session", value: obj.sessionId }]; + } + return []; + } + + return []; +} + +/** ZCode usage (camelCase) → the snake_case keys the consumer layer reads + * (same key set Claude's `result.usage` carries: input_tokens / + * output_tokens / cache_read_input_tokens / cache_creation_input_tokens). */ +function mapZcodeUsage(usage: unknown): Record | null { + if (!usage || typeof usage !== "object") return null; + const u = usage as Record; + const out: Record = {}; + if (typeof u.inputTokens === "number") out.input_tokens = u.inputTokens; + if (typeof u.outputTokens === "number") out.output_tokens = u.outputTokens; + if (typeof u.cacheReadTokens === "number") out.cache_read_input_tokens = u.cacheReadTokens; + if (typeof u.cacheWriteTokens === "number") out.cache_creation_input_tokens = u.cacheWriteTokens; + return Object.keys(out).length > 0 ? out : null; +} + function parseLineWithState(agent: string, line: string, state: ParseState): AgentParse[] { const trimmed = line.trim(); if (!trimmed) return []; @@ -237,6 +454,16 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age return [{ kind: "delta", text: trimmed.endsWith("\n") ? trimmed : trimmed + "\n" }]; } + // ZCode (argv-attach), NDJSON event envelope, one JSON object per line: + // {"type":"model.streaming","payload":{"kind":"text_delta","delta":"…"},…} + // plus a bare {"type":"result",…} terminator line. See parseZcodeLine for + // the full event surface. Handled before the shared JSON.parse so a + // non-JSON line returns [] here rather than a `noise` part (the invoke + // layer forwards noise as `raw`, which would flood the log panel). + if (agent === "zcode") { + return parseZcodeLine(trimmed, state); + } + let parsed: unknown; try { parsed = JSON.parse(trimmed); @@ -442,22 +669,88 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (!def) { return errorStream(`unknown agent: ${opts.agent}`); } - const resolved = resolveBinForAgent(def, opts.binOverride); - if (resolved.kind === "override-missing") { - return errorStream( - `${def.label}: custom path \`${resolved.tried}\` does not exist.`, - ); - } - if (resolved.kind === "not-found") { - return errorStream( - `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, - ); + + // argv-attach agents (ZCode) spawn `node -p …`, and + // html-anything is an external process: on a clean Windows host + // `where node` finds nothing. ZCODE_BIN (def.envOverride) is the `.cjs` + // override, not a node bin, so these agents resolve their bin separately: + // binOverride (explicit node path) wins, else resolveZcodeNodeBin() (never + // null because its Electron-exe fallback is present whenever detect + // passed). Only the binOverride-missing error remains. Execution then + // continues down the generic trunk below; ZCode has no dedicated protocol + // branch. + let bin: string; + if (def.protocol === "argv-attach") { + if (opts.binOverride && opts.binOverride.trim()) { + const tried = opts.binOverride.trim(); + if (/^([a-zA-Z]:[\\/]|[\\/])/.test(tried)) { + bin = existsSync(tried) ? tried : ""; + } else if (tried.includes("/") || tried.includes("\\") || tried.startsWith(".")) { + const abs = path.resolve(tried); + bin = existsSync(abs) ? abs : ""; + } else { + bin = resolveOnPath(tried) ?? ""; + } + if (!bin) { + return errorStream( + `${def.label}: custom node path \`${tried}\` does not exist. Set it to a node binary (or ZCode's ZCode.exe), or clear it to auto-discover.`, + ); + } + } else { + bin = resolveZcodeNodeBin(); + } + } else { + const resolved = resolveBinForAgent(def, opts.binOverride); + if (resolved.kind === "override-missing") { + return errorStream( + `${def.label}: custom path \`${resolved.tried}\` does not exist.`, + ); + } + if (resolved.kind === "not-found") { + return errorStream( + `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, + ); + } + bin = resolved.bin; } - const bin: string = resolved.bin; const env = envFor(opts.agent); + const promptViaArgv = def.protocol === "argv"; const promptViaMessageFlag = def.protocol === "argv-message"; + const promptViaAttach = def.protocol === "argv-attach"; + + // Lifted above the ReadableStream so `cancel` (a sibling callback of + // `start` that cannot reach its locals) can tear the argv-attach turn's + // resources down without waiting on `start`: + // - mountChild: on Linux the AppImage mount child stays alive holding the + // FUSE mount for the duration of the turn (null on Windows/macOS). + // - attachTmpDir: the mkdtemp dir holding zcode's prompt.md attachment. + let mountChild: ChildProcessWithoutNullStreams | null = null; + let attachTmpDir: string | null = null; + // The zcode silence-watchdog handle, lifted for the same reason as + // cleanupArgvAttach: `cancel`, a sibling of `start`, must be able to clear + // it without waiting for `start` to run. + let silenceTimer: ReturnType | null = null; + const clearSilenceTimer = () => { + if (silenceTimer !== null) { + clearTimeout(silenceTimer); + silenceTimer = null; + } + }; + const cleanupArgvAttach = () => { + // The mount child holds the FUSE mount alive; kill it so the + // mount unmounts at teardown (per-turn mount+unmount). No-op off-Linux. + try { + mountChild?.kill("SIGTERM"); + } catch {} + if (attachTmpDir) { + try { + rmSync(attachTmpDir, { recursive: true, force: true }); + } catch {} + attachTmpDir = null; + } + }; return new ReadableStream({ async start(controller) { @@ -480,6 +773,52 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } catch {} }; + // argv-attach (ZCode): resolve the .cjs argv[1] before the final argv is + // assembled; on Linux that path is only known after the AppImage + // self-mounts. Reuses resolveZcodeBin() and the + // mount helper unchanged; a ZCODE_BIN pointing at a `.cjs` is used + // directly with no mount. + let cjsPath: string | undefined; + if (promptViaAttach) { + if (process.platform === "linux") { + const resolvedZcode = resolveZcodeBin(); + if (!resolvedZcode) { + safeEnqueue({ + type: "error", + message: + "ZCode AppImage not found. Open the ZCode GUI once (to write its .desktop entry) or set ZCODE_BIN to the AppImage (or the zcode.cjs bundle).", + }); + safeClose(); + return; + } + if (resolvedZcode.endsWith(".cjs")) { + cjsPath = resolvedZcode; + } else { + try { + const mounted = await mountZcodeAppImage(resolvedZcode, { + cwd: opts.cwd, + signal: opts.signal, + }); + mountChild = mounted.mountChild; + cjsPath = path.posix.join( + mounted.mountPoint, + "resources", + "glm", + "zcode.cjs", + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + cleanupArgvAttach(); + safeEnqueue({ type: "error", message }); + safeClose(); + return; + } + } + } else { + cjsPath = resolveZcodeBin() ?? ZCODE_CJS_SENTINEL; + } + } + let argv: string[]; try { const argvOpts: AgentArgvOpts = { @@ -504,19 +843,97 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } if (promptViaArgv) argv = [...argv, opts.prompt]; if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; + // `protocol: "argv-attach"` (zcode): the full prompt (shared directives + // + template + user content, 20-30KB+) is far past command-line length + // limits, so it travels as a temp-file attachment; the `.md` enters the + // model context whole (verified). `-p` (buildArgv) already holds + // the fixed short guide. The mkdtemp dir is removed on every exit path + // via cleanupArgvAttach (close / error / abort / cancel); it also holds + // the per-turn provider-config clone below, so the binding is cleaned up + // with the prompt. + let boundModelMeta: string | null = null; + if (promptViaAttach) { + try { + attachTmpDir = mkdtempSync(path.join(tmpdir(), "html-anything-zcode-")); + writeFileSync(path.join(attachTmpDir, "prompt.md"), opts.prompt, "utf8"); + } catch (err) { + safeEnqueue({ + type: "error", + message: `failed to write the ZCode prompt attachment: ${err instanceof Error ? err.message : String(err)}`, + }); + cleanupArgvAttach(); + safeClose(); + return; + } + argv = [...argv, "--attach", path.join(attachTmpDir, "prompt.md")]; + + // Per-turn model binding (ZCode has no --model flag): clone + paired + // env vars + the identity-bridge credential; any broken link refuses + // the spawn. See zcode-model-binding.ts. A user who pre-set the env + // pair keeps it untouched (zero-code reroute). + if (!zcodeProviderEnvPairSet(process.env)) { + const binding = prepareZcodeModelBinding({ + cjsPath: cjsPath!, + model: opts.model, + attachDir: attachTmpDir, + }); + if (!binding.ok) { + safeEnqueue({ type: "error", message: binding.message }); + cleanupArgvAttach(); + safeClose(); + return; + } + Object.assign(env, zcodeBindingEnv(binding)); + boundModelMeta = binding.selection.reasoningLevel + ? `${binding.selection.modelId}/${binding.selection.reasoningLevel}` + : binding.selection.modelId; + } + } + + // node-script CLIs (ZCode's zcode.cjs) run as `node -p …`; + // binArgs holds the leading argv the bin needs, with the + // `` sentinel filled from the path resolved above + // (the mounted path on Linux). Absent for every existing adapter, so + // their spawn is unchanged. + const leadingArgv = (def.binArgs ?? []).map((arg) => + arg === ZCODE_CJS_SENTINEL && cjsPath ? cjsPath : arg, + ); + const fullArgv = leadingArgv.length ? [...leadingArgv, ...argv] : argv; try { - child = spawn(bin, argv, { - cwd: opts.cwd ?? process.cwd(), - env, - stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32", - }); + // On Windows, `spawn` cannot launch a `.cmd` / `.bat` shim without + // going through the shell. Exception: an absolute `.exe` (ZCode's + // `ZCode.exe` node driver, or a real node.exe) spawns directly with + // no cmd.exe round-trip, so spaced install paths + // (`C:\Program Files\ZCode\ZCode.exe`) work untouched and every argv + // element passes verbatim, with no quoting games. The shell path keeps + // this side's verbatim baseline for every other agent; zcode's + // `.cmd`-shim corner quotes the bin and each argv element (the spaced + // zcode.cjs path must survive cmd.exe's whitespace split). (The next + // mirror quotes the bin for all shell-path agents, its own + // long-standing baseline; the two files are adapted copies, not + // verbatim mirrors.) + const binIsAbsoluteExe = + process.platform === "win32" && + /^([a-zA-Z]:[\\/]|[\\/])/.test(bin) && + /\.exe$/i.test(bin); + const useShell = process.platform === "win32" && !binIsAbsoluteExe; + child = spawn( + useShell && promptViaAttach ? `"${bin}"` : bin, + useShell && promptViaAttach ? fullArgv.map(quoteWindowsArg) : fullArgv, + { + cwd: opts.cwd ?? process.cwd(), + env, + stdio: ["pipe", "pipe", "pipe"], + shell: useShell, + }, + ); } catch (err) { safeEnqueue({ type: "error", message: err instanceof Error ? err.message : String(err), }); + cleanupArgvAttach(); safeClose(); return; } @@ -524,19 +941,56 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { safeEnqueue({ type: "start", bin, - argv, + argv: fullArgv, promptBytes: Buffer.byteLength(opts.prompt, "utf8"), }); + // The bound model as resolved by the per-turn binding (ZCode's stream emits + // no model/session start meta of its own). Emitted so the log shows + // which model and level the turn was pinned to. + if (boundModelMeta) { + safeEnqueue({ type: "meta", key: "model", value: boundModelMeta }); + } + + // Arm the silence watchdog only here, after the spawn succeeded, so + // the argv-assembly / Linux-mount / binding window before it can + // never trip the timer (that window has its own bounded failures). + const fireSilence = () => { + silenceTimer = null; + safeEnqueue({ + type: "error", + message: `ZCode produced no stream events for ${ZCODE_SILENCE_TIMEOUT_MS / 1000}s. The turn was terminated (hung model or CLI?).`, + }); + try { + child?.kill("SIGTERM"); + } catch {} + cleanupArgvAttach(); + safeClose(); + }; + const resetSilenceTimer = () => { + // No-op once the turn ended (close/abort cleared the handle) or for + // agents that never armed one. + if (silenceTimer === null) return; + clearTimeout(silenceTimer); + silenceTimer = setTimeout(fireSilence, ZCODE_SILENCE_TIMEOUT_MS); + }; + if (opts.agent === "zcode") { + silenceTimer = setTimeout(fireSilence, ZCODE_SILENCE_TIMEOUT_MS); + } child.stdin.on("error", () => {}); try { - if (!promptViaArgv && !promptViaMessageFlag) child.stdin.write(opts.prompt); + // stdin-protocol agents read the prompt from stdin; argv / argv-message + // agents already have it on the command line; argv-attach agents carry + // it in the temp-file attachment, so stdin stays empty for all three. + if (!promptViaArgv && !promptViaMessageFlag && !promptViaAttach) child.stdin.write(opts.prompt); child.stdin.end(); } catch {} const parse = makeParser(opts.agent); let stdoutBuf = ""; + // Rolling stderr tail feeding the non-zero-exit error message. + let stderrTail = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { if (closed) return; @@ -547,10 +1001,17 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { const line = stdoutBuf.slice(0, nl); stdoutBuf = stdoutBuf.slice(nl + 1); if (!line) continue; - for (const part of parse(line)) { + const parts = parse(line); + // Any line that parsed to at least one event proves the turn + // is alive, so it resets the zcode silence watchdog (no-op for the + // agents that never armed one). Dropped noise lines do not reset: + // they are exactly the "silence" the watchdog exists to catch. + if (parts.length > 0) resetSilenceTimer(); + for (const part of parts) { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + else if (part.kind === "error") safeEnqueue({ type: "error", message: part.message }); else safeEnqueue({ type: "raw", text: line.slice(0, 240) }); } } @@ -558,15 +1019,19 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP); safeEnqueue({ type: "stderr", text: chunk }); }); child.on("error", (err) => { + clearSilenceTimer(); safeEnqueue({ type: "error", message: err.message }); + cleanupArgvAttach(); safeClose(); }); child.on("close", (code) => { + clearSilenceTimer(); if (opts.agent === "openclaw") { if (stdoutBuf.trim()) { try { @@ -600,25 +1065,133 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + else if (part.kind === "error") safeEnqueue({ type: "error", message: part.message }); } } } + // argv-attach teardown: kill the Linux mount holder and remove the + // prompt temp dir (no-op for every other agent). + cleanupArgvAttach(); + // A non-zero exit is a failed turn: emit the error before done + // so the consumer's failure gate renders the red error state (an + // exit-0/unknown-code close keeps the historical done-only shape). + // The message quotes the stderr tail for diagnosability; the full + // stderr has already streamed as `stderr` log events. + if (opts.agent === "zcode" && code != null && code !== 0) { + const hint = stderrTail.trim(); + safeEnqueue({ + type: "error", + message: `ZCode exited with code ${code}.${hint ? ` stderr: ${hint.slice(-STDERR_HINT_MAX)}` : ""}`, + }); + } safeEnqueue({ type: "done", code }); safeClose(); }); const onAbort = () => { + clearSilenceTimer(); try { child?.kill("SIGTERM"); } catch {} + cleanupArgvAttach(); safeClose(); }; opts.signal?.addEventListener("abort", onAbort, { once: true }); }, - cancel() {}, + cancel() { + // Stream consumer cancelled. Release the argv-attach turn's resources + // (mount holder + prompt temp file + silence watchdog); the child + // itself is governed by the abort signal, unchanged generic behavior. + clearSilenceTimer(); + cleanupArgvAttach(); + }, + }); +} + +// ─── ZCode AppImage self-mount (Linux) ──────────────────────────────── +// +// Shared helper for the argv-attach (CLI one-shot) trunk: on Linux the +// zcode.cjs bundle lives inside the AppImage squashfs, so the turn +// self-mounts the AppImage first and spawns node against the mounted +// /resources/glm/zcode.cjs. The generic trunk owns the process +// lifecycle (spawn + kill). + +/** + * Self-mount the ZCode AppImage. Spawns + * `AppImage --appimage-mount`, which prints the FUSE mount point to stdout + * (first line) and stays alive holding the mount for as long as it runs. + * Returns the mount child (killed on teardown) and the resolved mount point. + * + * Bounded to ~5s so a missing FUSE / corrupt AppImage / permission error + * surfaces a clear error event rather than hanging the turn. The + * caller wires `signal` into the surrounding teardown. + */ +async function mountZcodeAppImage( + appImage: string, + opts: { cwd?: string; signal?: AbortSignal }, +): Promise<{ mountChild: ChildProcessWithoutNullStreams; mountPoint: string }> { + const mountChild = spawn(appImage, ["--appimage-mount"], { + cwd: opts.cwd ?? process.cwd(), + // All-pipe stdio keeps the type ChildProcessWithoutNullStreams (matching + // the CLI child). The mount child never reads stdin; an idle pipe + // is harmless and gets torn down with the child on teardown. + stdio: ["pipe", "pipe", "pipe"], + }); + const mountPoint = await new Promise((resolve, reject) => { + let buf = ""; + let settled = false; + const settle = (err: Error | null, value?: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + mountChild.stdout.removeAllListeners(); + mountChild.removeListener("error", onError); + mountChild.removeListener("close", onClose); + if (opts.signal && onSignalAbort) { + opts.signal.removeEventListener("abort", onSignalAbort); + } + if (err) { + try { mountChild.kill("SIGTERM"); } catch {} + reject(err); + } else { + resolve(value!); + } + }; + const timer = setTimeout(() => { + settle(new Error("ZCode AppImage mount timed out (no mount point after 5s). Is FUSE available and is the AppImage executable?")); + }, 5_000); + const onError = (err: Error) => settle(err); + // The mount child should stay alive holding the FUSE mount; ANY exit before + // a mount point is printed is a failure (FUSE missing, corrupt AppImage, + // permission denied, or killed). After success the idempotent guard no-ops. + const onClose = () => + settle(new Error("ZCode AppImage mount exited before producing a mount point (FUSE missing, AppImage corrupt, or permission denied?)")); + // Honor an abort during the mount window (up to 5s). The outer onAbort is + // only registered after this await resolves, so without this wiring a + // cancel during mount would hold the FUSE mount until the timeout fires. + const onSignalAbort = () => settle(new Error("ZCode AppImage mount aborted")); + mountChild.stdout.setEncoding("utf8"); + mountChild.stdout.on("data", (chunk: string) => { + buf += chunk; + const nl = buf.indexOf("\n"); + if (nl !== -1) { + const mp = buf.slice(0, nl).trim(); + if (mp) settle(null, mp); + else settle(new Error("ZCode AppImage mount produced an empty mount point")); + } + }); + mountChild.on("error", onError); + mountChild.on("close", onClose); + if (opts.signal?.aborted) { + settle(new Error("ZCode AppImage mount aborted")); + } else if (opts.signal) { + opts.signal.addEventListener("abort", onSignalAbort, { once: true }); + } }); + return { mountChild, mountPoint }; } + function errorStream(message: string): ReadableStream { return new ReadableStream({ start(controller) { diff --git a/cli/src/index.ts b/cli/src/index.ts index 46b90142..7c50140b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -707,10 +707,18 @@ function handleAgents(): void { const status = a.available ? a.unsupported ? "⚠ (unsupported)" - : "✓" + : a.ready === false + ? "⚠ (installed · not ready)" + : "✓" : "✗"; const isDefault = a.id === config.defaultAgent ? " (default)" : ""; console.log(` ${status} ${a.id} — ${a.label} (${a.vendor})${isDefault}`); + // ZCode installed is not the same as ready; print the guidance line too. + if (a.available && a.ready === false) { + console.log( + " no usable provider: open ZCode, log in or add a model provider, then re-run", + ); + } } } diff --git a/cli/src/zcode-model-binding.ts b/cli/src/zcode-model-binding.ts new file mode 100644 index 00000000..6e01242c --- /dev/null +++ b/cli/src/zcode-model-binding.ts @@ -0,0 +1,1217 @@ +/** + * Per-turn model binding for the ZCode CLI one-shot adapter. The picker + * mirrors ZCode's own headless provider registry — every provider the + * spawned CLI would materialize, in the order it would list them — so the + * chips a user picks from are exactly the routes the turn can take. + * + * The registry has two kinds of members (verified against the ZCode 3.14 + * source drop; receipts below): + * + * - **Account providers**: catalog `providerRules` entries whose + * `access.mode` is `individual-coding-plan`. The headless CLI expands an + * account provider only when the credential store holds its identity key + * AND the api-key key named by that identity — it reads + * `account-provider::identity`, then requires + * `account-provider:coding-plan::account::api-key` + * (standalone-account-provider-runtime.ts). Only `zcode login` writes the + * identity key (OAuth → user id, API key → sha256 prefix); the GUI never + * does, and the team shell keys the GUI auto-lays at login have no + * identity key, so team/start/off-peak never satisfy the pairing. The + * enumeration mirrors that pairing exactly: it lists only what the CLI + * itself would materialize. + * - **Personal providers**: `provider_config.json` `providerRules` entries + * whose `access` carries an `apiKey` (template instances get their key + * material from the personal entry; the template schema has no key of + * its own). Template instances list the template's `builtinModelIds` + * union the entry's `personalModelIds`; custom entries list + * `personalModelIds` alone. + * + * Ordering mirrors the resolver: the account family group first in catalog + * declaration order, then personal providers in the user's `providerOrder` + * (`resolveOwnedOrder`: unordered entries follow in declaration order). + * Model order within a provider is `resolveOwnedOrder(builtinModelIds, + * personalModelIds, modelOrder)`. Reasoning levels come from the catalog's + * all-vendor `modelRules` regex table (last matching rule with values wins, + * case-insensitive full match). A model no rule gives levels to is still + * listed and bindable — the binding then carries no `reasoningLevel`. + * Enabled is the overlay of catalog `builtinProviderModelRules` / + * `templateModelRules` and personal `providerModelRules` (an explicit + * `enabled: false` drops the entry; a missing entry keeps it on), and + * `hidden` providers are filtered like the GUI's picker. + * + * The default chain mirrors the CLI's own (`model-selection-config.ts`): + * the configured `config.defaultModelSelection` while it still selects + * something in this registry (legacy `providerId`s migrated; only CLI + * login / the TUI / import ever write that key — never the GUI), else the + * first registry provider's first model at its highest level. + * + * Binding mechanism (unchanged from the verified flow): clone the user's + * `~/.zcode/v2/provider_config.json` to a temp file, write the exact + * `config.defaultModelSelection` into the clone, and hand the child the + * paired env vars `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` (the clone) and + * `ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` (the bundled catalog file validated + * here). The pair is required by the CLI's runtime-paths pair check. The + * user's real config file is never written; the clone is a per-turn temp + * file owned by the invoke layer's cleanup. + * + * The one real write is the identity bridge, and it only ever fires for an + * account target: if an explicit pick names an account provider whose + * api-key key exists but whose identity key is missing, the bridge appends + * that one key atomically (tmp file + rename, add-only — an existing + * identity value is never modified, and every spawn re-checks first). + * Without the key the headless CLI drops the provider from its registry and + * silently reroutes the turn. `setting.json` is not read at all: its family + * keys are GUI navigation state with no headless consumer. + * + * Refusal chain (any broken link refuses the spawn with an actionable + * message): catalog unreadable → provider config unreadable → no usable + * provider (ready is the single "no keyed service" state) → explicit pick + * resolution (provider without access / model not on the provider / + * unsupported level). Nothing silently falls back. + * + * Files read (all private ZCode formats): the bundled catalog next to the + * install (`/resources/config/provider/zcode-builtin.json`, + * runtime cache as a freshness-ordered second choice; schemaVersion must + * be 1), `~/.zcode/v2/provider_config.json`, and + * `~/.zcode/v2/credentials.json`. From credentials only key names are + * read, plus the value of the identity keys (a plaintext account id by + * ZCode's own convention — `zcode login` writes it unencrypted); the + * api-key values are encrypted at rest and are never read. + */ + +import { + mkdirSync, + readFileSync, + readdirSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +/** Narrow `unknown` to a plain JSON object: not null, not an array. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string" && v.length > 0) + : []; +} + +/** Env var carrying the personal provider config (name from ZCode's runtime-paths.ts). */ +export const ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV = "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"; +/** Env var carrying the builtin provider catalog (name from ZCode's runtime-paths.ts). */ +export const ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"; + +/** + * Why a detected install is not ready to run turns. Single state (v3): the + * registry mirror holds no usable provider, whatever the reason (nothing + * logged in, nothing keyed, catalog gone). The settings card carries one + * guidance line for it. + */ +export type ZcodeNotReadyReason = "no-usable-provider"; + +/** One enumerated model: its id plus the reasoning levels the catalog's + * `modelRules` give it. Empty `levels` = no level table; the model stays + * selectable and binds without a `reasoningLevel`. */ +export interface ZcodeRegistryModel { + modelId: string; + levels: string[]; +} + +/** One usable provider in registry order, as the headless CLI would list it. */ +export interface ZcodeRegistryProvider { + providerId: string; + /** Catalog `providerName` for account providers, the personal entry's name otherwise. */ + providerName: string; + kind: "account" | "personal"; + /** Account providers only: the identity the credential pair was built from. */ + accountIdentity?: string; + models: ZcodeRegistryModel[]; +} + +/** One picker chip as the detect layers render it (ModelOption-shaped). */ +export interface ZcodeModelOption { + /** `"/"` or `"//"`. */ + id: string; + /** `" ([, ])"` — the service name + * disambiguates the same model id across providers. */ + label: string; + providerId: string; +} + +/** What the "Default" chip currently resolves to (the same chain a default + * pick binds): the provider, its display name, the model, and the level + * (null when the model has no level table). */ +export interface ZcodeDefaultChoice { + providerId: string; + providerName: string; + modelId: string; + reasoningLevel: string | null; +} + +/** The exact binding written into the temp clone (CLI's ModelSelection + * shape; `reasoningLevel` null = omit the option entirely). */ +export interface ZcodeModelSelection { + providerId: string; + modelId: string; + reasoningLevel: string | null; +} + +/** The bundled catalog file we validated against, and where it came from. */ +export interface ZcodeCatalogFile { + path: string; + source: "install" | "cache"; + data: unknown; +} + +/** ─── Default paths (self-contained) ─── */ + +export function defaultZcodePersonalConfigPath(): string { + return join(homedir(), ".zcode", "v2", "provider_config.json"); +} +export function defaultZcodeCredentialsPath(): string { + return join(homedir(), ".zcode", "v2", "credentials.json"); +} +export function defaultZcodeRuntimeProviderDir(): string { + return join(homedir(), ".zcode", "v2", "runtime", "provider"); +} + +/** Shared tolerant JSON reader: null on missing/unreadable/malformed. */ +function readJsonFile(filePath: string): unknown { + let text: string; + try { + text = readFileSync(filePath, "utf8"); + } catch { + return null; + } + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +/** ─── Bundled catalog resolution: install file first, cache by freshness ─── */ + +/** + * The bundled catalog written by the same install as the cjs we spawn: + * `/resources/config/provider/zcode-builtin.json` where the cjs is + * `/resources/glm/zcode.cjs`. Holds for Windows, macOS, .deb, and + * the Linux AppImage mount alike; the GUI host passes exactly this path + * (per desktopProviderConfig.ts). + */ +export function zcodeBundledCatalogPathForCjs(cjsPath: string): string { + return join(dirname(dirname(cjsPath)), "config", "provider", "zcode-builtin.json"); +} + +/** + * Cache platform segment (resolveZCodeBuiltinClientPlatform): windows|linux| + * darwin + x86_64|aarch64. Exported only so tests can build the cache tree the + * resolver scans; callers should use `resolveZcodeCatalogFile()`. + */ +export function zcodeCachePlatform(): string { + const target = process.platform === "win32" ? "windows" : process.platform; + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch; + return `${target}-${arch}`; +} + +/** Numeric-aware version-dir compare ("3.14.10" > "3.14.9" > "3.9.2"). */ +function compareVersionDirs(a: string, b: string): number { + const sa = a.split(/[.-]/); + const sb = b.split(/[.-]/); + for (let i = 0; i < Math.max(sa.length, sb.length); i++) { + const na = Number(sa[i]); + const nb = Number(sb[i]); + const va = Number.isFinite(na) ? na : null; + const vb = Number.isFinite(nb) ? nb : null; + if (va === null && vb === null) { + const c = sa[i]!.localeCompare(sb[i]!); + if (c !== 0) return c; + } else if (va === null) return -1; + else if (vb === null) return 1; + else if (va !== vb) return va - vb; + } + return 0; +} + +function isCatalogShape(data: unknown): boolean { + if (!isRecord(data)) return false; + // schemaVersion gate (v1 unchanged to date). + if (data.schemaVersion !== 1) return false; + return isRecord(data.config); +} + +/** + * Locate + read the builtin catalog. Order: the install-bundled file + * first (version-consistent with the spawned cjs by construction), then the + * runtime cache by freshness (highest version dir, newest file within it). + * Returns null when neither yields a schema-valid file; that is the + * catalog-unreadable refusal link. + */ +export function resolveZcodeCatalogFile(opts: { + cjsPath: string; + cacheRoot?: string; +}): ZcodeCatalogFile | null { + const installPath = zcodeBundledCatalogPathForCjs(opts.cjsPath); + const installData = readJsonFile(installPath); + if (isCatalogShape(installData)) { + return { path: installPath, source: "install", data: installData }; + } + const cacheRoot = opts.cacheRoot ?? defaultZcodeRuntimeProviderDir(); + const platformDir = join(cacheRoot, zcodeCachePlatform()); + let versions: string[] = []; + try { + versions = readdirSync(platformDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(compareVersionDirs) + .reverse(); + } catch { + return null; + } + for (const version of versions) { + let candidates: string[] = []; + try { + candidates = readdirSync(join(platformDir, version), { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith("endpoint-")) + .map((e) => join(platformDir, version, e.name, "zcode-builtin.json")); + } catch { + continue; + } + // Freshest file mtime wins within a version dir. + let best: { path: string; mtime: number } | null = null; + for (const candidate of candidates) { + try { + const mtime = statSync(candidate).mtimeMs; + if (!best || mtime > best.mtime) best = { path: candidate, mtime }; + } catch { + // unreadable candidate; skip + } + } + if (best) { + const data = readJsonFile(best.path); + if (isCatalogShape(data)) return { path: best.path, source: "cache", data }; + } + } + return null; +} + +/** ─── Registry mirror (pure): providers, models, levels, ordering ─── */ + +/** + * `resolveOwnedOrder` from ZCode's owned-order.ts, mirrored: unordered + * builtin ids stay ahead of the user's order, unordered personal ids + * follow it, duplicates keep first occurrence. Keeps the adapter's model + * and provider ordering byte-compatible with the GUI's reorder semantics. + */ +function resolveOwnedOrder( + builtinIds: readonly string[], + personalIds: readonly string[], + requestedOrder: readonly string[], +): string[] { + const uniqueInOrder = (values: readonly string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; + }; + const builtin = uniqueInOrder(builtinIds); + const builtinSet = new Set(builtin); + const personal = uniqueInOrder(personalIds).filter((id) => !builtinSet.has(id)); + const members = new Set([...builtin, ...personal]); + const ordered = uniqueInOrder(requestedOrder).filter((id) => members.has(id)); + const orderedSet = new Set(ordered); + return [ + ...builtin.filter((id) => !orderedSet.has(id)), + ...ordered, + ...personal.filter((id) => !orderedSet.has(id)), + ]; +} + +interface RuleTables { + modelRules: unknown[]; + builtinProviderModelRules: unknown[]; + templateModelRules: unknown[]; + personalProviderModelRules: unknown[]; +} + +function readRuleTables(catalog: unknown, personal: unknown): RuleTables { + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const catalogModelRules = isRecord(catalogConfig.modelConfigRules) + ? catalogConfig.modelConfigRules + : {}; + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const personalModelRules = isRecord(personalConfig.modelConfigRules) + ? personalConfig.modelConfigRules + : {}; + return { + modelRules: Array.isArray(catalogModelRules.modelRules) ? catalogModelRules.modelRules : [], + builtinProviderModelRules: Array.isArray(catalogModelRules.builtinProviderModelRules) + ? catalogModelRules.builtinProviderModelRules + : [], + templateModelRules: Array.isArray(catalogModelRules.templateModelRules) + ? catalogModelRules.templateModelRules + : [], + personalProviderModelRules: Array.isArray(personalModelRules.providerModelRules) + ? personalModelRules.providerModelRules + : [], + }; +} + +/** + * The reasoning levels for a model id from the catalog's all-vendor + * `modelRules` regex table: the LAST rule (array order, per the engine's + * overlay semantics) whose `^(?:modelMatch)$` case-insensitively + * full-matches the id and specifies non-empty + * `config.optionSpecs.reasoningLevel.values`. Invalid regexes are skipped + * (tolerant). A model no rule gives values to has no levels — still + * selectable, bound without a reasoningLevel. + */ +function resolveLevels(modelRules: readonly unknown[], modelId: string): string[] { + let levels: string[] = []; + for (const rule of modelRules) { + if (!isRecord(rule)) continue; + const modelMatch = typeof rule.modelMatch === "string" ? rule.modelMatch : null; + if (!modelMatch) continue; + let matches: boolean; + try { + matches = new RegExp(`^(?:${modelMatch})$`, "i").test(modelId); + } catch { + continue; + } + if (!matches) continue; + const values = extractLevelValues(rule.config); + if (values) levels = values; + } + return levels; +} + +function extractLevelValues(config: unknown): string[] | null { + if (!isRecord(config) || !isRecord(config.optionSpecs)) return null; + const spec = isRecord(config.optionSpecs.reasoningLevel) + ? config.optionSpecs.reasoningLevel + : null; + if (!spec || !Array.isArray(spec.values)) return null; + const values = spec.values.filter( + (v): v is string => typeof v === "string" && v.trim().length > 0, + ); + return values.length > 0 ? values : null; +} + +/** + * The enabled model table for one provider: the ordered id union with the + * three enabled tables overlaid last-write-wins in catalog-first order + * (builtinProviderModelRules → templateModelRules → personal + * providerModelRules, mirroring composeEffective's [...builtin, ...personal]), + * so an explicit `enabled: false` drops a model and a later explicit + * `enabled: true` re-enables it; a missing entry keeps it on. Each listed + * model carries its `modelRules` levels. + */ +function enabledModelsFor( + tables: RuleTables, + input: { + providerId: string; + templateId: string | null; + builtinModelIds: readonly string[]; + personalModelIds: readonly string[]; + modelOrder: readonly string[]; + }, +): ZcodeRegistryModel[] { + const ids = resolveOwnedOrder(input.builtinModelIds, input.personalModelIds, input.modelOrder); + const enabled = new Map(); + const overlay = ( + rules: readonly unknown[], + matches: (rule: Record) => boolean, + ) => { + for (const rule of rules) { + if (!isRecord(rule)) continue; + if (!matches(rule) || typeof rule.modelId !== "string") continue; + const config = isRecord(rule.config) ? rule.config : null; + if (config && typeof config.enabled === "boolean") enabled.set(rule.modelId, config.enabled); + } + }; + overlay(tables.builtinProviderModelRules, (r) => r.providerId === input.providerId); + if (input.templateId) { + overlay(tables.templateModelRules, (r) => r.templateId === input.templateId); + } + overlay(tables.personalProviderModelRules, (r) => r.providerId === input.providerId); + const models: ZcodeRegistryModel[] = []; + for (const modelId of ids) { + if (enabled.get(modelId) === false) continue; + models.push({ modelId, levels: resolveLevels(tables.modelRules, modelId) }); + } + return models; +} + +/** A catalog account provider entry (already filtered to the headless + * expansion mode), with its model table computed. */ +interface AccountCandidate { + providerId: string; + providerName: string; + models: ZcodeRegistryModel[]; +} + +function accountCandidates(catalog: unknown, tables: RuleTables): AccountCandidate[] { + const out: AccountCandidate[] = []; + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const providerConfigRules = isRecord(catalogConfig.providerConfigRules) + ? catalogConfig.providerConfigRules + : {}; + if (!Array.isArray(providerConfigRules.providerRules)) return out; + for (const rule of providerConfigRules.providerRules) { + if (!isRecord(rule)) continue; + const providerId = nonEmptyString(rule.providerId); + if (!providerId) continue; + const config = isRecord(rule.config) ? rule.config : null; + const access = config && isRecord(config.access) ? config.access : null; + // The headless CLI only expands individual coding plans; team / start / + // off-peak account providers never materialize standalone. + if (!access || access.mode !== "individual-coding-plan") continue; + const providerName = nonEmptyString(rule.providerName) ?? providerId; + const models = enabledModelsFor(tables, { + providerId, + templateId: null, + builtinModelIds: config ? stringArray(config.builtinModelIds) : [], + personalModelIds: [], + modelOrder: [], + }); + out.push({ providerId, providerName, models }); + } + return out; +} + +/** A personal provider_config.json entry, parsed tolerantly. Entries with a + * missing template or disabled/hidden/keyless config stay in this list (an + * explicit pick of them must fail with the provider refusal, not a stale + * one) but are filtered out of the registry. */ +interface PersonalCandidate { + providerId: string; + templateId: string | null; + providerName: string; + apiKey: string | null; + enabled: boolean; + hidden: boolean; + models: ZcodeRegistryModel[]; +} + +function personalCandidates(catalog: unknown, personal: unknown, tables: RuleTables): PersonalCandidate[] { + const byId = new Map(); + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const providerConfigRules = isRecord(catalogConfig.providerConfigRules) + ? catalogConfig.providerConfigRules + : {}; + const templateRules = Array.isArray(providerConfigRules.templateRules) + ? providerConfigRules.templateRules + : []; + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const personalProviderRules = + isRecord(personalConfig.providerConfigRules) && + Array.isArray(personalConfig.providerConfigRules.providerRules) + ? personalConfig.providerConfigRules.providerRules + : []; + for (const rule of personalProviderRules) { + if (!isRecord(rule)) continue; + const providerId = nonEmptyString(rule.providerId); + if (!providerId || byId.has(providerId)) continue; + const config = isRecord(rule.config) ? rule.config : null; + const access = config && isRecord(config.access) ? config.access : null; + const apiKey = access ? nonEmptyString(access.apiKey) : null; + const templateId = nonEmptyString(rule.templateId); + let templateBuiltinIds: string[] = []; + if (templateId) { + const template = templateRules.find( + (t) => isRecord(t) && t.templateId === templateId && isRecord(t.config), + ); + if (template && isRecord(template.config)) { + templateBuiltinIds = stringArray(template.config.builtinModelIds); + } + } + const models = enabledModelsFor(tables, { + providerId, + templateId, + builtinModelIds: templateBuiltinIds, + personalModelIds: config ? stringArray(config.personalModelIds) : [], + modelOrder: config ? stringArray(config.modelOrder) : [], + }); + byId.set(providerId, { + providerId, + templateId, + providerName: nonEmptyString(rule.providerName) ?? providerId, + apiKey, + enabled: !(config?.enabled === false), + hidden: config?.visibility === "hidden", + models, + }); + } + return [...byId.values()]; +} + +/** + * The account credential pair mirror: the identity key's value (a plaintext + * account id by ZCode's own write convention) plus the existence of the + * api-key key it names — exactly the entitlement check the headless CLI + * performs (standalone-account-provider-runtime.ts reads the identity key, + * then requires the api-key key built from it). Null when the provider is + * not materialized standalone. + */ +function accountPair( + credentials: Record, + providerId: string, +): { identity: string } | null { + const raw = credentials[`account-provider:${providerId}:identity`]; + const identity = typeof raw === "string" ? raw.trim() : ""; + if (!identity) return null; + const apiKeyKey = + `account-provider:coding-plan:${providerId}:account:${encodeURIComponent(identity)}:api-key`; + return credentials[apiKeyKey] !== undefined ? { identity } : null; +} + +/** + * Find ANY coding-plan api-key key for the provider and recover the account + * identity from its name — the identity bridge's raw material. The GUI + * writes these keys with the identity embedded (encodeURIComponent) in the + * name, so no credential value is read. + */ +function findCodingPlanApiKeyEntry( + credentials: Record, + providerId: string, +): { key: string; identity: string } | null { + const prefix = `account-provider:coding-plan:${providerId}:account:`; + for (const key of Object.keys(credentials)) { + if (!key.startsWith(prefix) || !key.endsWith(":api-key")) continue; + const raw = key.slice(prefix.length, -":api-key".length); + try { + return { key, identity: decodeURIComponent(raw) }; + } catch { + return { key, identity: raw }; + } + } + return null; +} + +/** + * Enumerate the registry the headless CLI would build: paired account + * individual plans (catalog declaration order) first, then keyed personal + * providers (user `providerOrder`, unordered entries in declaration + * order). Providers whose model table empties out under the enabled + * overlays are dropped, like the CLI's registry builder. Pure; tolerant of + * every malformed input (worst case: an empty registry → the single + * no-usable-provider refusal). + */ +export function enumerateZcodeRegistry( + catalog: unknown, + personal: unknown, + credentials: unknown, +): ZcodeRegistryProvider[] { + const tables = readRuleTables(catalog, personal); + const credentialRecord = isRecord(credentials) ? credentials : {}; + const out: ZcodeRegistryProvider[] = []; + for (const account of accountCandidates(catalog, tables)) { + if (account.models.length === 0) continue; + const pair = accountPair(credentialRecord, account.providerId); + if (!pair) continue; + out.push({ + providerId: account.providerId, + providerName: account.providerName, + kind: "account", + accountIdentity: pair.identity, + models: account.models, + }); + } + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const candidates = personalCandidates(catalog, personal, tables); + const ordered = resolveOwnedOrder( + [], + candidates.map((c) => c.providerId), + stringArray(personalConfig.providerOrder), + ); + const byId = new Map(candidates.map((c) => [c.providerId, c])); + for (const providerId of ordered) { + const entry = byId.get(providerId); + if (!entry) continue; + if (entry.hidden || !entry.enabled || !entry.apiKey) continue; + if (entry.models.length === 0) continue; + out.push({ + providerId: entry.providerId, + providerName: entry.providerName, + kind: "personal", + models: entry.models, + }); + } + return out; +} + +/** ─── Default chain (pure) ─── */ + +/** + * The default-selection chain shared by the Default-chip label and a + * default pick: the configured `config.defaultModelSelection` while it + * still selects a provider+model in this registry (legacy provider ids + * migrated; the GUI never writes the key — only CLI login / TUI / import + * do), else the first registry provider's first model at its highest + * level (the CLI's own `registry-fallback`). A configured level the model + * does not support invalidates the configured default, not the turn. + */ +export function resolveZcodeDefaultSelection( + registry: readonly ZcodeRegistryProvider[], + personal: unknown, +): ZcodeDefaultChoice | null { + const first = registry[0]?.models[0]; + if (!first) return null; + const fallback = (): ZcodeDefaultChoice => ({ + providerId: registry[0]!.providerId, + providerName: registry[0]!.providerName, + modelId: first.modelId, + reasoningLevel: first.levels[first.levels.length - 1] ?? null, + }); + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const configured = isRecord(personalConfig.defaultModelSelection) + ? personalConfig.defaultModelSelection + : null; + if (!configured) return fallback(); + const providerId = + typeof configured.providerId === "string" + ? migrateLegacyZcodeProviderId(configured.providerId) + : null; + const provider = providerId ? registry.find((p) => p.providerId === providerId) : undefined; + const model = + provider && typeof configured.modelId === "string" + ? provider.models.find((m) => m.modelId === configured.modelId) + : undefined; + if (!provider || !model) return fallback(); + const configuredLevel = + isRecord(configured.options) && typeof configured.options.reasoningLevel === "string" + ? configured.options.reasoningLevel + : null; + if (configuredLevel && !model.levels.includes(configuredLevel)) return fallback(); + return { + providerId: provider.providerId, + providerName: provider.providerName, + modelId: model.modelId, + reasoningLevel: configuredLevel ?? model.levels[model.levels.length - 1] ?? null, + }; +} + +/** ─── Picker id codec ─── */ + +/** + * Encode a (providerId, modelId, level?) picker choice into the single + * string the UI store persists and the invoke layer receives as `model`: + * `"/[/]"` — the provider-namespace convention + * the openclaw (`openrouter/anthropic/…`) and opencode (`anthropic/…`) + * pickers use, so the same model id served by two providers never collides. + * Level-less models (no `modelRules` entry) encode without the suffix. + */ +export function encodeZcodeModelChoice( + providerId: string, + modelId: string, + reasoningLevel?: string | null, +): string { + return reasoningLevel + ? `${providerId}/${modelId}/${reasoningLevel}` + : `${providerId}/${modelId}`; +} + +/** + * Structural decode: the first segment is always the providerId (ZCode + * provider ids never contain `/`); the rest is the model id plus an + * optional trailing level. Model ids CAN contain `/` (the catalog's + * openrouter templates list vendor-prefixed ids like + * `anthropic/claude-fable-5.1`), so the split point is not fixed here — + * validation against the registry picks the reading. Returns null for ids + * that cannot even be structurally split. + */ +export function decodeZcodeModelChoice(id: string): { providerId: string } | null { + const segments = id.split("/"); + if (segments.length < 2 || segments.some((s) => s.length === 0)) return null; + return { providerId: segments[0]! }; +} + +/** ─── Picker state (the detect layer's one call) ─── */ + +/** The detect layer's composed ZCode state. */ +export interface ZcodePickerState { + /** True when the registry mirror holds ≥1 usable provider. */ + ready: boolean; + reason: ZcodeNotReadyReason | null; + /** Chips in registry order; empty when not ready (caller keeps the + * static floor and the invoke-time refusal reports the error). */ + options: ZcodeModelOption[]; + /** What "Default" resolves to right now; null when not ready. */ + defaultChoice: ZcodeDefaultChoice | null; +} + +/** + * Compose the picker state for a resolved cjs: enumerate the registry + * (catalog + personal config + credential key names), expand it into + * provider-namespaced chips, and resolve the Default label with the same + * chain a default pick binds. Read-only; shared by the next + cli detect + * mirrors so the two front ends cannot drift. + */ +export function readZcodePickerState(opts: { + cjsPath: string; + personalConfigPath?: string; + credentialsPath?: string; + cacheRoot?: string; +}): ZcodePickerState { + const notReady: ZcodePickerState = { + ready: false, + reason: "no-usable-provider", + options: [], + defaultChoice: null, + }; + const catalog = resolveZcodeCatalogFile({ cjsPath: opts.cjsPath, cacheRoot: opts.cacheRoot }); + if (!catalog) return notReady; + const personal = readJsonFile(opts.personalConfigPath ?? defaultZcodePersonalConfigPath()); + if (!isRecord(personal)) return notReady; + const credentials = readJsonFile(opts.credentialsPath ?? defaultZcodeCredentialsPath()); + const registry = enumerateZcodeRegistry(catalog.data, personal, credentials); + if (registry.length === 0) return notReady; + const options: ZcodeModelOption[] = []; + for (const provider of registry) { + for (const model of provider.models) { + const levelChips = model.levels.length ? model.levels : [null]; + for (const level of levelChips) { + options.push({ + id: encodeZcodeModelChoice(provider.providerId, model.modelId, level), + label: level + ? `${model.modelId} (${provider.providerName}, ${level})` + : `${model.modelId} (${provider.providerName})`, + providerId: provider.providerId, + }); + } + } + } + return { + ready: true, + reason: null, + options, + defaultChoice: resolveZcodeDefaultSelection(registry, personal), + }; +} + +/** ─── Legacy provider-id migration (one-way, read boundary only) ─── */ + +const LEGACY_PROVIDER_IDS: Record = { + "builtin:bigmodel-coding-plan": "account:bigmodel-individual-coding-plan", + "builtin:zai-coding-plan": "account:zai-individual-coding-plan", + "builtin:bigmodel-start-plan": "account:bigmodel-start-plan", + "builtin:zai-start-plan": "account:zai-start-plan", +}; + +/** + * Migrate legacy Coding-Plan provider ids to the current `account:*` ids + * (migrateLegacyModelProviderId semantics: known legacy ids map one-way; + * anything else passes through). Needed because a CLI-login-written + * `defaultModelSelection` on an upgraded install may still carry the + * legacy id. + */ +export function migrateLegacyZcodeProviderId(providerId: string): string { + return LEGACY_PROVIDER_IDS[providerId] ?? providerId; +} + +/** ─── Per-turn binding preparation (the invoke layer's one call) ─── */ + +/** The refusal links, in resolution order. */ +export type ZcodeBindingRefusal = + | "catalog-unreadable" + | "provider-config-unreadable" + | "no-usable-provider" + | "provider-no-access" + | "model-not-on-provider" + | "level-unsupported"; + +export type ZcodeBindingResult = + | { + ok: true; + /** The exact selection written into the clone (and returned for logging/tests). */ + selection: ZcodeModelSelection; + /** Path of the temp clone; its directory is the caller's to clean up. */ + clonePath: string; + /** The catalog file validated against, also the paired builtin env value. */ + builtinCatalogPath: string; + } + | { + ok: false; + code: ZcodeBindingRefusal; + /** Failure message (English, matching sibling invoke errors). */ + message: string; + }; + +/** Everything an explicit pick resolved to, plus the bridge material when + * the target is an account provider that still needs its identity key. */ +interface ResolvedSelection { + selection: ZcodeModelSelection; + /** Account target: the identity to ensure before spawning. */ + bridgeIdentity?: string; +} + +/** Resolve the pick's tail against one provider's model table: the first + * reading (shortest model id, then level-less) that validates becomes the + * selection (optionally carrying bridge material); a validated model with + * an unsupported level is the level refusal; no reading fits is the + * model-not-on-provider refusal. */ +function pickFromModels( + raw: string, + providerId: string, + models: readonly ZcodeRegistryModel[], + bridgeIdentity?: string, +): { ok: true; value: ResolvedSelection } | { ok: false; code: ZcodeBindingRefusal; message: string } { + const outcome = matchModelReadings(raw, models); + if (outcome.match) { + return { + ok: true, + value: { + selection: { + providerId, + modelId: outcome.match.modelId, + reasoningLevel: outcome.match.reasoningLevel, + }, + ...(bridgeIdentity ? { bridgeIdentity } : {}), + }, + }; + } + if (outcome.levelIssue) return { ok: false, code: "level-unsupported", message: outcome.levelIssue }; + return { + ok: false, + code: "model-not-on-provider", + message: modelNotOnProviderMessage(providerId), + }; +} + +/** + * Resolve an explicit picker id against the candidate tables. Model ids may + * contain `/` (openrouter-style ids), so the split between model id and + * trailing level is settled by validation: the shortest model reading whose + * provider+model exist wins, with a valid level preferred over an invalid + * one at the same reading. Personal providers that exist but are not + * usable (key removed / disabled / hidden / no enabled models) and account + * providers without their credential pair surface as `provider-no-access` + * rather than a stale-pick error, so the message can name the fix. + */ +function resolveExplicitPick( + raw: string, + input: { + registry: readonly ZcodeRegistryProvider[]; + accounts: readonly AccountCandidate[]; + personals: readonly PersonalCandidate[]; + credentials: Record; + }, +): { ok: true; value: ResolvedSelection } | { ok: false; code: ZcodeBindingRefusal; message: string } { + const decoded = decodeZcodeModelChoice(raw); + if (!decoded) { + return { + ok: false, + code: "model-not-on-provider", + message: + `ZCode: the saved model choice "${raw}" is stale or malformed (expected "/[/]"). Re-scan agents in Settings and pick a model again, then retry.`, + }; + } + const providerId = decoded.providerId; + + // Prefer the usable registry: its models already passed the enabled + // overlays and its account entries proved their credential pair. + const registryEntry = input.registry.find((p) => p.providerId === providerId); + if (registryEntry) { + return pickFromModels(raw, providerId, registryEntry.models); + } + + // Known account provider, not in the registry: its credential pair did + // not hold. An api-key key alone is still bindable — the identity bridge + // below completes the pair — but no key at all (or a stale identity + // value) is the provider-no-access refusal. + const account = input.accounts.find((a) => a.providerId === providerId); + if (account) { + const apiKeyEntry = findCodingPlanApiKeyEntry(input.credentials, providerId); + if (!apiKeyEntry) { + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: no saved Coding Plan credential for ${providerId}. Open ZCode, log in to your Coding Plan, then retry.`, + }; + } + const pair = accountPair(input.credentials, providerId); + if (pair) { + // Paired but not in the registry: every model was disabled. Nothing + // bindable remains on the provider. + return { + ok: false, + code: "model-not-on-provider", + message: modelNotOnProviderMessage(providerId), + }; + } + const identityRaw = input.credentials[`account-provider:${providerId}:identity`]; + if (typeof identityRaw === "string" && identityRaw.trim()) { + // An identity value exists but names no api-key key: add-only means + // the bridge must not overwrite it, and binding anyway would let the + // CLI drop the provider and silently reroute. Refuse instead. + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: the saved identity for ${providerId} is stale (its api-key credential is missing). Log in again in ZCode (zcode login), then retry.`, + }; + } + return pickFromModels(raw, providerId, account.models, apiKeyEntry.identity); + } + + // Known personal provider, not usable right now (key removed / disabled / + // hidden / no enabled models left). + const personal = input.personals.find((p) => p.providerId === providerId); + if (personal) { + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: provider ${personal.providerName} (${providerId}) has no usable model right now (missing API key, disabled, or every model disabled). Open ZCode, restore its key or enable a model, then rescan and retry.`, + }; + } + + return { + ok: false, + code: "model-not-on-provider", + message: + `ZCode: the saved model choice "${raw}" does not match any provider in your ZCode config. Re-scan agents in Settings and pick a model again, then retry.`, + }; +} + +function modelNotOnProviderMessage(providerId: string): string { + return `ZCode: that model is not available on ${providerId}. Re-scan agents in Settings and pick another model, then retry.`; +} + +/** + * Try every reading of the id's tail as (modelId, level?): shortest model + * first, level-less reading last. A reading whose model exists but whose + * level is unsupported is remembered so the refusal can list the supported + * levels; a fully valid reading wins immediately. + */ +function matchModelReadings( + raw: string, + models: readonly ZcodeRegistryModel[], +): { + match: { modelId: string; reasoningLevel: string | null } | null; + levelIssue: string | null; +} { + const segments = raw.split("/"); + let levelIssue: string | null = null; + // Level readings: level = segments[k], model = segments[1..k); then the + // level-less reading model = segments[1..n). + for (let k = 1; k <= segments.length - 1; k++) { + const modelId = segments.slice(1, k).join("/"); + const level = segments[k]!; + const entry = models.find((m) => m.modelId === modelId); + if (!entry) continue; + if (entry.levels.includes(level)) { + return { match: { modelId: entry.modelId, reasoningLevel: level }, levelIssue: null }; + } + levelIssue = + `ZCode: reasoning level "${level}" is not supported for ${entry.modelId} on this provider (supported: ${ + entry.levels.join(", ") || "none" + }). Re-scan agents in Settings and pick again.`; + } + const levelless = segments.slice(1).join("/"); + const entry = models.find((m) => m.modelId === levelless); + if (entry) return { match: { modelId: entry.modelId, reasoningLevel: null }, levelIssue: null }; + return { match: null, levelIssue }; +} + +/** + * Resolve + write the per-turn binding: the refusal chain, then one temp + * artifact — a clone of the user's personal provider config with the exact + * `config.defaultModelSelection` — plus, for an account target, the one + * sanctioned credential write (the identity bridge; see module header). + * + * `model` semantics: undefined / "default" binds the registry default — + * the CLI-login/TUI-written `defaultModelSelection` while it still selects + * something in the registry (legacy provider ids migrated), else the first + * registry provider's first model at its highest level. Any other string + * is an explicit pick and must validate against the registry, else the + * matching refusal fires; a bare or stale id never silently reroutes. + */ +export function prepareZcodeModelBinding(opts: { + cjsPath: string; + /** Picker id: undefined | "default" | "/[/]". */ + model?: string; + /** Directory for the temp artifacts, the invoke layer's attach temp dir. + * Its existing cleanup removes everything with the prompt file. */ + attachDir: string; + personalConfigPath?: string; + credentialsPath?: string; + cacheRoot?: string; +}): ZcodeBindingResult { + const catalog = resolveZcodeCatalogFile({ cjsPath: opts.cjsPath, cacheRoot: opts.cacheRoot }); + if (!catalog) { + return { + ok: false, + code: "catalog-unreadable", + message: + "ZCode: the bundled provider catalog could not be read (neither next to the install nor in the runtime cache). Reinstall ZCode or point ZCODE_BIN at a full install, then retry.", + }; + } + const personal = readJsonFile(opts.personalConfigPath ?? defaultZcodePersonalConfigPath()); + if (!isRecord(personal) || !isRecord(personal.config)) { + return { + ok: false, + code: "provider-config-unreadable", + message: + "ZCode: the provider config (~/.zcode/v2/provider_config.json) is missing or malformed. Open the ZCode GUI once so it can initialize the config, then retry.", + }; + } + const credentials = readJsonFile(opts.credentialsPath ?? defaultZcodeCredentialsPath()); + const credentialRecord = isRecord(credentials) ? credentials : {}; + const tables = readRuleTables(catalog.data, personal); + const accounts = accountCandidates(catalog.data, tables); + const personals = personalCandidates(catalog.data, personal, tables); + const registry = enumerateZcodeRegistry(catalog.data, personal, credentialRecord); + + let resolved: ResolvedSelection; + if (opts.model === undefined || opts.model === "default") { + const noUsableProvider = (): ZcodeBindingResult => ({ + ok: false, + code: "no-usable-provider", + message: + "ZCode: no usable model provider. Open ZCode, log in or add a model provider, then retry.", + }); + if (registry.length === 0) return noUsableProvider(); + const def = resolveZcodeDefaultSelection(registry, personal); + // registry[0].models[0] exists by construction, so def is never null here. + if (!def) return noUsableProvider(); + const account = registry.find((p) => p.providerId === def.providerId && p.kind === "account"); + resolved = { + selection: { + providerId: def.providerId, + modelId: def.modelId, + reasoningLevel: def.reasoningLevel, + }, + ...(account?.accountIdentity ? { bridgeIdentity: account.accountIdentity } : {}), + }; + } else { + const outcome = resolveExplicitPick(opts.model, { + registry, + accounts, + personals, + credentials: credentialRecord, + }); + if (!outcome.ok) return outcome; + resolved = outcome.value; + } + + // Identity bridge on the real credential store: account targets only, + // add-only (an existing identity value is never modified), re-checked + // every turn. A default/registry account target already proved its pair, + // so this writes nothing; an explicit account pick whose identity key + // vanished between scans gets it back — without the key the headless + // registry drops the provider and silently reroutes the turn. + const bridgeIdentity = resolved.bridgeIdentity; + if (bridgeIdentity) { + const identityKey = `account-provider:${resolved.selection.providerId}:identity`; + const existing = credentialRecord[identityKey]; + if (typeof existing !== "string" || !existing.trim()) { + const credentialsPath = opts.credentialsPath ?? defaultZcodeCredentialsPath(); + try { + const bridged = { ...credentialRecord, [identityKey]: bridgeIdentity }; + const tmpPath = `${credentialsPath}.html-anything-tmp`; + writeFileSync(tmpPath, JSON.stringify(bridged, null, 2), "utf8"); + renameSync(tmpPath, credentialsPath); + } catch (err) { + return { + ok: false, + code: "provider-no-access", + message: `ZCode: failed to add the Coding Plan identity credential to ${credentialsPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + } + } + + // Deep-clone via JSON round-trip: the source is freshly-parsed JSON anyway. + const clone = JSON.parse(JSON.stringify(personal)) as { config?: Record }; + clone.config = { + ...clone.config, + defaultModelSelection: resolved.selection.reasoningLevel + ? { + providerId: resolved.selection.providerId, + modelId: resolved.selection.modelId, + options: { reasoningLevel: resolved.selection.reasoningLevel }, + } + : { + providerId: resolved.selection.providerId, + modelId: resolved.selection.modelId, + }, + }; + mkdirSync(opts.attachDir, { recursive: true }); + const clonePath = join(opts.attachDir, "provider-config.clone.json"); + try { + writeFileSync(clonePath, JSON.stringify(clone, null, 2), "utf8"); + } catch (err) { + return { + ok: false, + code: "provider-config-unreadable", + message: `ZCode: failed to write the temp provider-config clone: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + return { + ok: true, + selection: resolved.selection, + clonePath, + builtinCatalogPath: catalog.path, + }; +} + +/** True when the environment already carries both provider-config vars; that + * is the user's zero-code reroute. The invoke layer then passes them through + * untouched and skips its own binding. */ +export function zcodeProviderEnvPairSet(env: Readonly): boolean { + const personal = env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]?.trim(); + const builtin = env[ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]?.trim(); + return Boolean(personal && builtin); +} + +/** Convenience: the paired env values for a prepared binding. */ +export function zcodeBindingEnv( + result: Extract, +): Record { + return { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: result.clonePath, + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: result.builtinCatalogPath, + }; +} + +/** Variable names the ZCode desktop host exports into every terminal it + * spawns. A dev server started from one of those terminals inherits them, + * and the invoke layer then passes the host's provider config through + * instead of binding itself, masking real first-run behaviour. */ +const ZCODE_HOST_INJECTED_PROVIDER_ENV_VARS: readonly string[] = [ + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, +]; + +/** Remove the host-injected provider-config vars from `env` (mutating) and + * return the names that were actually present. Dev-server startup calls + * this once so the server behaves as if launched from a clean terminal; + * when neither var is set it is a no-op. */ +export function stripZcodeHostProviderEnv(env: NodeJS.ProcessEnv): string[] { + const removed: string[] = []; + for (const name of ZCODE_HOST_INJECTED_PROVIDER_ENV_VARS) { + if (env[name] !== undefined) { + delete env[name]; + removed.push(name); + } + } + return removed; +} diff --git a/next/next.config.ts b/next/next.config.ts index e9ffa308..e16eae6d 100644 --- a/next/next.config.ts +++ b/next/next.config.ts @@ -1,7 +1,22 @@ import type { NextConfig } from "next"; +import { PHASE_DEVELOPMENT_SERVER } from "next/constants"; +import { stripZcodeHostProviderEnv } from "./src/lib/agents/zcode-model-binding"; const nextConfig: NextConfig = { /* config options here */ }; -export default nextConfig; +export default (phase: string): NextConfig => { + if (phase === PHASE_DEVELOPMENT_SERVER) { + // Strip the provider-config env vars the ZCode desktop host injects + // into every terminal it spawns, so a dev run matches a clean machine + // (rationale in stripZcodeHostProviderEnv). Log what was removed. + const stripped = stripZcodeHostProviderEnv(process.env); + if (stripped.length > 0) { + console.info( + `[next.config] stripped ZCode host-injected provider env vars: ${stripped.join(", ")}`, + ); + } + } + return nextConfig; +}; diff --git a/next/src/components/convert-chip.tsx b/next/src/components/convert-chip.tsx index 21d3b9d5..daeaa837 100644 --- a/next/src/components/convert-chip.tsx +++ b/next/src/components/convert-chip.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect } from "react"; import { useStore, selectActiveTask } from "@/lib/store"; import { useT } from "@/lib/i18n"; import { useConvert } from "@/lib/use-convert"; +import { resolveAgentModel } from "@/lib/agent-models"; /** * Floating chip pinned to the editor / preview divider — the single Convert @@ -24,7 +25,8 @@ export function ConvertChip() { const t = useT(); const agentInfo = agents.find((a) => a.id === agent); - const model = agent ? agentModels[agent] ?? "default" : "default"; + // A stale persisted pick falls back to Default and is never sent on. + const model = agent ? resolveAgentModel(agentInfo?.models, agentModels[agent]) : "default"; const canConvert = !!agent && !!content.trim() && status !== "running" && !agentInfo?.unsupported; diff --git a/next/src/components/settings-modal.tsx b/next/src/components/settings-modal.tsx index 75060ef5..9d538ec2 100644 --- a/next/src/components/settings-modal.tsx +++ b/next/src/components/settings-modal.tsx @@ -10,6 +10,7 @@ import { } from "@/lib/store"; import { useT, type DictKey } from "@/lib/i18n"; import { refreshTemplates } from "@/lib/templates"; +import { isStaleModelChoice, resolveAgentModel } from "@/lib/agent-models"; type Props = { onClose: () => void; initialSection?: SectionId }; @@ -32,6 +33,7 @@ const PROTOCOL_KEY: Record = { @@ -53,6 +55,7 @@ const VENDOR_GRADIENT: Record = { Kilo: "from-[#16a34a] to-[#0d9488]", Mistral: "from-[#fb923c] to-[#ef4444]", Qoder: "from-[#0891b2] to-[#7c3aed]", + "Z.AI": "from-[#3b5bfd] to-[#9d7bff]", }; export function SettingsModal({ onClose, initialSection = "agent" }: Props) { @@ -184,14 +187,25 @@ function AgentSection() { }; useEffect(() => { - if (!agents.length) load(); + // Refresh on every mount: ZCode's plan chips and readiness change + // with the GUI state, so a list cached from a previous mount would be + // stale (the same reasoning as the not-ready amber state). + load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const installed = useMemo(() => agents.filter((a) => a.available), [agents]); const missing = useMemo(() => agents.filter((a) => !a.available), [agents]); const selectedAgent = installed.find((a) => a.id === selected); - const selectedModelId = selected ? agentModels[selected] ?? "default" : "default"; + // Resolve the persisted pick against the (freshly scanned) list: a + // stale id renders Default active and shows the revert notice, and is never + // sent to the agent. + const selectedModelId = selected + ? resolveAgentModel(selectedAgent?.models, agentModels[selected]) + : "default"; + const staleModelPick = selectedAgent + ? isStaleModelChoice(selectedAgent.models, agentModels[selectedAgent.id]) + : false; return (
@@ -242,6 +256,7 @@ function AgentSection() { setAgentModel(selectedAgent.id, id)} /> )} @@ -349,11 +364,18 @@ function AgentCard({ const t = useT(); const proto = PROTOCOL_KEY[agent.protocol]; const gradient = VENDOR_GRADIENT[agent.vendor] ?? "from-[var(--ink)] to-[var(--ink-soft)]"; + // Installed is not the same as ready (ZCode). Amber ring + badge + + // one-line guidance, so the user learns it without hitting Generate. + const notReady = agent.available && agent.ready === false; return (
{agent.vendor} · {t(proto.key)}
+ {notReady && agent.notReadyReason && ( +
+ {t("agent.notReady.noUsableProvider")} +
+ )} {agent.path && (
void; }) { const t = useT(); @@ -419,13 +454,26 @@ function ModelPicker({
- {t("model.defaultHint.prefix")} - - --model - - {t("model.defaultHint.suffix")} + {agent.protocol === "argv-attach" ? ( + // ZCode has no --model flag, so the hint must be truthful + // about what Default does, not describe the sibling CLIs' flag. + t("model.defaultHint.zcode") + ) : ( + <> + {t("model.defaultHint.prefix")} + + --model + + {t("model.defaultHint.suffix")} + + )}
+ {stalePick && ( +
+ {t("model.staleNotice")} +
+ )}
{models.map((m) => { const active = m.id === modelId; @@ -831,8 +879,8 @@ function MarketplaceSection() { }), ); // Drop the in-memory template registry cache AND push the fresh list - // to every mounted `useTemplates` consumer — the picker switches over - // immediately, no page reload. Failing here just means the picker + // to every mounted `useTemplates` consumer, so the picker switches over + // immediately with no page reload. Failing here just means the picker // keeps the previous list; the install itself already succeeded. await refreshTemplates().catch(() => undefined); await load(); diff --git a/next/src/components/toolbar.tsx b/next/src/components/toolbar.tsx index ba941ebc..8c66cf67 100644 --- a/next/src/components/toolbar.tsx +++ b/next/src/components/toolbar.tsx @@ -2,6 +2,7 @@ import { useStore } from "@/lib/store"; import { useT } from "@/lib/i18n"; +import { resolveAgentModel } from "@/lib/agent-models"; import { TemplatePicker } from "./template-picker"; import { ExportMenu } from "./export-menu"; import { LayoutModeToggle } from "./layout-mode-toggle"; @@ -26,7 +27,8 @@ export function Toolbar({ const t = useT(); const agentInfo = agents.find((a) => a.id === agent); - const model = agent ? agentModels[agent] ?? "default" : "default"; + // A stale persisted pick falls back to Default and is never sent on. + const model = agent ? resolveAgentModel(agentInfo?.models, agentModels[agent]) : "default"; return (
= { stdin: { key: "protocol.stdin", tone: "ok" }, @@ -10,6 +11,7 @@ const PROTOCOL_KEY: Record = { @@ -85,6 +87,10 @@ const VENDOR_HINT: Record = { gradient: "from-[#0891b2] to-[#7c3aed]", install: "brew tap qoder/cli && brew install qodercli · qodercli login", }, + "Z.AI": { + gradient: "from-[#3b5bfd] to-[#9d7bff]", + install: "install the ZCode desktop app · open it & log in once", + }, }; type Props = { onClose: () => void }; @@ -129,7 +135,14 @@ export function WelcomeModal({ onClose }: Props) { const installed = agents.filter((a) => a.available); const missing = agents.filter((a) => !a.available); const selectedAgent = installed.find((a) => a.id === selected); - const selectedModelId = selected ? agentModels[selected] ?? "default" : "default"; + // Resolve the persisted pick against the scanned list: a stale id + // renders Default active with the revert notice, and never reaches the agent. + const selectedModelId = selected + ? resolveAgentModel(selectedAgent?.models, agentModels[selected]) + : "default"; + const staleModelPick = selectedAgent + ? isStaleModelChoice(selectedAgent.models, agentModels[selectedAgent.id]) + : false; const canEnter = !!selectedAgent && !selectedAgent.unsupported; const confirm = () => { @@ -227,6 +240,7 @@ export function WelcomeModal({ onClose }: Props) { setAgentModel(selectedAgent.id, id)} /> )} @@ -372,10 +386,13 @@ function AgentCard({ function ModelPicker({ agent, modelId, + stalePick = false, onPick, }: { agent: AgentInfo; modelId: string; + /** The persisted pick is gone from the list; render the revert notice. */ + stalePick?: boolean; onPick: (id: string) => void; }) { const t = useT(); @@ -399,11 +416,25 @@ function ModelPicker({
- {t("model.defaultHint.prefix")} - --model - {t("model.defaultHint.suffix")} + {agent.protocol === "argv-attach" ? ( + // ZCode has no --model flag, so the generic "--model" hint would + // be wrong here; show what Default actually does instead + // (mirrors the settings picker). + t("model.defaultHint.zcode") + ) : ( + <> + {t("model.defaultHint.prefix")} + --model + {t("model.defaultHint.suffix")} + + )}
+ {stalePick && ( +
+ {t("model.staleNotice")} +
+ )}
{models.map((m) => { const active = m.id === modelId; diff --git a/next/src/lib/__tests__/agent-models.test.ts b/next/src/lib/__tests__/agent-models.test.ts new file mode 100644 index 00000000..3ac8feca --- /dev/null +++ b/next/src/lib/__tests__/agent-models.test.ts @@ -0,0 +1,48 @@ +// Persisted model-choice resolution: a stale pick falls back to +// "default" everywhere it is read (send paths + pickers), while a +// not-yet-loaded agent list passes picks through untouched. +import { describe, expect, it } from "vitest"; +import { isStaleModelChoice, resolveAgentModel } from "../agent-models"; + +const MODELS = [ + { id: "default", label: "Default (ZCode GUI plan)" }, + { id: "GLM-5.2/max", label: "GLM-5.2 (max)" }, + { id: "GLM-5.3/high", label: "GLM-5.3 (high)" }, +]; + +describe("resolveAgentModel", () => { + it("a pick still offered by the list is used verbatim", () => { + expect(resolveAgentModel(MODELS, "GLM-5.3/high")).toBe("GLM-5.3/high"); + }); + + it("a stale pick (list loaded, id gone) falls back to default", () => { + expect(resolveAgentModel(MODELS, "GLM-5.2/high")).toBe("default"); + // e.g. a bare id persisted by an older UI + expect(resolveAgentModel(MODELS, "GLM-5.2")).toBe("default"); + }); + + it("undefined / empty / 'default' picks resolve to default", () => { + expect(resolveAgentModel(MODELS, undefined)).toBe("default"); + expect(resolveAgentModel(MODELS, "")).toBe("default"); + expect(resolveAgentModel(MODELS, "default")).toBe("default"); + }); + + it("an unloaded list (undefined/empty models) passes the pick through — never silently drops it", () => { + expect(resolveAgentModel(undefined, "sonnet")).toBe("sonnet"); + expect(resolveAgentModel([], "sonnet")).toBe("sonnet"); + }); +}); + +describe("isStaleModelChoice", () => { + it("true only for a non-default pick missing from a loaded list", () => { + expect(isStaleModelChoice(MODELS, "GLM-5.2/high")).toBe(true); + expect(isStaleModelChoice(MODELS, "GLM-5.2/max")).toBe(false); + expect(isStaleModelChoice(MODELS, "default")).toBe(false); + expect(isStaleModelChoice(MODELS, undefined)).toBe(false); + }); + + it("an unloaded list is never stale (the pick cannot be judged yet)", () => { + expect(isStaleModelChoice(undefined, "sonnet")).toBe(false); + expect(isStaleModelChoice([], "sonnet")).toBe(false); + }); +}); diff --git a/next/src/lib/__tests__/use-convert.test.ts b/next/src/lib/__tests__/use-convert.test.ts new file mode 100644 index 00000000..ad19bd5f --- /dev/null +++ b/next/src/lib/__tests__/use-convert.test.ts @@ -0,0 +1,29 @@ +// The zcode failure gate, extracted from use-convert.ts so the +// consumer-layer contract (error event / non-zero exit → red error state, +// zcode-only) is unit-testable without driving the full fetch/SSE loop. +import { describe, expect, it } from "vitest"; +import { zcodeTurnFailed } from "../use-convert"; + +describe("zcodeTurnFailed (zcode gate)", () => { + it("zcode: an error event fails the turn (refused binding / spawn failure)", () => { + expect(zcodeTurnFailed("zcode", "error", { message: "ZCode: no model plan found…" })).toBe(true); + }); + + it("zcode: a non-zero exit fails the turn (e.g. gateway 400 → exit 1)", () => { + expect(zcodeTurnFailed("zcode", "done", { code: 1 })).toBe(true); + }); + + it("zcode: exit 0 / delta / meta / stderr events do NOT fail the turn", () => { + expect(zcodeTurnFailed("zcode", "done", { code: 0 })).toBe(false); + expect(zcodeTurnFailed("zcode", "done", {})).toBe(false); // code unknown + expect(zcodeTurnFailed("zcode", "delta", { text: "" })).toBe(false); + expect(zcodeTurnFailed("zcode", "meta", { key: "model" })).toBe(false); + expect(zcodeTurnFailed("zcode", "stderr", { text: "warning" })).toBe(false); + }); + + it("other agents keep the historical behavior — no event fails their turns", () => { + expect(zcodeTurnFailed("claude", "error", { message: "boom" })).toBe(false); + expect(zcodeTurnFailed("claude", "done", { code: 1 })).toBe(false); + expect(zcodeTurnFailed("codex", "done", { code: 2 })).toBe(false); + }); +}); diff --git a/next/src/lib/agent-models.ts b/next/src/lib/agent-models.ts new file mode 100644 index 00000000..2b48651d --- /dev/null +++ b/next/src/lib/agent-models.ts @@ -0,0 +1,42 @@ +/** + * Persisted model-choice resolution. + * + * `agentModels` in the store persists the LAST picked model id per agent. + * Pick lists are dynamic: ZCode's plan chips change with the GUI plan, and + * curated lists change between releases, so a persisted id can outlive the + * list it came from. Every read path (send paths + pickers) resolves the + * persisted choice through here so a stale id falls back to Default + * consistently and is never sent to the agent (where ZCode would refuse + * the whole turn with a "stale choice" error rather than silently reroute). + */ +export type PickableModel = { id: string }; + +/** + * The model id to actually use: the persisted pick when it is still offered, + * else "default". An unknown/empty model list (agents not loaded yet) passes + * the pick through unchanged; a not-yet-loaded list must not silently drop + * a valid pick. + */ +export function resolveAgentModel( + models: readonly PickableModel[] | undefined, + picked: string | undefined, +): string { + const id = picked?.trim(); + if (!id || id === "default") return "default"; + if (!models || models.length === 0) return id; + return models.some((m) => m.id === id) ? id : "default"; +} + +/** + * True when the persisted pick exists but is no longer offered by the loaded + * list; the pickers render the "reverted to Default" notice from this. + */ +export function isStaleModelChoice( + models: readonly PickableModel[] | undefined, + picked: string | undefined, +): boolean { + const id = picked?.trim(); + if (!id || id === "default") return false; + if (!models || models.length === 0) return false; + return !models.some((m) => m.id === id); +} diff --git a/next/src/lib/agents/__tests__/__fixtures__/zcode-builtin.fixture.json b/next/src/lib/agents/__tests__/__fixtures__/zcode-builtin.fixture.json new file mode 100644 index 00000000..a29f7413 --- /dev/null +++ b/next/src/lib/agents/__tests__/__fixtures__/zcode-builtin.fixture.json @@ -0,0 +1,2507 @@ +{ + "schemaVersion": 1, + "revision": 30, + "config": { + "providerConfigRules": { + "templateRules": [ + { + "templateId": "bigmodel-api", + "templateNameMap": { + "zh-CN": "BigModel Coding Plan", + "en-US": "BigModel Coding Plan" + }, + "config": { + "access": { + "type": "zhipu-coding-plan-api-key", + "apiKeyManagementUrl": "https://bigmodel.cn/coding-plan/personal/overview" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "templateId": "bigmodel-standard-api", + "templateNameMap": { + "zh-CN": "BigModel API", + "en-US": "BigModel API" + }, + "config": { + "access": { + "type": "api-key", + "apiKeyManagementUrl": "https://bigmodel.cn/usercenter/proj-mgmt/apikeys" + }, + "api": { + "type": "openai-chat-completions", + "baseUrl": "https://open.bigmodel.cn/api/paas/v4" + }, + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash", + "GLM-5V-Turbo", + "GLM-5.1", + "GLM-5.1-Highspeed", + "GLM-5", + "GLM-5-Turbo", + "GLM-4.7", + "GLM-4.7-FlashX", + "GLM-4.7-Flash", + "GLM-4.6", + "GLM-4.5-Air", + "GLM-4.5", + "GLM-4.6V", + "GLM-4.6V-Flash", + "GLM-4.6V-FlashX", + "GLM-4.1V-Thinking-FlashX", + "GLM-4.1V-Thinking-Flash", + "GLM-4-FlashX-250414", + "GLM-4-Flash-250414", + "GLM-4V-Flash", + "codegeex-4", + "charglm-4", + "emohaa" + ], + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "templateId": "deepseek", + "templateNameMap": { + "zh-CN": "DeepSeek", + "en-US": "DeepSeek" + }, + "config": { + "access": { + "type": "api-key", + "apiKeyManagementUrl": "https://platform.deepseek.com/api_keys" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.deepseek.com/anthropic" + }, + "builtinModelIds": [ + "deepseek-flash", + "deepseek-v4-pro" + ], + "logo": { + "type": "builtin", + "key": "deepseek" + } + } + } + ], + "providerRules": [ + { + "providerId": "account:zai-individual-coding-plan", + "providerName": "Z.AI Individual Coding Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "individual-coding-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.z.ai/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:zai-team-coding-plan", + "providerName": "Z.AI Team Coding Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "team-coding-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://api.z.ai/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:zai-start-plan", + "providerName": "Start Plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3-Flash", + "GLM-5.2", + "GLM-5-Turbo" + ], + "access": { + "type": "zhipu-account", + "mode": "start-plan", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/zcode-plan/anthropic" + }, + "logo": { + "type": "builtin", + "key": "start-plan" + } + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "providerName": "BigModel Individual Coding Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "individual-coding-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "providerName": "BigModel Team Coding Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "access": { + "type": "zhipu-account", + "mode": "team-coding-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://open.bigmodel.cn/api/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + }, + { + "providerId": "account:bigmodel-start-plan", + "providerName": "Start Plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3-Flash", + "GLM-5.2", + "GLM-5-Turbo" + ], + "access": { + "type": "zhipu-account", + "mode": "start-plan", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/zcode-plan/anthropic" + }, + "logo": { + "type": "builtin", + "key": "start-plan" + } + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "providerName": "Z.AI Idle plan", + "config": { + "group": "zai-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "visibility": "hidden", + "access": { + "type": "zhipu-account", + "mode": "off-peak", + "accountType": "zai" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/off-peak/anthropic" + }, + "logo": { + "type": "builtin", + "key": "zai" + } + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "providerName": "BigModel Idle plan", + "config": { + "group": "bigmodel-family", + "builtinModelIds": [ + "GLM-5.3", + "GLM-5.3-Flash" + ], + "visibility": "hidden", + "access": { + "type": "zhipu-account", + "mode": "off-peak", + "accountType": "bigmodel" + }, + "api": { + "type": "anthropic-messages", + "baseUrl": "https://zcode.z.ai/api/v1/off-peak/anthropic" + }, + "logo": { + "type": "builtin", + "key": "bigmodel" + } + } + } + ] + }, + "modelConfigRules": { + "modelRules": [ + { + "modelMatch": ".*", + "config": { + "enabled": true, + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsText": true, + "supportsImage": false, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "outputFormat": { + "supportsText": true + }, + "supportsToolCall": true, + "supportsJsonSchemaOutput": false, + "supportsNativeWebSearch": false, + "supportsMidConversationSystem": false, + "requiresMfjsToolSchema": false + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + }, + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ], + "map": "{}" + } + } + } + }, + { + "modelMatch": ".*\\[1m\\]", + "config": { + "properties": { + "contextWindow": 1000000 + } + } + }, + { + "modelMatch": ".*(?:ox-alpha|glm-x-preview-f|x-preview-f-free).*", + "config": { + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*glm-5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*GLM-5\\.2(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*GLM-5-Turbo(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.3(?:-flash)?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.3-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsPdf": true + } + } + } + }, + { + "modelMatch": ".*glm-5\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5\\.1-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*glm-5v-turbo(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.7-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.5-air(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.6v-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.1v-thinking-flashx(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4\\.1v-thinking-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*glm-4-flashx-250414(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*glm-4-flash-250414(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*glm-4v-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 16384, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 1024 + } + } + } + }, + { + "modelMatch": ".*codegeex-4(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*charglm-4(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 4096 + } + } + } + }, + { + "modelMatch": ".*emohaa(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 4096 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.6(?:-(?:sol|terra|luna))?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.3-codex(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-6-astra(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-pro(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1050000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-mini(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*gpt-5\\.4-nano(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 400000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*claude-(?:fable|opus|sonnet)-5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*claude-haiku-4[.-]5(?:-20251001)?(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 200000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 64000 + } + } + } + }, + { + "modelMatch": ".*claude-(?:fable|mythos)-5[.-]1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-4\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 500000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-build-0\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 256000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*grok-4\\.3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false, + "supportsAudio": false, + "supportsPdf": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 128000 + } + } + } + }, + { + "modelMatch": ".*kimi-k3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1048576, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*k3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1048576, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*k3-256k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.7-code(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.6(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*kimi-k2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 98304 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-8k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-32k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 32768, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-128k(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-8k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 8192, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-32k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 32768, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*moonshot-v1-128k-vision-preview(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 131072, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M3(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.7(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.7-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.5-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.1(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*MiniMax-M2\\.1-highspeed(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 204800, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 32000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*deepseek-v4[.-]1-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "low", + "high", + "max" + ] + }, + "maxOutputTokens": { + "max": 384000 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.8-(?:max|flash)(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "low", + "medium", + "xhigh" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.5-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.5-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3-max(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen3-vl-plus(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 32768 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-max(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-plus(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": true + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.6-plus(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.6-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + }, + { + "modelMatch": ".*qwen3\\.7-max-2026-06-08(?:[:/\\[].*)?", + "config": { + "properties": { + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + } + } + }, + { + "modelMatch": ".*qwen3\\.8-omni-flash(?:-20\\d{2}(?:-?\\d{2}){2})?(?:[:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 65536, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsAudio": true + }, + "supportsJsonSchemaOutput": false + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "maxOutputTokens": { + "max": 16384 + } + } + } + }, + { + "modelMatch": ".*mimo-v2\\.5(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true, + "supportsAudio": true + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2\\.5-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false, + "supportsAudio": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-pro(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 1000000, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-omni(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": true, + "supportsVideo": true + } + }, + "optionSpecs": { + "maxOutputTokens": { + "max": 131072 + } + } + } + }, + { + "modelMatch": ".*mimo-v2-flash(?:[.\\-:/\\[].*)?", + "config": { + "properties": { + "contextWindow": 262144, + "inputFormat": { + "supportsImage": false, + "supportsVideo": false + } + }, + "optionSpecs": { + "reasoningLevel": { + "values": [ + "disabled", + "enabled" + ] + }, + "maxOutputTokens": { + "max": 65536 + } + } + } + } + ], + "templateModelRules": [ + { + "modelId": "GLM-5-Turbo", + "templateId": "bigmodel-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.3", + "templateId": "bigmodel-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3-Flash", + "templateId": "bigmodel-api", + "config": { + "enabled": true + } + }, + { + "modelId": "deepseek-v4-pro", + "templateId": "deepseek", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5.3-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": true + } + }, + { + "modelId": "GLM-5V-Turbo", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.1", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5.1-Highspeed", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-5-Turbo", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.7-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.5-Air", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.5", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.6V-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.1V-Thinking-FlashX", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4.1V-Thinking-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4-FlashX-250414", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4-Flash-250414", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "GLM-4V-Flash", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "codegeex-4", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "charglm-4", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "modelId": "emohaa", + "templateId": "bigmodel-standard-api", + "config": { + "enabled": false + } + }, + { + "templateId": "deepseek", + "modelId": "deepseek-flash", + "config": { + "enabled": true + } + } + ], + "builtinProviderModelRules": [ + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-individual-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-team-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-start-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-individual-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-team-coding-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5.2", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-start-plan", + "modelId": "GLM-5-Turbo", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:zai-offpeak-idle-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "modelId": "GLM-5.3-Flash", + "config": { + "enabled": true + } + }, + { + "providerId": "account:bigmodel-offpeak-idle-plan", + "modelId": "GLM-5.3", + "config": { + "enabled": true + } + } + ] + } + } +} diff --git a/next/src/lib/agents/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl b/next/src/lib/agents/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl new file mode 100644 index 00000000..4ba5cf74 --- /dev/null +++ b/next/src/lib/agents/__tests__/__fixtures__/zcode-right-binding.stdout.jsonl @@ -0,0 +1,2 @@ +{"eventId":"ca7955e8-76b3-478c-a00d-ccfb2ad41bc7","payload":{"response":"PROBE_OK","tokenCount":131409,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":131405,"outputTokens":4,"totalTokens":131409,"cacheReadTokens":256,"cacheWriteTokens":0,"reasoningTokens":0,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":24160,"resultType":"success","cacheStats":{"totalMessages":8,"cachedMessages":7,"lastCacheHit":true,"cacheReadTokens":256}},"seq":35,"sessionId":"sess_b23229a4-fd9d-4d40-a44d-9cb6a0b9ce69","timestamp":1790060846779,"traceId":"50bd4735-2e5c-4e46-8aa2-647940965779","turnId":"turn_c5647dfb-cf22-4740-b713-6e6441ea496b","type":"turn.completed"} +{"type":"result","sessionId":"sess_b23229a4-fd9d-4d40-a44d-9cb6a0b9ce69","traceId":"50bd4735-2e5c-4e46-8aa2-647940965779","turnId":"turn_c5647dfb-cf22-4740-b713-6e6441ea496b","response":"PROBE_OK","usage":{"source":"provider","modelRequestCount":1,"inputTokens":131405,"outputTokens":4,"totalTokens":131409,"cacheReadTokens":256,"cacheWriteTokens":0,"reasoningTokens":0,"webFetchRequests":0,"webSearchRequests":0},"eventCount":12,"projection":{"status":"idle","turnCount":1,"totalTokenCount":131409,"contextUsed":131409,"contextWindow":200000}} diff --git a/next/src/lib/agents/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt b/next/src/lib/agents/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt new file mode 100644 index 00000000..753e5a06 --- /dev/null +++ b/next/src/lib/agents/__tests__/__fixtures__/zcode-wrong-binding.stderr.txt @@ -0,0 +1,11 @@ +ProviderBusinessError: 1 validation error: {'type': 'literal_error', 'loc': ('body', 'reasoning_effort'), 'msg': "Input should be 'low', 'medium' or 'high'", 'input': 'max', 'ctx': {'expected': "'low', 'medium' or 'high'"}} File "/home/user/envs/ray/lib/python3.10/site-packages/sglang/srt/entrypoints/http_server.py", line 1141, in openai_v1_chat_completions POST /v1/chat/completions [{'type': 'literal_error', 'loc': ('body', 'reasoning_effort'), 'msg': "Input should be 'low', 'medium' or 'high'", 'input': 'max', 'ctx': {'expected': "'low', 'medium' or 'high'"}}] + at detectProviderBusinessError (C:\Program Files\ZCode\resources\glm\zcode.cjs:2047:10939) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Object.fetch (C:\Program Files\ZCode\resources\glm\zcode.cjs:2047:10435) + at async postToApi (C:\Program Files\ZCode\resources\glm\zcode.cjs:2028:19657) + at async OpenAICompatibleChatLanguageModel.doStream (C:\Program Files\ZCode\resources\glm\zcode.cjs:2038:6883) + code: 'PROVIDER_BUSINESS_ERROR', + isProviderBusinessError: true, + providerCode: 400, + providerId: '00000000-1111-4222-8333-444444444444', + providerKind: 'openai-compatible', diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts index 449277d9..7ad42080 100644 --- a/next/src/lib/agents/__tests__/argv.test.ts +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -219,3 +219,301 @@ describe("parseLine bob", () => { }); }); +describe("parseLine zcode (argv-attach stream-json)", () => { + // Real NDJSON lines captured from the installed ZCode CLI running + // `-p … --output-format stream-json --mode yolo` headless; the parser + // contract is pinned against these real bytes. The captured turns were + // plain-text replies, so the reasoning_delta / tool_call / tool.updated + // lines below reuse the real envelope (eventId/seq/sessionId/timestamp + // fields verbatim from the captures) around payload shapes pinned in the + // ZCode open-source contracts + // (apps/zcode-cli/packages/contracts/src/events/session.events.ts). + const SESSION = "sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2"; + const TURN = "turn_428dacfc-74fc-4056-9aac-785654cb17bc"; + const envelope = (type: string, payload: unknown, seq: number) => + JSON.stringify({ + eventId: `d20d9e75-af8b-4d70-8b12-750a984${String(seq).padStart(3, "0")}`, + payload, + seq, + sessionId: SESSION, + timestamp: 1790039046427, + traceId: "fac306de-bf37-4eff-94ca-eb8476962e3c", + turnId: TURN, + type, + }); + const TEXT_DELTA_LINE = (delta: string, seq: number) => + envelope( + "model.streaming", + { assistantMessageId: "msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462", delta, done: false, kind: "text_delta" }, + seq, + ); + const REASONING_DELTA_LINE = (delta: string, seq: number) => + envelope( + "model.streaming", + { assistantMessageId: "msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462", delta, done: false, kind: "reasoning_delta" }, + seq, + ); + + it("bridges model.streaming text_delta to a delta with the exact text", () => { + expect(parseLine("zcode", TEXT_DELTA_LINE("PRO", 22))).toEqual([ + { kind: "delta", text: "PRO" }, + ]); + expect(parseLine("zcode", TEXT_DELTA_LINE("_OK", 24))).toEqual([ + { kind: "delta", text: "_OK" }, + ]); + }); + + it("bridges model.streaming reasoning_delta to a thinking meta per fragment", () => { + expect(parseLine("zcode", REASONING_DELTA_LINE("先把布局", 40))).toEqual([ + { kind: "meta", key: "thinking", value: "先把布局" }, + ]); + expect(parseLine("zcode", REASONING_DELTA_LINE("定成两栏", 41))).toEqual([ + { kind: "meta", key: "thinking", value: "定成两栏" }, + ]); + }); + + it("drops every non-content streaming kind (start / text_start / text_end / reasoning_start / reasoning_end / tool_input_* / finish)", () => { + for (const kind of [ + "start", + "text_start", + "text_end", + "reasoning_start", + "reasoning_end", + "tool_input_start", + "tool_input_delta", + "tool_input_end", + "finish", + ]) { + const line = `{"payload":{"delta":"","done":false,"kind":"${kind}"},"seq":1,"type":"model.streaming"}`; + expect(parseLine("zcode", line)).toEqual([]); + } + }); + + it("drops a text_delta / reasoning_delta with an empty delta string (no zero-length events)", () => { + expect(parseLine("zcode", TEXT_DELTA_LINE("", 21))).toEqual([]); + expect(parseLine("zcode", REASONING_DELTA_LINE("", 42))).toEqual([]); + }); + + it("rescues HTML from a Write tool_call input (file_path + content, ZCode's Write tool shape)", () => { + const line = envelope( + "model.streaming", + { + assistantMessageId: "msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462", + delta: "", + done: false, + kind: "tool_call", + toolCallId: "tc_1", + toolName: "Write", + input: { file_path: "C:\\ws\\output.html", content: "rescued" }, + }, + 50, + ); + expect(parseLine("zcode", line)).toEqual([ + { kind: "html", text: "rescued" }, + ]); + }); + + it("surfaces a non-write tool_call as a status line, not HTML", () => { + const line = envelope( + "model.streaming", + { + kind: "tool_call", + toolCallId: "tc_2", + toolName: "WebSearch", + input: { query: "css grid" }, + }, + 51, + ); + expect(parseLine("zcode", line)).toEqual([ + { kind: "meta", key: "status", value: "调用工具 WebSearch" }, + ]); + }); + + it("emits no status line for a Write tool_call targeting a non-HTML path (no rescue, no nameless noise)", () => { + // A Write to a .md sidecar rescues nothing (path filter), but the tool + // still has a name, so it surfaces as a status line like any other tool. + const line = envelope( + "model.streaming", + { + kind: "tool_call", + toolCallId: "tc_3", + toolName: "Write", + input: { file_path: "C:\\ws\\notes.md", content: "# notes" }, + }, + 52, + ); + expect(parseLine("zcode", line)).toEqual([ + { kind: "meta", key: "status", value: "调用工具 Write" }, + ]); + }); + + it("resolves the tool name of a tool.updated result via the tool_call map (调用工具 X / 工具 X 完成 pair)", () => { + const parse = makeParser("zcode"); + expect(parse( + envelope("model.streaming", { kind: "tool_call", toolCallId: "tc_9", toolName: "Bash", input: { command: "ls" } }, 60), + )).toEqual([{ kind: "meta", key: "status", value: "调用工具 Bash" }]); + // ToolCallResultPayload carries only toolCallId + result + duration; the + // name comes from the map filled by the tool_call event above. + expect(parse( + envelope("tool.updated", { kind: "result", toolCallId: "tc_9", result: { success: true, content: "file1\nfile2" }, duration: 120 }, 61), + )).toEqual([{ kind: "meta", key: "status", value: "工具 Bash 完成" }]); + }); + + it("emits nothing for a nameless tool.updated result (a bare ✓ with no context is noise)", () => { + expect(parseLine( + "zcode", + envelope("tool.updated", { kind: "result", toolCallId: "tc_unknown", result: { success: true, content: "…" }, duration: 5 }, 62), + )).toEqual([]); + }); + + it("drops the non-result tool.updated kinds (scheduled / started / progress / error / batch)", () => { + for (const kind of ["scheduled", "started", "progress", "error", "batch"]) { + const line = envelope("tool.updated", { kind, toolCallId: "tc_1", toolName: "Bash" }, 63); + expect(parseLine("zcode", line)).toEqual([]); + } + }); + + it("drops the noise envelope lines verbatim-captured from a real turn", () => { + // session.titleUpdated + expect( + parseLine( + "zcode", + `{"eventId":"ba62ed88-8c55-4431-83ca-32a57b0b2395","payload":{"previousTitle":"","source":"first_input","title":"Reply with exactly the token: PROBE_OK"},"seq":1,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034226,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.titleUpdated"}`, + ), + ).toEqual([]); + // session.resumed + expect( + parseLine( + "zcode", + `{"eventId":"081fe5f4-0d82-493a-be2d-fb69dff0168f","payload":{"directory":"C:\\Users\\dev\\Git\\html-anything","interruptedToolCount":0,"messageCount":15,"partCount":31,"recoveredCompactTimelineCount":0,"recoveredSteerInputCount":0,"resumedTodoCount":0},"seq":2,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034228,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.resumed"}`, + ), + ).toEqual([]); + // session.updated, plugin hook descriptor (SessionStart frame) + expect( + parseLine( + "zcode", + `{"eventId":"704f78d3-0c2d-4566-afd8-c94f6f0906c6","payload":{"descriptor":{"clientVisible":true,"commandDisplay":"node \\"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1/hooks/session-start-seen-skills.mjs\\"","executionMode":"foreground","executionType":"command","pluginId":"vercel@claude-plugins-official","pluginName":"vercel","sourceKind":"plugin","sourcePath":"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1\\hooks\\hooks.json","timeoutMs":60000},"hookEventName":"SessionStart","hookIndex":0,"hookCount":4,"startedAt":1790039034230},"seq":3,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034230,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.updated"}`, + ), + ).toEqual([]); + }); + + it("drops turn.started (cold-boot info line is a log-panel concern, not a parser one)", () => { + expect( + parseLine( + "zcode", + `{"eventId":"2bf2eb12-0196-4448-9546-fa9bdbbdfd2e","payload":{"executionStartedAt":1790039035583.7253,"turnNumber":7,"input":"Reply with exactly the token: PROBE_OK","messageId":"msg_mubz0vk6_f9084d10-58cb-4fb2-b226-98373bfe8cec","foregroundExecutionId":"runtime_command_1","queryId":"query_cf24b136-7c0f-4afc-a83d-ced3669c001a"},"seq":11,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039035590,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.started"}`, + ), + ).toEqual([]); + }); + + it("maps turn.completed to exactly one usage (snake_case) + duration_ms + result meta (verbatim-captured real line)", () => { + expect( + parseLine( + "zcode", + `{"eventId":"af34daea-c765-4083-9fa5-2f88eba64782","payload":{"response":"PROBE_OK","tokenCount":134374,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":11229,"resultType":"success","cacheStats":{"totalMessages":23,"cachedMessages":22,"lastCacheHit":true,"cacheReadTokens":64}},"seq":33,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046811,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.completed"}`, + ), + ).toEqual([ + { + kind: "meta", + key: "usage", + value: { + input_tokens: 134370, + output_tokens: 4, + cache_read_input_tokens: 64, + cache_creation_input_tokens: 0, + }, + }, + { kind: "meta", key: "duration_ms", value: 11229 }, + { kind: "meta", key: "result", value: "success" }, + ]); + }); + + it("maps a cancelled turn.completed resultType through the same result meta", () => { + const line = envelope( + "turn.completed", + { response: "", tokenCount: 0, usage: { inputTokens: 10, outputTokens: 0 }, toolCallCount: 0, duration: 900, resultType: "cancelled" }, + 34, + ); + expect(parseLine("zcode", line)).toEqual([ + { kind: "meta", key: "usage", value: { input_tokens: 10, output_tokens: 0 } }, + { kind: "meta", key: "duration_ms", value: 900 }, + { kind: "meta", key: "result", value: "cancelled" }, + ]); + }); + + it("maps the bare result terminator to a session meta only; its usage is the same cumulative numbers turn.completed already reported", () => { + expect( + parseLine( + "zcode", + `{"type":"result","sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","response":"PROBE_OK","usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"eventCount":13,"projection":{"status":"idle","turnCount":1,"totalTokenCount":134374,"contextUsed":134374,"contextWindow":200000}}`, + ), + ).toEqual([{ kind: "meta", key: "session", value: "sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2" }]); + }); + + it("maps turn.failed (verbatim-captured real line) to an error part carrying the provider message", () => { + expect( + parseLine( + "zcode", + `{"eventId":"d66c6937-6bd7-4f3d-a38a-43d252a1e052","payload":{"error":{"type":"unknown_error","attribution":{"retryable":false},"code":"CONFIGURATION_ERROR","message":"Select a model before continuing","detail":"Model creation failed","underlyingErrorMessage":"Select a model before continuing","stack":"Error: Model creation failed\\n at createCoreError (C:\\\\Program Files\\\\ZCode\\\\resources\\\\glm\\\\zcode.cjs:108:2685)\\n at createTurnFailureError (C:\\\\Program Files\\\\ZCode\\\\resources\\\\glm\\\\zcode.cjs:14575:1578)"},"turnPhase":"model_creation"},"seq":1,"sessionId":"sess_b154be6b-7148-4098-8d80-6f717ce7bdb8","timestamp":1790041331523,"traceId":"f2f332d5-1d5c-4f95-baee-6c4ecedfd155","turnId":"turn_28ed2805-a161-450f-a426-9f3182f003bb","type":"turn.failed"}`, + ), + ).toEqual([{ kind: "error", message: "Select a model before continuing" }]); + }); + + it("falls back to a generic message when turn.failed carries no error.message", () => { + expect( + parseLine("zcode", envelope("turn.failed", { error: { type: "unknown_error" }, turnPhase: "run" }, 2)), + ).toEqual([{ kind: "error", message: "zcode turn failed" }]); + }); + + it("drops non-JSON lines and future event types without throwing", () => { + expect(parseLine("zcode", "not json at all")).toEqual([]); + expect(parseLine("zcode", "")).toEqual([]); + expect( + parseLine("zcode", `{"type":"hook.progress","payload":{"foo":1}}`), + ).toEqual([]); + }); + + it("makeParser accumulates streamed text across lines", () => { + const parse = makeParser("zcode"); + const parts = [ + ...parse(TEXT_DELTA_LINE("PRO", 22)), + ...parse(TEXT_DELTA_LINE("BE", 23)), + ...parse(TEXT_DELTA_LINE("_OK", 24)), + ]; + expect(parts).toEqual([ + { kind: "delta", text: "PRO" }, + { kind: "delta", text: "BE" }, + { kind: "delta", text: "_OK" }, + ]); + }); + + it("a full real-turn line sequence yields exactly: deltas, then usage/duration/result, then session (noise: zero output, usage: exactly once)", () => { + // Ordered slice of the real capture: 2 hook-noise lines, the 7 real + // model.streaming lines of the turn (start, text_start, three + // text_delta, text_end, finish), turn.completed, and the result terminator. + const parse = makeParser("zcode"); + const out = [ + ...parse(`{"eventId":"ba62ed88-8c55-4431-83ca-32a57b0b2395","payload":{"previousTitle":"","source":"first_input","title":"Reply with exactly the token: PROBE_OK"},"seq":1,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034226,"type":"session.titleUpdated"}`), + ...parse(`{"payload":{"descriptor":{"clientVisible":true},"hookEventName":"SessionStart","hookIndex":0,"hookCount":4,"startedAt":1790039034230},"seq":3,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034230,"type":"session.updated"}`), + ...parse(TEXT_DELTA_LINE("", 20)), + ...parse(TEXT_DELTA_LINE("", 21)), + ...parse(TEXT_DELTA_LINE("PRO", 22)), + ...parse(TEXT_DELTA_LINE("BE", 23)), + ...parse(TEXT_DELTA_LINE("_OK", 24)), + ...parse(TEXT_DELTA_LINE("", 25)), + ...parse(TEXT_DELTA_LINE("", 26)), + ...parse(`{"payload":{"response":"PROBE_OK","tokenCount":134374,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":11229,"resultType":"success"},"seq":33,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046811,"type":"turn.completed"}`), + ...parse(`{"type":"result","sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","response":"PROBE_OK","usage":{"inputTokens":134370,"outputTokens":4,"cacheReadTokens":64,"cacheWriteTokens":0},"eventCount":13}`, + ), + ]; + expect(out).toEqual([ + { kind: "delta", text: "PRO" }, + { kind: "delta", text: "BE" }, + { kind: "delta", text: "_OK" }, + { kind: "meta", key: "usage", value: { input_tokens: 134370, output_tokens: 4, cache_read_input_tokens: 64, cache_creation_input_tokens: 0 } }, + { kind: "meta", key: "duration_ms", value: 11229 }, + { kind: "meta", key: "result", value: "success" }, + { kind: "meta", key: "session", value: "sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2" }, + ]); + }); +}); diff --git a/next/src/lib/agents/__tests__/detect.test.ts b/next/src/lib/agents/__tests__/detect.test.ts index 7911d272..e6ea50c2 100644 --- a/next/src/lib/agents/__tests__/detect.test.ts +++ b/next/src/lib/agents/__tests__/detect.test.ts @@ -1,20 +1,79 @@ +// @vitest-environment node +// +// Runtime detection tests stub process.env (ZCODE_BIN) and process.platform; +// the happy-dom default env provides a synthetic process that does not reflect +// stubs into the real process.env that resolveZcodeBin()/resolveOnPath() read. +// The node environment is correct for a discovery/PATH unit test and matches +// how cli runs the same code path (see invoke.test.ts). import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { homedir } from "node:os"; +import { join, posix } from "node:path"; -const { existsSyncMock } = vi.hoisted(() => ({ +const { existsSyncMock, readFileSyncMock, bindingPickerState } = vi.hoisted(() => ({ existsSyncMock: vi.fn((_path?: string) => false), + // discoverZcodeAppImage reads the XDG `.desktop` entry. Mocked so + // the discover tests don't touch disk; the pure parser (parseZcodeDesktopExec) + // is exercised separately with string fixtures. + readFileSyncMock: vi.fn((_path?: string, _enc?: string) => ""), + // The ZCode detect data (registry picker + ready state) comes from + // zcode-model-binding. Mutated per test below; the module itself + // (registry enumeration, catalog resolution, level tables) is tested in + // its own file. + bindingPickerState: { + ready: true as boolean, + reason: null as null | "no-usable-provider", + options: [ + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ], + defaultChoice: { + providerId: "account:bigmodel-individual-coding-plan", + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "high", + } as null | { + providerId: string; + providerName: string; + modelId: string; + reasoningLevel: string | null; + }, + }, })); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); - return { ...actual, existsSync: existsSyncMock }; + return { ...actual, existsSync: existsSyncMock, readFileSync: readFileSyncMock }; }); -import { detectAgents } from "../detect"; +// Detect reads the registry picker via the binding module's one composed +// call. Mock only that; everything else stays the real implementation (keep +// the module's exports real so this mock keeps working). +vi.mock("../zcode-model-binding", async () => { + const actual = await vi.importActual< + typeof import("../zcode-model-binding") + >("../zcode-model-binding"); + return { + ...actual, + readZcodePickerState: () => bindingPickerState, + }; +}); + +import { + AGENTS, + DEFAULT_MODEL, + defaultZcodeCjsPaths, + defaultZcodeElectronExePaths, + detectAgents, + discoverZcodeAppImage, + parseZcodeDesktopExec, + resolveZcodeBin, + resolveZcodeNodeBin, + type AgentDef, + type AgentProtocol, +} from "../detect"; -function findAgent( - agents: ReturnType, - id: string, -) { +function findAgent(agents: ReturnType, id: string) { const agent = agents.find((a) => a.id === id); if (!agent) throw new Error(`Agent with id "${id}" not found`); return agent; @@ -23,10 +82,595 @@ function findAgent( beforeEach(() => { existsSyncMock.mockReset(); existsSyncMock.mockReturnValue(false); + readFileSyncMock.mockReset(); + readFileSyncMock.mockReturnValue(""); + // Reset the binding mock to the ready-registry fixture (tests mutate it). + bindingPickerState.ready = true; + bindingPickerState.reason = null; + bindingPickerState.options = [ + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ]; + bindingPickerState.defaultChoice = { + providerId: "account:bigmodel-individual-coding-plan", + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "high", + }; }); +// Real value captured once at module load; restore after each platform-stubbing +// test so later tests run against the host platform again. +const REAL_PLATFORM = process.platform; afterEach(() => { vi.unstubAllEnvs(); + Object.defineProperty(process, "platform", { + value: REAL_PLATFORM, + configurable: true, + }); +}); + +// ZCode install discovery probes platform-specific absolute paths. Stub the +// platform so a single host (win32 here) can exercise the macOS/Linux branches. +function stubPlatform(p: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: p, configurable: true }); +} + +describe("AgentProtocol type surface", () => { + it("accepts \"argv-attach\" as a union member", () => { + // Compile-time proof: if "argv-attach" is missing from the union this + // assignment fails typecheck. The runtime echo keeps the test meaningful. + const p: AgentProtocol = "argv-attach"; + expect(p).toBe("argv-attach"); + }); + + it("AgentDef accepts optional binArgs?: string[]", () => { + const withBinArgs: AgentDef = { + id: "type-probe", + label: "Type Probe", + bin: "type-probe", + vendor: "probe", + protocol: "argv-attach", + binArgs: [""], + fallbackModels: [DEFAULT_MODEL], + }; + expect(withBinArgs.binArgs).toEqual([""]); + }); + + it("AgentDef.binArgs is optional (existing entries omit it)", () => { + // binArgs stays optional so existing AgentDef literals keep typechecking + // unchanged. Check the whole array, not a sample, so a future entry that + // accidentally sets a required-looking binArgs is caught here. ZCode + // is the one intentional exception; its node-script spawn needs it. + for (const def of AGENTS) { + if (def.id === "zcode") continue; + expect(def.binArgs).toBeUndefined(); + } + expect(AGENTS.find((a) => a.id === "zcode")?.binArgs).toEqual([ + "", + ]); + }); + + it("detectAgents still surfaces the existing protocol set", () => { + // Regression guard: the "argv-attach" union member must not flip the + // unsupported flag for any of the protocols already in use. + const agents = detectAgents(); + const protocols = new Set(agents.map((a) => a.protocol)); + for (const expected of [ + "stdin", + "argv", + "argv-message", + "argv-attach", + "acp", + "pi-rpc", + ] as const) { + expect(protocols.has(expected)).toBe(true); + } + }); +}); + +describe("ZCode agent registration", () => { + it("AGENTS contains a zcode entry with the spec fields", () => { + const def = AGENTS.find((a) => a.id === "zcode"); + expect(def).toBeDefined(); + expect(def!.label).toBe("ZCode"); + expect(def!.vendor).toBe("Z.AI"); + expect(def!.envOverride).toBe("ZCODE_BIN"); + expect(def!.protocol).toBe("argv-attach"); + expect(def!.bin).toBe("node"); + // binArgs holds the node-script leading argv: + // just the resolved .cjs (argv[1] must be the cjs; there is no + // subcommand anymore). The placeholder is filled at + // detect time from resolveZcodeBin(); the literal here is the sentinel on + // the AgentDef. + expect(def!.binArgs).toEqual([""]); + }); + + it("detectAgents() returns available=true when resolveZcodeBin() hits", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.path).toBe("/opt/zcode/zcode.cjs"); + // resolveZcodeNodeBin() now returns + // `string` (not `string | null`), so detectAgents() no longer coalesces + // to the literal `node`. resolvedBin is the node driver the spawn will + // use, a non-empty string (the resolver never returns null). We don't + // pin the exact path here: existsSync admits only the .cjs in this + // scenario, so the resolver falls through to its terminal Electron-exe + // candidate, whose exact value is an install-layout detail covered by + // the dedicated resolveZcodeNodeBin cases below. + expect(typeof zcode.resolvedBin).toBe("string"); + expect((zcode.resolvedBin ?? "").length).toBeGreaterThan(0); + expect(zcode.protocol).toBe("argv-attach"); + // argv-attach is implemented (generic invoke trunk) and must not be + // flagged unsupported, unlike the acp/pi-rpc family. + expect(zcode.unsupported).toBeUndefined(); + }); + + it("detectAgents() returns available=false when no install is found (no throw)", () => { + stubPlatform("darwin"); + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(false); + expect(zcode.path).toBeUndefined(); + expect(zcode.resolvedBin).toBeUndefined(); + expect(zcode.protocol).toBe("argv-attach"); + expect(zcode.unsupported).toBeUndefined(); + }); + + // Detection reports the ACTUAL node driver when one resolves. On a clean + // host where only the ZCode Electron exe exists, resolvedBin must point at + // it, reconciling the detection layer with the invoke layer's + // resolveZcodeNodeBin() (no conflicting assumption that detect says "node" + // while invoke spawns ZCode.exe). + it("detectAgents() reports the resolved Electron exe as resolvedBin when no system node", () => { + vi.stubEnv("ZCODE_BIN", "/resolved/zcode.cjs"); + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "C:\\Program Files\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => + p === "/resolved/zcode.cjs" || + p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.path).toBe("/resolved/zcode.cjs"); + // The Electron exe is what the spawn will actually use as "node". + expect(zcode.resolvedBin).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); + + // The picker mirrors the registry (catalog + personal config + credential + // key names via zcode-model-binding), encoded as provider-namespaced + // "/[/]" ids with the service name in the + // label, with a Default entry first. The Default chip LABEL names what + // Default resolves to right now (the resolved default service, model, and + // level), so it is never a stale promise. + it("picker models are the registry's provider-namespaced chips; Default chip labelled with the resolved default", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.ready).toBe(true); + expect(zcode.notReadyReason).toBeUndefined(); + expect(zcode.models).toEqual([ + { id: "default", label: "Default (BigModel Individual Coding Plan GLM-5.3, high)" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/low", label: "GLM-5.3 (BigModel Individual Coding Plan, low)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "account:bigmodel-individual-coding-plan/GLM-5.3/high", label: "GLM-5.3 (BigModel Individual Coding Plan, high)", providerId: "account:bigmodel-individual-coding-plan" }, + { id: "deepseek/deepseek-flash/max", label: "deepseek-flash (DeepSeek, max)", providerId: "deepseek" }, + ]); + }); + + // When the default choice cannot be resolved (empty registry at the label + // read), the Default chip falls back to the generic label rather than + // promising a model it cannot name. + it("unresolvable default choice → generic Default (ZCode default) label", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + bindingPickerState.defaultChoice = null; + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.models[0]).toEqual({ id: "default", label: "Default (ZCode default)" }); + expect(zcode.models.length).toBe(4); // chips still listed + }); + + // Installed is not ready (no usable keyed provider): ready:false + the + // single reason the settings card renders amber; the registry holds + // nothing, so the picker keeps the static floor. + it("not-ready install: ready=false + single reason surfaced, static floor picker", () => { + vi.stubEnv("ZCODE_BIN", "/opt/zcode/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/zcode/zcode.cjs"); + bindingPickerState.ready = false; + bindingPickerState.reason = "no-usable-provider"; + bindingPickerState.options = []; + bindingPickerState.defaultChoice = null; + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(true); + expect(zcode.ready).toBe(false); + expect(zcode.notReadyReason).toBe("no-usable-provider"); + expect(zcode.models).toEqual([{ id: "default", label: "Default (ZCode default)" }]); + }); + + // When ZCode is NOT available (no install found), the picker still surfaces + // the static floor. The config read is gated on the same availability as + // the rest of ZCode's detection, so an unavailable install doesn't crash + // the picker with a spurious config read. + it("picker falls back to the static floor when ZCode is unavailable (config read gated)", () => { + stubPlatform("darwin"); + existsSyncMock.mockReturnValue(false); + + const agents = detectAgents(); + const zcode = findAgent(agents, "zcode"); + + expect(zcode.available).toBe(false); + expect(zcode.models).toEqual([{ id: "default", label: "Default (ZCode default)" }]); + }); + + it("resolveZcodeBin() honours ZCODE_BIN override", () => { + // Sanity-check the discovery function the detect branch depends on, so a + // regression in resolveZcodeBin surfaces here rather than as a confusing + // available=false in the test above. + vi.stubEnv("ZCODE_BIN", "/custom/zcode.cjs"); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/custom/zcode.cjs"); + + expect(resolveZcodeBin()).toBe("/custom/zcode.cjs"); + }); +}); + +// Resolve the node binary that drives `node -p …` for +// an EXTERNAL caller. html-anything is an external process; on a clean Windows +// host `where node` finds nothing (only ZCode.exe exists). resolveZcodeBin() +// locates the .cjs bundle; this layer locates the NODE the .cjs runs under. +// Strategy: prefer real node on PATH; fall back to the ZCode Electron +// executable (ZCode.exe / ZCode / AppImage) driven under +// ELECTRON_RUN_AS_NODE=1 by the spawn branch. No separate node.exe ships in +// the install tree. +describe("resolveZcodeNodeBin", () => { + it("ZCODE_NODE_BIN absolute path that exists wins over all defaults", () => { + vi.stubEnv("ZCODE_NODE_BIN", "C:\\custom\\node.exe"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => p === "C:\\custom\\node.exe"); + + expect(resolveZcodeNodeBin()).toBe("C:\\custom\\node.exe"); + }); + + it("ZCODE_NODE_BIN command name resolves on PATH when not absolute", () => { + const dir = "/opt/node-shim"; + const expected = join(dir, "my-node"); + vi.stubEnv("ZCODE_NODE_BIN", "my-node"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + it("prefers a real `node` on PATH over the Electron fallback", () => { + const dir = "/opt/realnode"; + const expected = join(dir, "node"); + vi.stubEnv("PATH", dir); + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === expected || p === "/opt/zcode/ZCode"); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + it("Windows: falls back to ZCode.exe next to the resolved .cjs install", () => { + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "C:\\Program Files\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + expect(resolveZcodeNodeBin()).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); + + it("Windows: honours a custom ZCODE_WINDOWS_APP_INSTALL_DIR for the .exe", () => { + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + stubPlatform("win32"); + existsSyncMock.mockImplementation((p) => p === "D:\\ZCode\\ZCode.exe"); + + expect(resolveZcodeNodeBin()).toBe("D:\\ZCode\\ZCode.exe"); + }); + + it("macOS: falls back to ZCode.app/Contents/MacOS/ZCode", () => { + stubPlatform("darwin"); + existsSyncMock.mockImplementation( + (p) => p === "/Applications/ZCode.app/Contents/MacOS/ZCode", + ); + + expect(resolveZcodeNodeBin()).toBe( + "/Applications/ZCode.app/Contents/MacOS/ZCode", + ); + }); + + it("Linux: falls back to the AppImage mount", () => { + stubPlatform("linux"); + const expected = `${homedir()}/Applications/ZCode.AppImage`; + existsSyncMock.mockImplementation((p) => p === expected); + + expect(resolveZcodeNodeBin()).toBe(expected); + }); + + // The Electron-exe fallback is the terminal + // step of the probe chain. detectAgents() reports zcode available only when + // zcode.cjs was found ⟺ ZCode is installed ⟺ the sibling Electron exe + // exists, so any caller reaching this code sees a hit here. The return type + // is therefore `string` (not `string | null`); this case pins that the + // fallback yields a non-empty path, and the type itself is the compile-time + // proof the old null outcome is gone. + it("returns a non-empty string when the Electron-exe fallback exists", () => { + stubPlatform("win32"); + existsSyncMock.mockImplementation( + (p) => p === "C:\\Program Files\\ZCode\\ZCode.exe", + ); + + const result = resolveZcodeNodeBin(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe("C:\\Program Files\\ZCode\\ZCode.exe"); + }); +}); + +// On Linux ZCode ships as a `.deb` or an AppImage. The AppImage is a single +// compressed file with zcode.cjs packed inside, unreachable without mounting; +// the adapter discovers it via the XDG .desktop entry ZCode writes on first +// GUI launch, not by guessing a version-less filename. The pure parser +// (parseZcodeDesktopExec) holds the parsing+validation logic; the discover +// function is a thin I/O glue over it. resolveZcodeBin / resolveZcodeNodeBin +// wire the discovery into the existing probe chain (step 3 on Linux). +describe("parseZcodeDesktopExec (.desktop Exec= parsing + validation)", () => { + it("extracts the AppImage path from a well-formed Exec= line", () => { + const desktop = [ + "[Desktop Entry]", + "Name=ZCode", + "Exec=/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage --no-sandbox %U", + "Icon=zcode", + "Type=Application", + ].join("\n"); + expect(parseZcodeDesktopExec(desktop)).toBe( + "/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage", + ); + }); + + it("strips surrounding quotes a launcher might write", () => { + const desktop = `[Desktop Entry]\nExec="/opt/ZCode.AppImage" %U\n`; + expect(parseZcodeDesktopExec(desktop)).toBe("/opt/ZCode.AppImage"); + }); + + it("preserves a quoted path that contains spaces (no truncation)", () => { + // A path with an interior space MUST be quoted per the freedesktop spec; + // splitting on whitespace first would truncate it at the space. This is + // the regression guard for the quote-aware tokenizer. + const desktop = `[Desktop Entry]\nExec="/home/user/My Apps/ZCode-3.7.5.AppImage" --no-sandbox %U\n`; + expect(parseZcodeDesktopExec(desktop)).toBe( + "/home/user/My Apps/ZCode-3.7.5.AppImage", + ); + }); + + it("returns null when no Exec= line is present", () => { + const desktop = `[Desktop Entry]\nName=ZCode\nIcon=zcode\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("does not match TryExec= (a different field)", () => { + const desktop = `[Desktop Entry]\nTryExec=/opt/ZCode.AppImage\nExec=/real/ZCode.AppImage\n`; + expect(parseZcodeDesktopExec(desktop)).toBe("/real/ZCode.AppImage"); + }); + + it("returns null for a malformed Exec= that is just a field code", () => { + const desktop = `[Desktop Entry]\nExec=%U\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("returns null for an empty Exec= value", () => { + const desktop = `[Desktop Entry]\nExec=\n`; + expect(parseZcodeDesktopExec(desktop)).toBeNull(); + }); + + it("returns null for empty content", () => { + expect(parseZcodeDesktopExec("")).toBeNull(); + }); +}); + +describe("discoverZcodeAppImage (.desktop I/O glue)", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("reads the .desktop entry and returns the parsed AppImage path", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode-3.7.5.AppImage --no-sandbox\n", + ); + + expect(discoverZcodeAppImage()).toBe("/opt/ZCode-3.7.5.AppImage"); + expect(readFileSyncMock).toHaveBeenCalledWith(desktopPath, "utf8"); + }); + + it("returns null when the .desktop file is missing", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + expect(discoverZcodeAppImage()).toBeNull(); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + + it("returns null (never throws) when readFileSync throws (e.g. EACCES)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + + expect(discoverZcodeAppImage()).toBeNull(); + }); + + it("returns null when the Exec= line is malformed", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === desktopPath); + readFileSyncMock.mockReturnValue("[Desktop Entry]\nName=ZCode\n"); + + expect(discoverZcodeAppImage()).toBeNull(); + }); +}); + +// On Linux, resolveZcodeBin() step 3 discovers the +// AppImage via the .desktop entry (priority ZCODE_BIN → `zcode` on PATH → +// .desktop Exec=). The returned path is the AppImage binary, NOT a .cjs; the +// .cjs lives inside the mount and is resolved post-mount in the invoke layer. +describe("resolveZcodeBin / resolveZcodeNodeBin on Linux", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("resolveZcodeBin() step 3 returns the .desktop AppImage when no env/PATH hit", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation( + (p) => p === desktopPath || p === "/opt/ZCode.AppImage", + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeBin()).toBe("/opt/ZCode.AppImage"); + }); + + it("resolveZcodeBin() returns null on Linux when the .desktop is missing (no silent guess)", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + expect(resolveZcodeBin()).toBeNull(); + }); + + it("resolveZcodeNodeBin() step 3 returns the discovered AppImage (Electron driver)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation( + (p) => p === desktopPath || p === "/opt/ZCode.AppImage", + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeNodeBin()).toBe("/opt/ZCode.AppImage"); + }); + + it("resolveZcodeNodeBin() terminal fallback is the canonical guess when discovery misses", () => { + stubPlatform("linux"); + existsSyncMock.mockReturnValue(false); + + // The contract is `string` (never null). When discovery misses (no .desktop, + // no env, no PATH) the canonical ~/Applications/ZCode.AppImage is returned + // so a hand-crafted caller's spawn surfaces a real ENOENT rather than a null. + expect(resolveZcodeNodeBin()).toBe( + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ); + }); + + it("defaultZcodeCjsPaths() returns the .deb .cjs candidate on Linux", () => { + stubPlatform("linux"); + expect(defaultZcodeCjsPaths()).toEqual([ + "/opt/ZCode/resources/glm/zcode.cjs", + ]); + }); + + it("defaultZcodeElectronExePaths() lists the .deb driver before the AppImage on Linux", () => { + stubPlatform("linux"); + expect(defaultZcodeElectronExePaths()).toEqual([ + "/opt/ZCode/zcode", + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ]); + }); + + it("defaultZcodeCjsPaths() Windows/macOS branches are unchanged", () => { + stubPlatform("win32"); + // A custom install dir yields both it and the hardcoded default; a value + // equal to the default would dedupe, so use a distinct dir here. + vi.stubEnv("ZCODE_WINDOWS_APP_INSTALL_DIR", "D:\\ZCode"); + expect(defaultZcodeCjsPaths()).toEqual([ + "D:\\ZCode\\resources\\glm\\zcode.cjs", + "C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs", + ]); + stubPlatform("darwin"); + expect(defaultZcodeCjsPaths()).toEqual([ + "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + ]); + }); +}); + +// The official .deb install lays a loose zcode.cjs at +// /opt/ZCode/resources/glm/zcode.cjs next to the Electron driver /opt/ZCode/zcode. +// This is the same loose-file layout as Windows/macOS, so the .deb is probed +// the same way: resolveZcodeBin() checks the on-disk .cjs BEFORE the AppImage +// (a loose .cjs cleanly distinguishes a .deb from an AppImage, whose .cjs is +// packed in the squashfs), and resolveZcodeNodeBin() probes /opt/ZCode/zcode +// so a .deb-only host without a system node can still drive the .cjs with no +// mount needed. +describe("resolveZcodeBin / resolveZcodeNodeBin Linux .deb install", () => { + const desktopPath = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", + ); + + it("resolveZcodeBin() returns the on-disk .deb .cjs before AppImage discovery", () => { + stubPlatform("linux"); + // The .deb .cjs exists. An AppImage is ALSO discoverable here; the on-disk + // .cjs probe must win so a .deb install never needlessly mounts. + existsSyncMock.mockImplementation( + (p) => + p === "/opt/ZCode/resources/glm/zcode.cjs" || + p === "/opt/ZCode.AppImage" || + p === desktopPath, + ); + readFileSyncMock.mockReturnValue( + "[Desktop Entry]\nExec=/opt/ZCode.AppImage\n", + ); + + expect(resolveZcodeBin()).toBe("/opt/ZCode/resources/glm/zcode.cjs"); + }); + + it("resolveZcodeNodeBin() returns /opt/ZCode/zcode when no system node (.deb-only)", () => { + stubPlatform("linux"); + existsSyncMock.mockImplementation((p) => p === "/opt/ZCode/zcode"); + + expect(resolveZcodeNodeBin()).toBe("/opt/ZCode/zcode"); + }); }); describe("detectAgents", () => { diff --git a/next/src/lib/agents/__tests__/invoke.test.ts b/next/src/lib/agents/__tests__/invoke.test.ts new file mode 100644 index 00000000..cb7008b0 --- /dev/null +++ b/next/src/lib/agents/__tests__/invoke.test.ts @@ -0,0 +1,1239 @@ +// @vitest-environment node +// +// This test mocks node:child_process and node:fs and drives real spawn-style +// child plumbing (EventEmitter + PassThrough). The default happy-dom env +// interferes with vi.mock on node: builtins; the node environment is correct +// for a spawn/stdio unit test and matches how cli runs the same code path. +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import path from "node:path"; +import { PassThrough, Writable } from "node:stream"; + +const { mockSpawn, existsSyncDelegate, mockMkdtempSync, mockWriteFileSync, mockRmSync, mockPrepareBinding, bindingOkResult } = vi.hoisted(() => { + return { + mockSpawn: vi.fn(), + existsSyncDelegate: vi.fn((p: string) => p === "/bin/sh"), + // argv-attach (zcode) prompt-delivery path: the invoke layer mkdtemps a + // temp dir, writes prompt.md into it, and rmSyncs the dir on every exit + // path. Mocked so the tests assert the write/cleanup contract without + // touching the real temp filesystem. + mockMkdtempSync: vi.fn((prefix: string) => `${prefix}TEST`), + mockWriteFileSync: vi.fn(), + mockRmSync: vi.fn(), + // The per-turn model binding boundary. invoke calls one function from + // the binding module; everything else in that module stays real (keep + // the module's exports real so this mock keeps working). The ok-result + // is the shape a real prepared binding returns; refusal tests swap + // `mockPrepareBinding.mockReturnValue`. + mockPrepareBinding: vi.fn(), + bindingOkResult: { + ok: true as const, + selection: { + providerId: "account:bigmodel-individual-coding-plan", + modelId: "GLM-5.2", + reasoningLevel: "high", + }, + clonePath: "/tmp/attach/provider-config.clone.json", + builtinCatalogPath: "/install/resources/config/provider/zcode-builtin.json", + }, + }; +}); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, spawn: mockSpawn }; +}); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: existsSyncDelegate, + mkdtempSync: mockMkdtempSync, + writeFileSync: mockWriteFileSync, + rmSync: mockRmSync, + }; +}); + +vi.mock("../zcode-model-binding", async () => { + const actual = await vi.importActual< + typeof import("../zcode-model-binding") + >("../zcode-model-binding"); + return { ...actual, prepareZcodeModelBinding: mockPrepareBinding }; +}); + +import { invokeAgent, type InvokeEvent } from "../invoke"; + +function makeFakeChild() { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + // Capture stdin writes so the argv-attach tests can prove the prompt is + // NOT piped to stdin (it travels in the --attach temp file). + const stdinWrites: string[] = []; + const stdin = new Writable({ + write(chunk: unknown, _enc: unknown, cb: () => void) { + stdinWrites.push(typeof chunk === "string" ? chunk : String(chunk)); + cb(); + }, + }); + + const child = Object.assign(new EventEmitter(), { + stdin, + stdout, + stderr, + pid: 99999, + }); + + return { child, stdout, stderr, stdin, stdinWrites }; +} + +async function collectStream( + stream: ReadableStream, +): Promise { + const events: InvokeEvent[] = []; + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) events.push(value); + } + return events; +} + +/** + * Script the fake AppImage mount child for the Linux self-mount tests. + * The real `AppImage --appimage-mount` prints the FUSE mount point to stdout + * (first line) and stays alive holding the mount. Here the mount point is + * written on a setTimeout(0) so the helper's stdout listener is attached + * before the data arrives (matching real async I/O). Pass `write: false` to + * simulate a mount that never produces a point (timeout / early-exit paths). + */ +function makeMountChild(mountPoint: string, write = true) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdin = new Writable({ write(_c: unknown, _e: unknown, cb: () => void) { cb(); } }); + const child = Object.assign(new EventEmitter(), { + stdin, + stdout, + stderr, + pid: 7777, + }); + if (write) { + setTimeout(() => { stdout.write(`${mountPoint}\n`); }, 0); + } + return { child, stdout, stderr }; +} + +const BIN_OVERRIDE = "/bin/sh"; + +// Real NDJSON lines captured from the installed ZCode CLI (3.14.1 desktop +// bundle, zcode 0.16.9) running `-p … --output-format stream-json --mode +// yolo` headless. The adapter's parser and invoke plumbing are pinned against +// these real bytes, captured from a real CLI run, not a hand-written sketch. +// Includes the full noise vocabulary observed on a +// single turn: session.titleUpdated / session.resumed / session.updated +// (plugin hook descriptors), turn.started, the model.streaming kinds, the +// turn.completed envelope, and the bare result terminator line. +const ZCODE_STREAM_JSON = String.raw`{"eventId":"ba62ed88-8c55-4431-83ca-32a57b0b2395","payload":{"previousTitle":"","source":"first_input","title":"Reply with exactly the token: PROBE_OK"},"seq":1,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034226,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.titleUpdated"} +{"eventId":"081fe5f4-0d82-493a-be2d-fb69dff0168f","payload":{"directory":"C:\\Users\\dev\\Git\\html-anything","interruptedToolCount":0,"messageCount":15,"partCount":31,"recoveredCompactTimelineCount":0,"recoveredSteerInputCount":0,"resumedTodoCount":0},"seq":2,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034228,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.resumed"} +{"eventId":"704f78d3-0c2d-4566-afd8-c94f6f0906c6","payload":{"descriptor":{"clientVisible":true,"commandDisplay":"node \"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1/hooks/session-start-seen-skills.mjs\"","executionMode":"foreground","executionType":"command","pluginId":"vercel@claude-plugins-official","pluginName":"vercel","sourceKind":"plugin","sourcePath":"C:\\Users\\dev\\.zcode\\cli\\plugins\\cache\\claude-plugins-official\\vercel\\0.45.1\\hooks\\hooks.json","timeoutMs":60000},"hookEventName":"SessionStart","hookIndex":0,"hookCount":4,"hookInvocationId":"b5547636-0e41-4c9b-b95d-32941f50b6aa","hookRunId":"4699df10-ce5a-42c2-bfde-f7213e1a95a6","hookSource":"plugin.vercel@claude-plugins-official.SessionStart.1.0","matcher":"startup|resume|clear|compact","startedAt":1790039034230},"seq":3,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039034230,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","type":"session.updated"} +{"eventId":"2bf2eb12-0196-4448-9546-fa9bdbbdfd2e","payload":{"executionStartedAt":1790039035583.7253,"turnNumber":7,"input":"Reply with exactly the token: PROBE_OK","messageId":"msg_mubz0vk6_f9084d10-58cb-4fb2-b226-98373bfe8cec","foregroundExecutionId":"runtime_command_1","queryId":"query_cf24b136-0c0f-4afc-a83d-ced3669c001a"},"seq":11,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039035590,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.started"} +{"eventId":"dbd9ae00-db4f-4710-ae4b-c4b66ee1600f","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"start"},"seq":20,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046426,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"2dee4cce-ca91-4521-95e3-b4b68ceaf5d39","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"text_start"},"seq":21,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046426,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"d20d9e75-af8b-4d70-8b12-750a98416233","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"PRO","done":false,"kind":"text_delta"},"seq":22,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046427,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"70e217a9-3d1a-49d0-9049-8411f226dff5","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"BE","done":false,"kind":"text_delta"},"seq":23,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046428,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"43884742-953d-4b45-8496-7e5773bc9996","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"_OK","done":false,"kind":"text_delta"},"seq":24,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046428,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"005c69cb-1785-447f-8f42-6ab57ac111a8","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":false,"kind":"text_end"},"seq":25,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046429,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"84413ce1-a3ef-49af-864b-4a81605d8e2f","payload":{"assistantMessageId":"msg_mubz0ze1_3fd06d2d-4911-4f90-8aa7-db4660374462","delta":"","done":true,"kind":"finish"},"seq":26,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046429,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"model.streaming"} +{"eventId":"af34daea-c765-4083-9fa5-2f88eba64782","payload":{"response":"PROBE_OK","tokenCount":134374,"usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"toolCallCount":0,"historyRoundCount":1,"duration":11229,"resultType":"success","cacheStats":{"totalMessages":23,"cachedMessages":22,"lastCacheHit":true,"cacheReadTokens":64}},"seq":33,"sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","timestamp":1790039046811,"traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","type":"turn.completed"} +{"type":"result","sessionId":"sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2","traceId":"fac306de-bf37-4eff-94ca-eb8476962e3c","turnId":"turn_428dacfc-74fc-4056-9aac-785654cb17bc","response":"PROBE_OK","usage":{"source":"provider","modelRequestCount":1,"inputTokens":134370,"outputTokens":4,"totalTokens":134374,"cacheReadTokens":64,"cacheWriteTokens":0,"reasoningTokens":1,"webFetchRequests":0,"webSearchRequests":0},"eventCount":13,"projection":{"status":"idle","turnCount":1,"totalTokenCount":134374,"contextUsed":134374,"contextWindow":200000}}`; + +function zcodeLines(): string[] { + return ZCODE_STREAM_JSON.split("\n").filter((l) => l.trim()); +} + +describe("invokeAgent zcode CLI one-shot (argv-attach)", () => { + beforeEach(() => { + // resolveZcodeBin() must return the test .cjs so the binArgs sentinel + // resolves to it (invoke-time substitution). The resolved node driver is + // an absolute `.exe`-style path, so the spawn takes the DIRECT (no-shell) + // Windows path and argv passes verbatim on every host platform. + vi.stubEnv("ZCODE_BIN", "/resolved/zcode.cjs"); + // The host may carry the GUI-inherited provider-config pair (running the + // tests inside a ZCode-spawned terminal exports ZCODE_PERSONAL/_BUILTIN_ + // PROVIDER_CONFIG_FILE to children). Force it absent so the + // binding-active tests are deterministic on every host; the passthrough + // test stubs the pair back on. + delete process.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE; + delete process.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE; + existsSyncDelegate.mockImplementation((p: string) => + p === "/resolved/node.exe" || + p === "/resolved/zcode.cjs" || + p === "/bin/sh", + ); + mockSpawn.mockReset(); + mockMkdtempSync.mockClear(); + mockWriteFileSync.mockClear(); + mockRmSync.mockClear(); + // Default happy-path binding (refusal tests override the return). + mockPrepareBinding.mockReset(); + mockPrepareBinding.mockReturnValue(bindingOkResult); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + existsSyncDelegate.mockImplementation((p: string) => p === "/bin/sh"); + }); + + const attachDir = () => mockMkdtempSync.mock.results[0]?.value as string; + const attachFile = () => path.join(attachDir(), "prompt.md"); + // Fake-timer tests drive start() to completion through microtasks only + // (process.nextTick is NOT faked); the same helper the mount describe + // uses for its 5s-timeout test. + const flushMicrotasks = async (iterations = 200) => { + for (let i = 0; i < iterations; i++) { + await Promise.resolve(); + await new Promise((r) => process.nextTick(r)); + } + }; + + it("spawns `node -p --attach --output-format stream-json --mode yolo` and reports start.argv", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stdout.write(`${zcodeLines().join("\n")}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [spawnBin, spawnArgv, spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + Record, + ]; + // Absolute `.exe` bin → DIRECT spawn on win32 (no cmd.exe), plain spawn + // elsewhere: argv elements verbatim on every host, so spaced install + // paths survive with no quoting games. + expect(spawnBin).toBe("/resolved/node.exe"); + expect(spawnOpts.shell ?? false).toBeFalsy(); + // argv[0] is the resolved .cjs (argv[1] of the real process; a bare + // executable would be rejected by Node as an arg), never `app-server`. + expect(spawnArgv[0]).toBe("/resolved/zcode.cjs"); + expect(spawnArgv).not.toContain("app-server"); + const pIdx = spawnArgv.indexOf("-p"); + expect(pIdx).toBe(1); + // `-p` holds the fixed short guide, NOT the prompt. + expect(spawnArgv[pIdx + 1]).not.toBe("build it"); + expect(spawnArgv[pIdx + 1]!.length).toBeLessThan(200); + // The prompt travels as the --attach temp file. + const aIdx = spawnArgv.indexOf("--attach"); + expect(aIdx).toBeGreaterThan(-1); + expect(spawnArgv[aIdx + 1]).toBe(attachFile()); + expect(spawnArgv).toEqual( + expect.arrayContaining(["--output-format", "stream-json", "--mode", "yolo"]), + ); + + const start = events.find((e) => e.type === "start"); + expect(start).toMatchObject({ + type: "start", + bin: "/resolved/node.exe", + argv: spawnArgv, + }); + }); + + it("spawns with ELECTRON_RUN_AS_NODE=1 merged into the env", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockSpawn).toHaveBeenCalledWith( + "/resolved/node.exe", + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: "1" }), + }), + ); + }); + + // The per-turn model binding: the paired provider-config env vars ride + // the spawn env (personal → the temp clone, builtin → the catalog file the + // selection was validated against), and the bound model surfaces as a meta + // event so the log shows what the turn was pinned to. + it("delivers the paired ZCODE_*_PROVIDER_CONFIG_FILE env vars + a bound-model meta event", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + model: "GLM-5.2/high", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + const events = await eventsPromise; + + // The binding got the picker id and the attach temp dir (clone lives there). + expect(mockPrepareBinding).toHaveBeenCalledWith( + expect.objectContaining({ cjsPath: "/resolved/zcode.cjs", model: "GLM-5.2/high", attachDir: attachDir() }), + ); + const [, , spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + { env: Record }, + ]; + expect(spawnOpts.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE).toBe( + bindingOkResult.clonePath, + ); + expect(spawnOpts.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE).toBe( + bindingOkResult.builtinCatalogPath, + ); + expect(spawnOpts.env.ELECTRON_RUN_AS_NODE).toBe("1"); + // Bound model meta right after start (ZCode's own stream has no model meta). + const meta = events.find((e) => e.type === "meta" && e.key === "model"); + expect(meta).toMatchObject({ type: "meta", key: "model", value: "GLM-5.2/high" }); + }); + + // Fail-refuse: a broken link in the resolution chain refuses the spawn + // with the refusal error rather than silently falling back to whatever the + // CLI would pick on its own. + it("refuses to spawn when the binding resolution fails (error event, no spawn, temp cleaned)", async () => { + mockPrepareBinding.mockReturnValue({ + ok: false, + code: "no-usable-provider", + message: "ZCode: no usable model provider. Open ZCode, log in or add a model provider, then retry.", + }); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + const events = await collectStream(stream); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("Open ZCode"), + }); + // The attach temp dir (holding the would-be clone) is cleaned on refusal. + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), expect.anything()); + }); + + // Escape hatch: a user who pre-set both provider-config vars keeps + // them verbatim (zero-code reroute); the adapter neither overrides them nor + // prepares its own binding. + it("passes a user-set env pair through untouched and skips its own binding", async () => { + vi.stubEnv("ZCODE_PERSONAL_PROVIDER_CONFIG_FILE", "/user/personal.json"); + vi.stubEnv("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE", "/user/builtin.json"); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockPrepareBinding).not.toHaveBeenCalled(); + const [, , spawnOpts] = mockSpawn.mock.calls[0] as unknown as [ + string, + string[], + { env: Record }, + ]; + expect(spawnOpts.env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE).toBe("/user/personal.json"); + expect(spawnOpts.env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE).toBe("/user/builtin.json"); + }); + + it("bridges the full stream-json surface: deltas, usage/duration/result, session; noise lines produce nothing and usage is emitted exactly once", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stdout.write(`${ZCODE_STREAM_JSON}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + const deltas = events.filter((e) => e.type === "delta"); + expect(deltas.map((d) => (d as { text: string }).text)).toEqual(["PRO", "BE", "_OK"]); + // Noise envelope lines (session.updated hook frames …) and turn.started + // produce nothing: dropped, not forwarded as raw / error events. The + // meta events are exactly: the bound-model line after start, then + // turn.completed's usage (snake_case) + duration_ms + resultType, then + // the result terminator's sessionId. usage appears exactly once; the + // terminator's duplicate cumulative numbers are never re-emitted. + expect(events.filter((e) => e.type === "raw")).toEqual([]); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect( + events.filter((e) => e.type === "meta" && e.key !== "model"), + ).toEqual([ + { + type: "meta", + key: "usage", + value: { + input_tokens: 134370, + output_tokens: 4, + cache_read_input_tokens: 64, + cache_creation_input_tokens: 0, + }, + }, + { type: "meta", key: "duration_ms", value: 11229 }, + { type: "meta", key: "result", value: "success" }, + { type: "meta", key: "session", value: "sess_87b8cfd7-66c0-43d1-8133-23a659bae7b2" }, + ]); + expect(events.filter((e) => e.type === "meta" && e.key === "usage")).toHaveLength(1); + expect(events[events.length - 1]).toMatchObject({ type: "done", code: 0 }); + }); + + it("bridges reasoning_delta to thinking meta and tool phases to status lines + HTML rescue", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + // Envelope fields (sessionId/turnId/eventId/…) verbatim from the real + // capture above; the reasoning_delta / tool_call / tool.updated payload + // shapes are pinned in the ZCode open-source contracts + // (session.events.ts ModelStreamingPayload / ToolCallResultPayload). + stdout.write( + [ + JSON.stringify({ seq: 1, sessionId: "s", type: "model.streaming", payload: { kind: "reasoning_delta", delta: "先想清楚布局" } }), + JSON.stringify({ seq: 2, sessionId: "s", type: "model.streaming", payload: { kind: "tool_call", toolCallId: "tc_1", toolName: "WebSearch", input: { query: "css" } } }), + JSON.stringify({ seq: 3, sessionId: "s", type: "tool.updated", payload: { kind: "result", toolCallId: "tc_1", result: { success: true, content: "…" }, duration: 42 } }), + JSON.stringify({ seq: 4, sessionId: "s", type: "model.streaming", payload: { kind: "tool_call", toolCallId: "tc_2", toolName: "Write", input: { file_path: "C:\\ws\\out.html", content: "r" } } }), + JSON.stringify({ seq: 5, sessionId: "s", type: "tool.updated", payload: { kind: "result", toolCallId: "tc_9_unknown", result: { success: true, content: "…" }, duration: 1 } }), + ].join("\n") + "\n", + ); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + expect( + events.filter((e) => e.type === "meta" && e.key !== "model"), + ).toEqual([ + { type: "meta", key: "thinking", value: "先想清楚布局" }, + { type: "meta", key: "status", value: "调用工具 WebSearch" }, + { type: "meta", key: "status", value: "工具 WebSearch 完成" }, + ]); + // The Write tool's input is the deliverable, forwarded as a canonical + // html event (replaces streamed text), not a status line. + expect(events.filter((e) => e.type === "html")).toEqual([ + { type: "html", text: "r" }, + ]); + // The nameless result (no preceding tool_call for tc_9_unknown) emits + // no status line at all; a bare ✓ with no context is noise. + }); + + it("maps turn.failed to an error event (consumer-side red-error gate fires on it)", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "hi", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + // Verbatim-captured failure line from a real failed run: + // fresh-config turn whose model binding never resolved. + stdout.write( + `{"eventId":"d66c6937-6bd7-4f3d-a38a-43d252a1e052","payload":{"error":{"type":"unknown_error","attribution":{"retryable":false},"code":"CONFIGURATION_ERROR","message":"Select a model before continuing","detail":"Model creation failed"},"turnPhase":"model_creation"},"seq":1,"sessionId":"sess_b154be6b-7148-4098-8d80-6f717ce7bdb8","timestamp":1790041331523,"turnId":"turn_28ed2805-a161-450f-a426-9f3182f003bb","type":"turn.failed"}\n`, + ); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 1); + + const events = await eventsPromise; + const errors = events.filter((e) => e.type === "error"); + // The stream's own turn.failed error comes first; the exit handling then maps the + // non-zero exit to a second error right before done. + expect(errors[0]).toEqual({ type: "error", message: "Select a model before continuing" }); + expect(errors[1]).toMatchObject({ + type: "error", + message: expect.stringMatching(/exited with code 1/), + }); + }); + + it("writes the full prompt to the attach temp file (not on argv, not on stdin)", async () => { + const { child, stdout, stdinWrites } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "FULL PROMPT BODY", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockMkdtempSync).toHaveBeenCalledTimes(1); + expect(mockWriteFileSync).toHaveBeenCalledWith(attachFile(), "FULL PROMPT BODY", "utf8"); + expect(stdinWrites.join("")).not.toContain("FULL PROMPT BODY"); + }); + + it("removes the attach temp dir on normal close", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir when the child errors", async () => { + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + child.emit("error", new Error("spawn ENOENT")); + await collectStream(stream); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir on abort", async () => { + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + const controller = new AbortController(); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + await new Promise((r) => setTimeout(r, 0)); + controller.abort(); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + it("removes the attach temp dir when the consumer cancels the stream", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + await stream.cancel(); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + }); + + // ─── Failure & teardown hardening ──────────────────────────────────── + + it("errors and tears the turn down after 180s of zero parsed events", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + const killSpy = vi.fn(); + (child as unknown as { kill: unknown }).kill = killSpy; + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + // A NOISE line (session.* parses to zero events) arrives mid-window. + // It must NOT reset the clock: the watchdog counts PARSED events only. + vi.advanceTimersByTime(90_000); + stdout.write(`${zcodeLines()[0]}\n`); + await flushMicrotasks(); + vi.advanceTimersByTime(90_000); // 180s since the spawn, 90s since the noise + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect((errors[0] as { message?: string }).message).toMatch(/no stream events for 180s/); + // Child killed, attach temp dir removed, stream closed, and NO done + // event: the turn failed, it did not complete. + expect(killSpy).toHaveBeenCalled(); + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + expect(events.some((e) => e.type === "done")).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("resets the silence clock on every parsed event", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + // t+100s: a PARSED text_delta arrives → the clock restarts from here. + vi.advanceTimersByTime(100_000); + stdout.write( + `{"payload":{"delta":"BE","done":false,"kind":"text_delta"},"seq":1,"type":"model.streaming"}\n`, + ); + await flushMicrotasks(); + // t+200s (100s since the delta): without the reset the spawn-time timer + // would have fired at t=180s. The clock restarted, so it is still armed. + vi.advanceTimersByTime(100_000); + await flushMicrotasks(); + expect(vi.getTimerCount()).toBe(1); + + // 180s of silence counted from the delta: fires. + vi.advanceTimersByTime(80_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect((errors[0] as { message?: string }).message).toMatch(/no stream events for 180s/); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not arm the watchdog for non-zcode agents", async () => { + vi.useFakeTimers(); + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "claude", + prompt: "p", + binOverride: "/bin/sh", + }); + + await flushMicrotasks(); + // Never armed for a sibling agent, so a full silence window + close + // produces no watchdog error and the close path still reaches done + // (historical behavior intact). + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(180_500); + await flushMicrotasks(); + stdout.end(); + await flushMicrotasks(); + child.emit("close", 0); + + const events = await collectStream(stream); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(true); + }); + + it("never arms the watchdog before the spawn (binding-refusal window)", async () => { + vi.useFakeTimers(); + mockPrepareBinding.mockReturnValue({ ok: false, message: "no model plan found" }); + const { child } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await flushMicrotasks(); + + const events = await collectStream(stream); + expect(events.some((e) => e.type === "error")).toBe(true); + expect(mockSpawn).not.toHaveBeenCalled(); // refused before the spawn + // No orphaned timer survives the refusal. + expect(vi.getTimerCount()).toBe(0); + }); + + it("maps a non-zero exit to an error BEFORE done, carrying the stderr essence", async () => { + const { child, stdout, stderr } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stderr.write("node:internal/modules/cjs/loader:1145\nError: Cannot find module 'zcode.cjs'\n"); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 3); + + const events = await eventsPromise; + const idxErr = events.findIndex((e) => e.type === "error"); + const idxDone = events.findIndex((e) => e.type === "done"); + expect(idxErr).toBeGreaterThan(-1); + expect(idxDone).toBeGreaterThan(idxErr); + const message = (events[idxErr] as { message: string }).message; + expect(message).toMatch(/exited with code 3/); + expect(message).toContain("Cannot find module 'zcode.cjs'"); + expect((events[idxDone] as { code: number }).code).toBe(3); + }); + + it("caps the stderr essence carried by the exit error", async () => { + const { child, stdout, stderr } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + + stderr.write(`HEAD_MARKER${"x".repeat(3_000)}TAIL_MARKER`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 1); + + const events = await eventsPromise; + const errors = events.filter((e) => e.type === "error"); + const message = (errors[0] as { message?: string }).message ?? ""; + expect(message).toContain("TAIL_MARKER"); + expect(message).not.toContain("HEAD_MARKER"); + }); + + it("keeps the exit-0 close shape with no exit error", async () => { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.write(`${zcodeLines().join("\n")}\n`); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + + const events = await eventsPromise; + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(true); + }); + + it("clears the watchdog on abort; nothing fires after the cancel", async () => { + vi.useFakeTimers(); + const { child } = makeFakeChild(); + const killSpy = vi.fn(); + (child as unknown as { kill: unknown }).kill = killSpy; + mockSpawn.mockReturnValue(child); + const controller = new AbortController(); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + await flushMicrotasks(); + controller.abort(); + await flushMicrotasks(); + // Advancing past the whole silence window must not fire anything: the + // abort path already killed the child, cleaned the temp dir, and closed + // the stream. + vi.advanceTimersByTime(180_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + expect(events.filter((e) => e.type === "error")).toEqual([]); + expect(events.some((e) => e.type === "done")).toBe(false); + expect(killSpy).toHaveBeenCalled(); + expect(mockRmSync).toHaveBeenCalledWith(attachDir(), { recursive: true, force: true }); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("invokeAgent Linux AppImage self-mount (CLI one-shot form)", () => { + const mountCjs = (mp: string) => `${mp}/resources/glm/zcode.cjs`; + const REAL_PLATFORM = process.platform; + const flushMicrotasks = async (iterations = 200) => { + for (let i = 0; i < iterations; i++) { + await Promise.resolve(); + await new Promise((r) => process.nextTick(r)); + } + }; + + beforeEach(() => { + mockSpawn.mockReset(); + mockMkdtempSync.mockClear(); + mockWriteFileSync.mockClear(); + mockRmSync.mockClear(); + existsSyncDelegate.mockImplementation((p: string) => + p === "/opt/ZCode.AppImage" || + p === "/resolved/node.exe" || + p === "/bin/sh", + ); + vi.stubEnv("ZCODE_BIN", "/opt/ZCode.AppImage"); + }); + afterEach(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); + vi.unstubAllEnvs(); + vi.useRealTimers(); + existsSyncDelegate.mockImplementation((p: string) => p === "/bin/sh"); + }); + + const isMountSpawn = (argv: string[]) => argv.includes("--appimage-mount"); + + // (1) The mount child is spawned BEFORE the CLI child, and the CLI argv + // holds the mount-point cjs path (not the AppImage path). + it("mounts the AppImage before spawning the CLI; cjs argv is the mount-point path", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mountPoint = "/tmp/.mount_ZCode-xxxx"; + const mount = makeMountChild(mountPoint); + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + // Let the mount resolve (setTimeout(0) write + microtasks + CLI spawn). + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + await eventsPromise; + + // Two spawns: mount first, CLI second. + expect(mockSpawn).toHaveBeenCalledTimes(2); + const [mountCall, appCall] = mockSpawn.mock.calls as unknown as [string, string[]][]; + expect(mountCall[0]).toBe("/opt/ZCode.AppImage"); + expect(mountCall[1]).toEqual(["--appimage-mount"]); + expect(appCall[0]).toBe("/resolved/node.exe"); + // CLI argv: mount-point cjs in argv[0], the one-shot flags, and no + // `app-server` subcommand anywhere. + expect(appCall[1][0]).toBe(mountCjs(mountPoint)); + expect(appCall[1]).not.toContain("app-server"); + expect(appCall[1]).toEqual( + expect.arrayContaining(["-p", "--attach", "--output-format", "stream-json", "--mode", "yolo"]), + ); + }); + + // (2) The start event reports the mount-point cjs argv (proves the cjs path + // flows through to the caller, not only to the spawn). + it("reports the mount-point cjs path in the start event argv", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mountPoint = "/tmp/.mount_ZCode-yyyy"; + const mount = makeMountChild(mountPoint); + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + + const events = await eventsPromise; + const startEv = events.find((e) => e.type === "start"); + expect(startEv).toMatchObject({ + type: "start", + bin: "/resolved/node.exe", + }); + expect((startEv as { argv: string[] }).argv[0]).toBe(mountCjs(mountPoint)); + }); + + // (3) When the CLI child dies mid-turn, teardown kills the mount child + // too (per-turn mount+unmount, no leak across turns). + it("kills the mount child when the CLI child dies mid-turn", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/tmp/.mount_ZCode-zzz"); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + // CLI child dies; the close handler's cleanup kills the mount holder. + app.child.emit("close", 1); + await eventsPromise; + + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (4) When the stream consumer cancels, cancel() (a ReadableStream sibling + // of start()) kills the mount child (it can't reach start()'s locals). + it("kills the mount child when the stream consumer cancels", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/tmp/.mount_ZCode-cancel"); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + await stream.cancel(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (5) A mount child that exits non-zero (FUSE missing / corrupt AppImage) + // produces a clear error event and never spawns the CLI child. + it("emits a clear error and never spawns the CLI when the mount child exits non-zero", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 10)); + // Mount child exits with an error before printing a mount point. + mount.child.emit("close", 1); + await new Promise((r) => setTimeout(r, 10)); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect((errors[0] as { message?: string }).message).toMatch(/mount/i); + // Only the mount child was spawned; the CLI never reached. + expect(mockSpawn).toHaveBeenCalledTimes(1); + // Mount child was cleaned up on the failure path. + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (6) Mount setup has a bounded ~5s timeout. A mount that never + // prints a point fires the timeout, emits a clear error, and cleans up. + it("fires a clear error after the 5s mount timeout and cleans up the mount child", async () => { + vi.useFakeTimers(); + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + }); + + // Drive start() to the mount await (spawn done, listeners attached, 5s + // timer armed). No stdout → the await blocks until the timer fires. + await flushMicrotasks(); + vi.advanceTimersByTime(5_500); + await flushMicrotasks(); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect((errors[0] as { message?: string }).message).toMatch(/mount timed out/i); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // (7) An abort during the mount window (before the outer onAbort is + // registered) is honored by the signal wired into mountZcodeAppImage: the + // mount child is killed and a clear error surfaces, without waiting for the + // 5s timeout. + it("honors an abort during the mount window (signal wired into the mount helper)", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + const mount = makeMountChild("/never/printed", false); + const mountKillSpy = vi.fn(); + (mount.child as unknown as { kill: unknown }).kill = mountKillSpy; + const app = makeFakeChild(); + mockSpawn.mockImplementation((_bin: string, argv: string[]) => + isMountSpawn(argv) ? mount.child : app.child, + ); + + const controller = new AbortController(); + const stream = invokeAgent({ + agent: "zcode", + prompt: "p", + binOverride: "/resolved/node.exe", + signal: controller.signal, + }); + + // Abort during the mount window (mount child never prints a point). + await new Promise((r) => setTimeout(r, 10)); + controller.abort(); + await new Promise((r) => setTimeout(r, 10)); + + const events = await collectStream(stream); + const errors = events.filter((e) => e.type === "error"); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mountKillSpy).toHaveBeenCalled(); + }); + + // resolveZcodeBin() honours a ZCODE_BIN that + // points directly at the zcode.cjs bundle (the detect tests set + // ZCODE_BIN=<…>.cjs and assert available=true). For a `.cjs` the AppImage + // self-mount would spawn `<.cjs> --appimage-mount` and fail; a JS bundle is + // not an executable AppImage. So on Linux the `.cjs` is used directly: + // exactly ONE spawn (the CLI child), argv holds the override in argv[0], + // and no spawn argv contains `--appimage-mount`. + it("uses a ZCODE_BIN .cjs override directly on Linux with no AppImage mount", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + vi.stubEnv("ZCODE_BIN", "/opt/zcode/override.cjs"); + existsSyncDelegate.mockImplementation((p: string) => + p === "/opt/zcode/override.cjs" || + p === "/resolved/node.exe" || + p === "/bin/sh", + ); + const app = makeFakeChild(); + mockSpawn.mockReturnValue(app.child); + + const stream = invokeAgent({ + agent: "zcode", + prompt: "build it", + binOverride: "/resolved/node.exe", + }); + + await new Promise((r) => setTimeout(r, 50)); + const eventsPromise = collectStream(stream); + + app.stdout.write(`${zcodeLines().join("\n")}\n`); + app.stdout.end(); + await new Promise((r) => setImmediate(r)); + app.child.emit("close", 0); + + const events = await eventsPromise; + + // Exactly ONE spawn, the CLI child. No mount child is spawned. + expect(mockSpawn).toHaveBeenCalledTimes(1); + const calls = mockSpawn.mock.calls as unknown as [string, string[]][]; + const [appCall] = calls; + expect(appCall[0]).toBe("/resolved/node.exe"); + // argv uses the .cjs override verbatim, NOT a `/resources/glm/...` path. + expect(appCall[1][0]).toBe("/opt/zcode/override.cjs"); + // No spawn argv contains `--appimage-mount` (the mount flow never ran). + for (const [, argv] of calls) { + expect(argv).not.toContain("--appimage-mount"); + } + // The .cjs override propagates to the start event too. + const startEv = events.find((e) => e.type === "start"); + expect((startEv as { argv: string[] }).argv[0]).toBe("/opt/zcode/override.cjs"); + }); +}); + + +// Regression guards for the argv branch: keep parity with the behavior from +// before the argv-attach routing landed, so it doesn't disturb existing +// adapters. +describe("invokeAgent argv branch (regression)", () => { + // Isolate from the zcode describes above: those blocks legitimately call + // spawn and its beforeEach reset is scoped to its own tests. Reset the mock + // here too so the call-count assertion below counts only this block's spawn. + beforeEach(() => { + mockSpawn.mockReset(); + }); + + // Per-element quoting applies only to zcode's shell-fallback corner. The + // shared argv spawn (every argv / argv-message agent: deepseek-tui, + // openclaw) must pass argv verbatim, matching the `main` baseline. This pins + // that guarantee on a win32-mocked host: no argv element gains quotes. + it("argv-protocol agent (deepseek-tui) spawns with bare argv elements on win32 without per-element quoting", async () => { + // Force win32 so the shared branch takes the useShell path. Restore in + // finally so a mid-assertion throw can't poison sibling tests. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + const { child, stdout } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + existsSyncDelegate.mockImplementation((p: string) => p === BIN_OVERRIDE); + + const stream = invokeAgent({ + agent: "deepseek-tui", + prompt: "make a page", + binOverride: BIN_OVERRIDE, + }); + + await new Promise((r) => setTimeout(r, 0)); + const eventsPromise = collectStream(stream); + stdout.end(); + await new Promise((r) => setImmediate(r)); + child.emit("close", 0); + await eventsPromise; + + // spawn called once; argv (2nd arg) is bare, no element wrapped in + // quotes. The bin is still quoted on win32 (for .cmd/.bat shims) and + // shell:true is still set, but that's the unchanged bin-quoting path. + expect(mockSpawn).toHaveBeenCalledTimes(1); + const call = mockSpawn.mock.calls[0]; + const spawnedArgv = call[1] as string[]; + for (const el of spawnedArgv) { + expect(el.startsWith('"')).toBe(false); + expect(el.endsWith('"')).toBe(false); + } + // Shape is the documented deepseek-tui argv + the prompt positional. + expect(spawnedArgv).toEqual( + expect.arrayContaining(["exec", "--auto", "make a page"]), + ); + expect(call[2]).toMatchObject({ shell: true }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + + it("unknown agent returns a single error event", async () => { + const stream = invokeAgent({ + agent: "nonexistent", + prompt: "test", + binOverride: BIN_OVERRIDE, + }); + + const events = await collectStream(stream); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("unknown agent"), + }); + }); + + it("missing binOverride returns a single error event", async () => { + const stream = invokeAgent({ + agent: "claude", + prompt: "test", + binOverride: "/nonexistent/path/to/bin", + }); + + const events = await collectStream(stream); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + message: expect.stringContaining("does not exist"), + }); + }); +}); diff --git a/next/src/lib/agents/__tests__/zcode-model-binding.test.ts b/next/src/lib/agents/__tests__/zcode-model-binding.test.ts new file mode 100644 index 00000000..b56d1fdb --- /dev/null +++ b/next/src/lib/agents/__tests__/zcode-model-binding.test.ts @@ -0,0 +1,1172 @@ +// Unit tests for the registry-mirror model binding (v3): enumeration of +// the headless CLI's provider registry (account individual plans paired by +// credential keys ⊕ keyed personal providers), ordering, the modelRules +// level table, the enabled overlays, the default chain, the six-link +// refusal chain, the add-only identity bridge with the zero-write contract +// on the user's real files, and the verified binding discriminator. +// +// Fixture families (scrubbed from two real machines; structure verbatim): +// - F3 catalog: next to this file, __fixtures__/zcode-builtin.fixture.json — +// the install's rule tables (all-vendor modelRules verbatim, the three +// templates the personal fixtures instantiate, their templateModelRules, +// all account providerRules, builtinProviderModelRules). Keys, account +// ids, and gateway hosts in the personal fixtures are placeholders. +// - F1 dual-form machine: 4 keyed personal providers (two templates with +// empty personalModelIds, one custom local gateway, one custom uuid +// gateway) + credentials holding the paired individual keys, the GUI's +// team shell api-key key, and the oauth keys. +// - F2 pure API-key machine: a single keyed bigmodel-api template instance +// with empty personalModelIds/modelOrder and NO credentials.json — the +// shape the v2 "plan parsing" rejected wholesale (regression anchor). +// +// No setting.json is ever written in these tests: v3 reads none, and the +// one explicit test below proves a stale one cannot influence anything. +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + decodeZcodeModelChoice, + encodeZcodeModelChoice, + enumerateZcodeRegistry, + migrateLegacyZcodeProviderId, + prepareZcodeModelBinding, + readZcodePickerState, + resolveZcodeCatalogFile, + resolveZcodeDefaultSelection, + zcodeBundledCatalogPathForCjs, + zcodeCachePlatform, + zcodeProviderEnvPairSet, + stripZcodeHostProviderEnv, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, +} from "../zcode-model-binding"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// ─── F3: the install catalog fixture (structure verbatim) ─────────────── + +function catalogFixture(): Record { + return JSON.parse( + readFileSync(join(here, "__fixtures__", "zcode-builtin.fixture.json"), "utf8"), + ) as Record; +} + +/** Deep-clone + mutate helper for the catalog variants below. */ +function catalogVariant(mutate: (catalog: Record) => void): Record { + const catalog = JSON.parse(JSON.stringify(catalogFixture())) as Record; + mutate(catalog); + return catalog; +} + +// ─── F1: the dual-form machine (personal config + credentials) ────────── + +const WECHAT_PROVIDER_ID = "00000000-1111-4222-8333-444444444444"; +const ACCOUNT_ID = "10000000000000001"; +const ACCOUNT_PROVIDER_ID = "account:bigmodel-individual-coding-plan"; + +function dualFormPersonalConfig(extraConfig: Record = {}): Record { + return { + schemaVersion: 1, + config: { + providerOrder: ["deepseek", "bigmodel-standard-api", "new-provider", WECHAT_PROVIDER_ID], + providerConfigRules: { + providerRules: [ + { + providerId: WECHAT_PROVIDER_ID, + providerName: "WeChat CodingPlan Token", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-wechat-token" }, + api: { type: "openai-chat-completions", baseUrl: "https://gateway.example.com/openai/v1" }, + personalModelIds: ["GLM-5.2", "Deepseek-v4-flash"], + modelOrder: ["GLM-5.2", "Deepseek-v4-flash"], + }, + }, + { + providerId: "bigmodel-standard-api", + templateId: "bigmodel-standard-api", + providerName: "BigModel API", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-bigmodel-key" }, + api: { type: "openai-chat-completions" }, + personalModelIds: [], + modelOrder: [ + "GLM-5.3", "GLM-5.3-Flash", "GLM-5V-Turbo", "GLM-5.1", "GLM-5.1-Highspeed", + "GLM-5", "GLM-5-Turbo", "GLM-4.7", "GLM-4.7-FlashX", "GLM-4.7-Flash", + "GLM-4.6", "GLM-4.5-Air", "GLM-4.5", "GLM-4.6V", "GLM-4.6V-Flash", + "GLM-4.6V-FlashX", "GLM-4.1V-Thinking-FlashX", "GLM-4.1V-Thinking-Flash", + "GLM-4-FlashX-250414", "GLM-4-Flash-250414", "GLM-4V-Flash", "codegeex-4", + "charglm-4", "emohaa", + ], + }, + }, + { + providerId: "deepseek", + templateId: "deepseek", + providerName: "DeepSeek", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-deepseek-key" }, + api: { type: "openai-responses", baseUrl: "https://api.deepseek.com" }, + personalModelIds: [], + modelOrder: ["deepseek-flash", "deepseek-v4-pro"], + }, + }, + { + providerId: "new-provider", + providerName: "llama.cpp", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "sk-local" }, + api: { type: "openai-chat-completions", baseUrl: "http://127.0.0.1:8027/v1" }, + personalModelIds: ["Qwen3.6-35B-A3B", "Qwen3.8-27B"], + modelOrder: ["Qwen3.6-35B-A3B", "Qwen3.8-27B"], + }, + }, + ], + }, + modelConfigRules: { + providerModelRules: [ + { + modelId: "GLM-5.2", + config: { properties: { contextWindow: 200000 } }, + providerId: WECHAT_PROVIDER_ID, + }, + { + modelId: "Deepseek-v4-flash", + config: { properties: { contextWindow: 1000000 } }, + providerId: WECHAT_PROVIDER_ID, + }, + { + modelId: "Qwen3.6-35B-A3B", + config: { + enabled: true, + properties: { contextWindow: 262144, inputFormat: { supportsImage: true } }, + supportsJsonSchemaOutput: true, + supportsMidConversationSystem: true, + }, + optionSpecs: { maxOutputTokens: { max: 16384 } }, + providerId: "new-provider", + }, + { + modelId: "Qwen3.8-27B", + config: { + enabled: false, + properties: { contextWindow: 16384, inputFormat: { supportsImage: true } }, + supportsJsonSchemaOutput: true, + supportsMidConversationSystem: true, + }, + optionSpecs: { maxOutputTokens: { max: 8192 } }, + providerId: "new-provider", + }, + ], + manualProviderModelRules: [], + }, + ...extraConfig, + }, + }; +} + +/** F1 credential key names (values are opaque fakes; only names and the + * plaintext identity value matter to the adapter). The team api-key key is + * the GUI's auto-laid shell: no identity key exists for it. */ +function dualFormCredentials(): Record { + return { + "oauth:bigmodel:access_token": "enc:v1:fixture", + "oauth:bigmodel:user_info": "enc:v1:fixture", + zcodejwttoken: "enc:v1:fixture", + "oauth:active_provider": "enc:v1:fixture", + [`account-provider:coding-plan:${ACCOUNT_PROVIDER_ID}:account:${ACCOUNT_ID}:api-key`]: + "enc:v1:fixture", + [`account-provider:coding-plan:account:bigmodel-team-coding-plan:account:${ACCOUNT_ID}:api-key`]: + "enc:v1:fixture", + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: ACCOUNT_ID, + }; +} + +// ─── F2: the pure API-key machine ──────────────────────────────────────── + +function pureApiKeyPersonalConfig(): Record { + return { + schemaVersion: 1, + config: { + providerOrder: ["bigmodel-api"], + providerConfigRules: { + providerRules: [ + { + providerId: "bigmodel-api", + templateId: "bigmodel-api", + providerName: "BigModel Coding Plan", + config: { + group: "standard-personal", + access: { type: "zhipu-coding-plan-api-key", apiKey: "fixture-coding-plan-key" }, + personalModelIds: [], + modelOrder: [], + }, + }, + ], + }, + modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] }, + }, + }; +} + +// ─── Fixture tree (install + personal + credentials on temp disk) ──────── + +interface FixtureTree { + cjs: string; + catalogPath: string; + personalPath: string; + credentialsPath: string; + attachDir: string; + dir: string; +} + +function writeFixtureTree( + dir: string, + opts: { + catalog?: unknown; + personal?: Record; + credentials?: Record | null; + } = {}, +): FixtureTree { + const catalog = opts.catalog ?? catalogFixture(); + const install = join(dir, "install"); + mkdirSync(join(install, "resources", "glm"), { recursive: true }); + mkdirSync(join(install, "resources", "config", "provider"), { recursive: true }); + const cjs = join(install, "resources", "glm", "zcode.cjs"); + writeFileSync(cjs, ""); + const catalogPath = join(install, "resources", "config", "provider", "zcode-builtin.json"); + writeFileSync(catalogPath, JSON.stringify(catalog)); + const personalPath = join(dir, "provider_config.json"); + writeFileSync(personalPath, JSON.stringify(opts.personal ?? dualFormPersonalConfig())); + const credentialsPath = join(dir, "credentials.json"); + const credentials = opts.credentials === undefined ? dualFormCredentials() : opts.credentials; + if (credentials !== null) writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2)); + const attachDir = join(dir, "attach"); + mkdirSync(attachDir, { recursive: true }); + return { cjs, catalogPath, personalPath, credentialsPath, attachDir, dir }; +} + +// ─── Registry enumeration ───────────────────────────────────────────────── + +describe("enumerateZcodeRegistry (the registry mirror)", () => { + it("F1 dual-form: account individual first (catalog order), then personal by providerOrder; team shell keys produce no entry", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + dualFormCredentials(), + ); + expect(providers.map((p) => [p.providerId, p.providerName, p.kind])).toEqual([ + [ACCOUNT_PROVIDER_ID, "BigModel Individual Coding Plan", "account"], + ["deepseek", "DeepSeek", "personal"], + ["bigmodel-standard-api", "BigModel API", "personal"], + ["new-provider", "llama.cpp", "personal"], + [WECHAT_PROVIDER_ID, "WeChat CodingPlan Token", "personal"], + ]); + // The GUI's team shell api-key key names a team provider (no identity + // key, and team mode is never expanded): no team entry anywhere. + expect(providers.some((p) => p.providerId.includes("team"))).toBe(false); + expect(providers.some((p) => p.providerId.includes("start"))).toBe(false); + expect(providers.some((p) => p.providerId.includes("offpeak"))).toBe(false); + // The paired identity rides along for the account entry. + expect(providers[0]!.accountIdentity).toBe(ACCOUNT_ID); + expect(providers.slice(1).every((p) => p.accountIdentity === undefined)).toBe(true); + }); + + it("F1 model tables: template builtin lists ∪ personalModelIds under the enabled overlays, with modelRules levels", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + dualFormCredentials(), + ); + const modelsOf = (p: (typeof providers)[number]) => p.models.map((m) => [m.modelId, m.levels]); + // Account plan: catalog builtinModelIds, levels from the glm-5.3 rule. + expect(modelsOf(providers[0]!)).toEqual([ + ["GLM-5.3", ["low", "high", "max"]], + ["GLM-5.3-Flash", ["low", "high", "max"]], + ]); + // deepseek template: personalModelIds empty → template builtinModelIds + // in modelOrder, templateModelRules entries enabled. + expect(modelsOf(providers[1]!)).toEqual([ + ["deepseek-flash", ["disabled", "low", "high", "max"]], + ["deepseek-v4-pro", ["disabled", "low", "high", "max"]], + ]); + // bigmodel-standard-api: 24 template ids, but templateModelRules + // disables all but GLM-5.3 / GLM-5.3-Flash. + expect(modelsOf(providers[2]!)).toEqual([ + ["GLM-5.3", ["low", "high", "max"]], + ["GLM-5.3-Flash", ["low", "high", "max"]], + ]); + // llama.cpp custom provider: Qwen3.8-27B dropped by the personal + // providerModelRules enabled:false entry; Qwen3.6-35B-A3B hits no + // specific rule → the base .* table only. + expect(modelsOf(providers[3]!)).toEqual([["Qwen3.6-35B-A3B", ["disabled", "enabled"]]]); + // WeChat custom gateway: personalModelIds as-is. + expect(modelsOf(providers[4]!)).toEqual([ + ["GLM-5.2", ["disabled", "high", "max"]], + ["Deepseek-v4-flash", ["disabled", "low", "high", "max"]], + ]); + }); + + it("F1 without the credential pair: account drops out, personal providers remain", () => { + const providers = enumerateZcodeRegistry( + catalogFixture(), + dualFormPersonalConfig(), + // identity key present but naming a DIFFERENT account: the CLI's own + // entitlement check pairs identity value → api-key key name, so this + // does not materialize either. + { + ...dualFormCredentials(), + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: "10000000000000002", + }, + ); + expect(providers.map((p) => p.providerId)).toEqual([ + "deepseek", + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + ]); + }); + + it("F2 pure API-key machine: single template instance enumerates the template builtinModelIds with levels; no credentials.json at all", () => { + // Regression anchor: the v2 plan-parsing rejected this shape wholesale + // ("not logged in"); the registry mirror enumerates it. + const providers = enumerateZcodeRegistry(catalogFixture(), pureApiKeyPersonalConfig(), null); + expect(providers.map((p) => [p.providerId, p.kind])).toEqual([["bigmodel-api", "personal"]]); + expect(providers[0]!.models).toEqual([ + { modelId: "GLM-5.3", levels: ["low", "high", "max"] }, + { modelId: "GLM-5.3-Flash", levels: ["low", "high", "max"] }, + ]); + }); + + it("a keyed provider missing from providerOrder follows after the ordered ones (declaration order)", () => { + const personal = dualFormPersonalConfig(); + const config = personal.config as { providerOrder: string[] }; + config.providerOrder = ["bigmodel-standard-api", "new-provider", WECHAT_PROVIDER_ID]; + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + expect(providers.map((p) => p.providerId)).toEqual([ + ACCOUNT_PROVIDER_ID, + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + "deepseek", + ]); + }); + + it("keyless / hidden / disabled personal entries are not enumerated (an explicit pick of them refuses later)", () => { + const personal = dualFormPersonalConfig(); + const rules = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules; + // Strip deepseek's key, hide llama.cpp, disable the WeChat entry. + const deepseek = rules.find((r) => r.providerId === "deepseek") as { + config: { access: { apiKey?: string } }; + }; + delete deepseek.config.access.apiKey; + const llama = rules.find((r) => r.providerId === "new-provider") as { + config: Record; + }; + llama.config.visibility = "hidden"; + const wechat = rules.find((r) => r.providerId === WECHAT_PROVIDER_ID) as { + config: Record; + }; + wechat.config.enabled = false; + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + expect(providers.map((p) => p.providerId)).toEqual([ + ACCOUNT_PROVIDER_ID, + "bigmodel-standard-api", + ]); + }); + + it("a catalog whose modelRules match nothing leaves models level-less (still enumerable)", () => { + const catalog = catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = []; + }); + const providers = enumerateZcodeRegistry(catalog, dualFormPersonalConfig(), dualFormCredentials()); + expect(providers[0]!.models).toEqual([ + { modelId: "GLM-5.3", levels: [] }, + { modelId: "GLM-5.3-Flash", levels: [] }, + ]); + }); + + it("a personal enabled:true re-enables a template-disabled model (last-write-wins overlay)", () => { + // The catalog's templateModelRules disables GLM-5V-Turbo on + // bigmodel-standard-api; a personal providerModelRules entry with + // enabled:true re-enables it, mirroring composeEffective's + // [...builtin, ...personal] overlay order. + const personal = dualFormPersonalConfig(); + (personal.config as { modelConfigRules: { providerModelRules: unknown[] } }).modelConfigRules + .providerModelRules.push({ + modelId: "GLM-5V-Turbo", + config: { enabled: true }, + providerId: "bigmodel-standard-api", + }); + const providers = enumerateZcodeRegistry(catalogFixture(), personal, dualFormCredentials()); + const standard = providers.find((p) => p.providerId === "bigmodel-standard-api")!; + expect(standard.models.map((m) => m.modelId)).toEqual([ + "GLM-5.3", + "GLM-5.3-Flash", + "GLM-5V-Turbo", + ]); + expect(standard.models[2]!.levels).toEqual(["disabled", "enabled"]); + }); + + it("malformed inputs degrade to an empty registry (never throws)", () => { + expect(enumerateZcodeRegistry(null, null, null)).toEqual([]); + expect(enumerateZcodeRegistry({}, {}, {})).toEqual([]); + expect( + enumerateZcodeRegistry({ schemaVersion: 1, config: {} }, { config: {} }, {}), + ).toEqual([]); + }); +}); + +// ─── Catalog resolution (unchanged mechanics) ───────────────────────────── + +describe("zcodeBundledCatalogPathForCjs", () => { + it("derives /resources/config/provider/zcode-builtin.json from the cjs", () => { + const win = zcodeBundledCatalogPathForCjs("C:\\Program Files\\ZCode\\resources\\glm\\zcode.cjs"); + expect(win).toBe( + join("C:\\Program Files\\ZCode", "resources", "config", "provider", "zcode-builtin.json"), + ); + const linux = zcodeBundledCatalogPathForCjs("/opt/ZCode/resources/glm/zcode.cjs"); + expect(linux.endsWith(join("config", "provider", "zcode-builtin.json"))).toBe(true); + expect(dirname(dirname(dirname(linux))).replace(/\\/g, "/")).toBe("/opt/ZCode/resources"); + }); +}); + +describe("resolveZcodeCatalogFile", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-catalog-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Minimal schema-valid body (the shape gate only needs schemaVersion+config). */ + const minimalCatalog = { schemaVersion: 1, config: {} }; + + function writeInstall(dirName: string, body: unknown): { cjs: string } { + const install = join(dir, dirName); + mkdirSync(join(install, "resources", "glm"), { recursive: true }); + mkdirSync(join(install, "resources", "config", "provider"), { recursive: true }); + const cjs = join(install, "resources", "glm", "zcode.cjs"); + writeFileSync(cjs, ""); + writeFileSync(join(install, "resources", "config", "provider", "zcode-builtin.json"), JSON.stringify(body)); + return { cjs }; + } + + function writeCache(version: string, endpoint: string, body: unknown): void { + const p = join(dir, "cache", zcodeCachePlatform(), version, endpoint); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, "zcode-builtin.json"), JSON.stringify(body)); + } + + it("prefers the install-bundled file (version-consistent with the cjs)", () => { + writeCache("3.14.3", "endpoint-a", minimalCatalog); + const { cjs } = writeInstall("install", minimalCatalog); + const catalog = resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") }); + expect(catalog!.source).toBe("install"); + expect(catalog!.path).toBe(join(dirname(dirname(cjs)), "config", "provider", "zcode-builtin.json")); + }); + + it("falls back to the NEWEST cache version by freshness (3.14.10 > 3.14.9 > 3.9.2)", () => { + const { cjs } = writeInstall("install-empty", {}); + writeCache("3.9.2", "endpoint-a", minimalCatalog); + writeCache("3.14.9", "endpoint-a", minimalCatalog); + writeCache("3.14.10", "endpoint-b", minimalCatalog); + const catalog = resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") }); + expect(catalog!.source).toBe("cache"); + expect(catalog!.path).toContain("3.14.10"); + }); + + it("a schemaVersion other than 1 is rejected (schema gate)", () => { + const { cjs } = writeInstall("install", { ...minimalCatalog, schemaVersion: 2 }); + expect(resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") })).toBeNull(); + }); + + it("nothing readable → null (catalog-unreadable refusal link)", () => { + const { cjs } = writeInstall("install", { schemaVersion: 1 }); + // config object missing → invalid shape + expect(resolveZcodeCatalogFile({ cjsPath: cjs, cacheRoot: join(dir, "cache") })).toBeNull(); + }); +}); + +// ─── Picker id codec ────────────────────────────────────────────────────── + +describe("picker id codec", () => { + it("encodes provider-namespaced ids with and without a level", () => { + expect(encodeZcodeModelChoice("deepseek", "deepseek-flash", "max")).toBe( + "deepseek/deepseek-flash/max", + ); + expect(encodeZcodeModelChoice("new-provider", "Qwen3.6-35B-A3B")).toBe( + "new-provider/Qwen3.6-35B-A3B", + ); + expect(encodeZcodeModelChoice("p", "m", null)).toBe("p/m"); + }); + + it("decode is structural: the first segment is the providerId; empty segments fail", () => { + expect(decodeZcodeModelChoice("deepseek/deepseek-flash/max")).toEqual({ providerId: "deepseek" }); + // Model ids containing "/" (openrouter-style) still decode to the provider. + expect(decodeZcodeModelChoice("my-router/anthropic/claude-fable-5.1")).toEqual({ + providerId: "my-router", + }); + expect(decodeZcodeModelChoice("deepseek")).toBeNull(); + expect(decodeZcodeModelChoice("deepseek/")).toBeNull(); + expect(decodeZcodeModelChoice("/deepseek-flash")).toBeNull(); + expect(decodeZcodeModelChoice("a//b")).toBeNull(); + }); + + it("migrateLegacyZcodeProviderId maps the legacy coding-plan ids one-way", () => { + expect(migrateLegacyZcodeProviderId("builtin:bigmodel-coding-plan")).toBe(ACCOUNT_PROVIDER_ID); + expect(migrateLegacyZcodeProviderId("builtin:zai-coding-plan")).toBe( + "account:zai-individual-coding-plan", + ); + expect(migrateLegacyZcodeProviderId("deepseek")).toBe("deepseek"); + }); +}); + +// ─── Default chain ──────────────────────────────────────────────────────── + +describe("resolveZcodeDefaultSelection", () => { + const registry = () => + enumerateZcodeRegistry(catalogFixture(), dualFormPersonalConfig(), dualFormCredentials()); + + it("no configured default → first registry provider, first model, highest level (the CLI's fallback)", () => { + expect(resolveZcodeDefaultSelection(registry(), dualFormPersonalConfig())).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("a valid configured default wins, with the legacy providerId migrated", () => { + const personal = dualFormPersonalConfig({ + defaultModelSelection: { + providerId: "builtin:bigmodel-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "low" }, + }, + }); + expect(resolveZcodeDefaultSelection(registry(), personal)).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3-Flash", + reasoningLevel: "low", + }); + }); + + it("a configured default on another registry provider is honoured (it still selects)", () => { + const personal = dualFormPersonalConfig({ + defaultModelSelection: { providerId: "deepseek", modelId: "deepseek-flash" }, + }); + expect(resolveZcodeDefaultSelection(registry(), personal)).toEqual({ + providerId: "deepseek", + providerName: "DeepSeek", + modelId: "deepseek-flash", + // CLI-login-written defaults carry no level; the chain completes the + // model's highest level. + reasoningLevel: "max", + }); + }); + + it("a configured default whose provider or level is invalid falls back (never half-applies)", () => { + const foreign = dualFormPersonalConfig({ + defaultModelSelection: { providerId: "gone", modelId: "GLM-5.3", options: { reasoningLevel: "low" } }, + }); + expect(resolveZcodeDefaultSelection(registry(), foreign)!.modelId).toBe("GLM-5.3"); + const badLevel = dualFormPersonalConfig({ + defaultModelSelection: { + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + options: { reasoningLevel: "xhigh" }, + }, + }); + expect(resolveZcodeDefaultSelection(registry(), badLevel)!.reasoningLevel).toBe("max"); + }); + + it("a level-less first model defaults without a reasoningLevel; an empty registry yields null", () => { + const catalog = catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = []; + }); + const providers = enumerateZcodeRegistry(catalog, dualFormPersonalConfig(), dualFormCredentials()); + expect(resolveZcodeDefaultSelection(providers, dualFormPersonalConfig())).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: null, + }); + expect(resolveZcodeDefaultSelection([], dualFormPersonalConfig())).toBeNull(); + }); +}); + +// ─── Picker state composition ───────────────────────────────────────────── + +describe("readZcodePickerState", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-picker-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("F1: ready, provider-namespaced chips with service-name labels, and the resolved Default", () => { + const f = writeFixtureTree(dir); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state.ready).toBe(true); + expect(state.reason).toBeNull(); + expect(state.defaultChoice).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + providerName: "BigModel Individual Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + // Same-name model across providers: distinct ids, service name in label. + const glm53Chips = state.options.filter((o) => + o.id.endsWith("/GLM-5.3/low"), + ); + expect(glm53Chips.map((o) => [o.id, o.label])).toEqual([ + [`${ACCOUNT_PROVIDER_ID}/GLM-5.3/low`, "GLM-5.3 (BigModel Individual Coding Plan, low)"], + ["bigmodel-standard-api/GLM-5.3/low", "GLM-5.3 (BigModel API, low)"], + ]); + // Level-less chips carry no suffix (llama.cpp's Qwen model here has only + // the base .* table, so it does have levels; assert its chip shape via + // the disabled level instead). + expect(state.options).toContainEqual({ + id: "new-provider/Qwen3.6-35B-A3B/disabled", + label: "Qwen3.6-35B-A3B (llama.cpp, disabled)", + providerId: "new-provider", + }); + // Team / start / off-peak never appear in any chip id. + expect(state.options.every((o) => !/team|start-plan|offpeak/.test(o.id))).toBe(true); + }); + + it("level-less models produce chips without a level suffix", () => { + const f = writeFixtureTree(dir, { + catalog: catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = + []; + }), + }); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state.ready).toBe(true); + expect(state.options[0]).toEqual({ + id: `${ACCOUNT_PROVIDER_ID}/GLM-5.3`, + label: "GLM-5.3 (BigModel Individual Coding Plan)", + providerId: ACCOUNT_PROVIDER_ID, + }); + }); + + it("F2 pure API-key machine: ready with the template's models (regression anchor: v2 rejected this shape)", () => { + const f = writeFixtureTree(dir, { personal: pureApiKeyPersonalConfig(), credentials: null }); + const state = readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(state).toMatchObject({ ready: true, reason: null }); + expect(state.options.map((o) => o.id)).toEqual([ + "bigmodel-api/GLM-5.3/low", + "bigmodel-api/GLM-5.3/high", + "bigmodel-api/GLM-5.3/max", + "bigmodel-api/GLM-5.3-Flash/low", + "bigmodel-api/GLM-5.3-Flash/high", + "bigmodel-api/GLM-5.3-Flash/max", + ]); + expect(state.defaultChoice).toEqual({ + providerId: "bigmodel-api", + providerName: "BigModel Coding Plan", + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("no usable provider anywhere → the single not-ready state with an empty picker", () => { + // Keyless personal entries + no credentials: nothing keyed remains. + const personal = pureApiKeyPersonalConfig(); + const rules = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules; + delete (rules[0]!.config as { access: { apiKey?: string } }).access.apiKey; + const f = writeFixtureTree(dir, { personal, credentials: null }); + expect( + readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }), + ).toEqual({ ready: false, reason: "no-usable-provider", options: [], defaultChoice: null }); + }); + + it("an unreadable catalog → the same single not-ready state (the CLI cannot boot without it)", () => { + const f = writeFixtureTree(dir); + rmSync(f.catalogPath, { force: true }); + expect( + readZcodePickerState({ + cjsPath: f.cjs, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }), + ).toEqual({ ready: false, reason: "no-usable-provider", options: [], defaultChoice: null }); + }); +}); + +// ─── prepareZcodeModelBinding: the per-turn contract ────────────────────── + +describe("prepareZcodeModelBinding", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "zcode-prepare-test-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const baseOverrides = (f: FixtureTree) => ({ + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + + it("explicit account pick: exact selection into a temp clone; both real files byte-identical (zero-write)", () => { + const f = writeFixtureTree(dir); + const personalBefore = readFileSync(f.personalPath, "utf8"); + const credentialsBefore = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + reasoningLevel: "high", + }); + expect(result.builtinCatalogPath).toBe(f.catalogPath); + const clone = JSON.parse(readFileSync(result.clonePath, "utf8")) as { + config: { defaultModelSelection: unknown; providerOrder: string[] }; + }; + expect(clone.config.defaultModelSelection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + options: { reasoningLevel: "high" }, + }); + // The clone keeps the rest of the personal config (provider rules ride along). + expect(clone.config.providerOrder).toEqual([ + "deepseek", + "bigmodel-standard-api", + "new-provider", + WECHAT_PROVIDER_ID, + ]); + // Zero-write contract: the user's real files are untouched (the paired + // identity key already exists, so the bridge writes nothing). + expect(readFileSync(f.personalPath, "utf8")).toBe(personalBefore); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(credentialsBefore); + }); + + it("default pick → the registry default (first provider, first model, highest level)", () => { + const f = writeFixtureTree(dir); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3", + reasoningLevel: "max", + }); + }); + + it("default pick honours a valid CLI-written configured default (legacy id migrated)", () => { + const f = writeFixtureTree(dir, { + personal: dualFormPersonalConfig({ + defaultModelSelection: { + providerId: "builtin:bigmodel-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "low" }, + }, + }), + }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: ACCOUNT_PROVIDER_ID, + modelId: "GLM-5.3-Flash", + reasoningLevel: "low", + }); + }); + + it("a level-less model binds without a reasoningLevel; the clone omits the option", () => { + const f = writeFixtureTree(dir, { + catalog: catalogVariant((c) => { + (c.config as { modelConfigRules: { modelRules: unknown[] } }).modelConfigRules.modelRules = + []; + }), + }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "new-provider/Qwen3.6-35B-A3B", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: "new-provider", + modelId: "Qwen3.6-35B-A3B", + reasoningLevel: null, + }); + const clone = JSON.parse(readFileSync(result.clonePath, "utf8")) as { + config: { defaultModelSelection: unknown }; + }; + expect(clone.config.defaultModelSelection).toEqual({ + providerId: "new-provider", + modelId: "Qwen3.6-35B-A3B", + }); + }); + + it("a model id containing '/' splits by validation (openrouter-style custom provider)", () => { + const personal = dualFormPersonalConfig(); + (personal.config as { providerConfigRules: { providerRules: unknown[] } }).providerConfigRules + .providerRules.push({ + providerId: "my-router", + providerName: "My OpenRouter", + config: { + group: "standard-personal", + access: { type: "api-key", apiKey: "fixture-router-key" }, + personalModelIds: ["anthropic/claude-fable-5.1", "deepseek/deepseek-v4-pro"], + }, + }); + const f = writeFixtureTree(dir, { personal }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "my-router/anthropic/claude-fable-5.1/high", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.selection).toEqual({ + providerId: "my-router", + modelId: "anthropic/claude-fable-5.1", + reasoningLevel: "high", + }); + // Level-less variant of the same slash id. + const levelless = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "my-router/anthropic/claude-fable-5.1", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(levelless.ok && levelless.selection.reasoningLevel).toBeNull(); + }); + + it("identity bridge (add-only): an explicit account pick with the api-key key but no identity key gains exactly that key", () => { + const credentials = dualFormCredentials(); + delete credentials[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]; + const f = writeFixtureTree(dir, { credentials }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + const after = JSON.parse(readFileSync(f.credentialsPath, "utf8")) as Record; + expect(after).toEqual({ + ...credentials, + // The identity value is the account id embedded in the GUI's own + // api-key key name; nothing else changed. + [`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]: ACCOUNT_ID, + }); + // Idempotent: a second preparation finds the key and writes nothing. + const second = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(second.ok).toBe(true); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(JSON.stringify(after, null, 2)); + }); + + it("personal targets never touch the credential store (snapshot compare)", () => { + const f = writeFixtureTree(dir); + const before = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "deepseek/deepseek-v4-pro/max", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(before); + }); + + it("a stale identity value (names no api-key key) refuses with provider-no-access and writes nothing", () => { + const credentials = dualFormCredentials(); + credentials[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`] = "10000000000000009"; + const f = writeFixtureTree(dir, { credentials }); + const before = readFileSync(f.credentialsPath, "utf8"); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result).toMatchObject({ ok: false, code: "provider-no-access" }); + expect(readFileSync(f.credentialsPath, "utf8")).toBe(before); + }); + + it("each refusal link fires with an actionable message", () => { + const f = writeFixtureTree(dir); + const base = { + cjsPath: f.cjs, + attachDir: f.attachDir, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }; + // 1. Catalog unreadable (install file removed, no cache). + rmSync(f.catalogPath, { force: true }); + const noCatalog = prepareZcodeModelBinding({ ...base }); + expect(noCatalog).toMatchObject({ ok: false, code: "catalog-unreadable" }); + writeFileSync(f.catalogPath, JSON.stringify(catalogFixture())); + // 2. Provider config unreadable. + const noPersonal = prepareZcodeModelBinding({ + ...base, + personalConfigPath: join(dir, "no-personal.json"), + }); + expect(noPersonal).toMatchObject({ ok: false, code: "provider-config-unreadable" }); + // 3. No usable provider at all (keyless F2 + no credentials). + const keyless = pureApiKeyPersonalConfig(); + delete ( + (keyless.config as { providerConfigRules: { providerRules: Array> } }) + .providerConfigRules.providerRules[0]!.config as { access: { apiKey?: string } } + ).access.apiKey; + const keylessPath = join(dir, "keyless-personal.json"); + writeFileSync(keylessPath, JSON.stringify(keyless)); + const none = prepareZcodeModelBinding({ + ...base, + personalConfigPath: keylessPath, + credentialsPath: join(dir, "no-credentials.json"), + }); + expect(none).toMatchObject({ ok: false, code: "no-usable-provider" }); + // 4. Account provider known, api-key credential missing. + const noApiKey = dualFormCredentials(); + delete noApiKey[`account-provider:coding-plan:${ACCOUNT_PROVIDER_ID}:account:${ACCOUNT_ID}:api-key`]; + delete noApiKey[`account-provider:${ACCOUNT_PROVIDER_ID}:identity`]; + const noApiKeyPath = join(dir, "no-apikey.json"); + writeFileSync(noApiKeyPath, JSON.stringify(noApiKey)); + const unauthorized = prepareZcodeModelBinding({ + ...base, + credentialsPath: noApiKeyPath, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/high`, + }); + expect(unauthorized).toMatchObject({ ok: false, code: "provider-no-access" }); + // 5. Model not on the provider (and a bare v2-era stale id). + const notOnProvider = prepareZcodeModelBinding({ + ...base, + model: `${ACCOUNT_PROVIDER_ID}/GLM-9/high`, + }); + expect(notOnProvider).toMatchObject({ ok: false, code: "model-not-on-provider" }); + const staleBare = prepareZcodeModelBinding({ ...base, model: "GLM-5.3" }); + expect(staleBare).toMatchObject({ ok: false, code: "model-not-on-provider" }); + // 6. Level unsupported for the model. + const badLevel = prepareZcodeModelBinding({ + ...base, + model: `${ACCOUNT_PROVIDER_ID}/GLM-5.3/medium`, + }); + expect(badLevel).toMatchObject({ ok: false, code: "level-unsupported" }); + // Every refusal message carries an action and the ZCode: prefix. + for (const r of [noCatalog, noPersonal, none, unauthorized, notOnProvider, staleBare, badLevel]) { + expect(r.ok).toBe(false); + if (r.ok) continue; + expect(r.message).toMatch(/[Rr]etry\.|[Rr]escan|[Pp]ick again|log in/); + expect(r.message.startsWith("ZCode:")).toBe(true); + } + }); + + it("a personal provider that exists but is not usable refuses with provider-no-access (not a stale pick)", () => { + const personal = dualFormPersonalConfig(); + const deepseek = (personal.config as { + providerConfigRules: { providerRules: Array> }; + }).providerConfigRules.providerRules.find((r) => r.providerId === "deepseek") as { + config: { access: { apiKey?: string } }; + }; + delete deepseek.config.access.apiKey; + const f = writeFixtureTree(dir, { personal }); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: "deepseek/deepseek-flash/max", + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result).toMatchObject({ ok: false, code: "provider-no-access" }); + }); + + it("setting.json is not part of the contract: a stale plan-era file changes nothing", () => { + const f = writeFixtureTree(dir); + // v2-era keys; v3 never reads this file. + writeFileSync( + join(dir, "setting.json"), + JSON.stringify({ + providerFamilyDomain: "zai", + modelProviderFamilySelectedKeys: { bigmodel: "coding-plan:builtin:bigmodel-coding-plan" }, + }), + ); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + attachDir: f.attachDir, + ...baseOverrides(f), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + // The zai keys in setting.json cannot steer anything: the registry + // default is still the paired bigmodel plan. + expect(result.selection.providerId).toBe(ACCOUNT_PROVIDER_ID); + }); +}); + +// ─── The binding discriminator ──────────────────────────────────────────── +// +// A mis-bound fresh turn silently reroutes to the first personal provider +// and fails at its gateway (ProviderBusinessError 400, exit 1); a correctly +// bound turn completes (resultType success, exit 0). Both arms are pinned +// below with fixtures captured from real CLI runs. + +describe("binding discriminator (verified)", () => { + const wrongBindingStderr = readFileSync( + join(here, "__fixtures__", "zcode-wrong-binding.stderr.txt"), + "utf8", + ); + // The exit-0 arm's captured evidence: the turn.completed + result lines of + // a run that completed under the paired redirect. + const rightBindingStdout = readFileSync( + join(here, "__fixtures__", "zcode-right-binding.stdout.jsonl"), + "utf8", + ); + + it("the wrong-binding evidence: gateway 400 on a reasoning level routed to a non-plan provider", () => { + expect(wrongBindingStderr).toContain("providerCode: 400"); + expect(wrongBindingStderr).toContain("'input': 'max'"); + expect(wrongBindingStderr).toContain(`providerId: '${WECHAT_PROVIDER_ID}'`); + }); + + it("the right-binding evidence: a bound turn completes (resultType success, exit 0)", () => { + const completed = rightBindingStdout + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as { type?: string; payload?: { resultType?: string; response?: string }; response?: string }); + const turn = completed.find((l) => l.type === "turn.completed"); + expect(turn?.payload?.resultType).toBe("success"); + const result = completed.find((l) => l.type === "result"); + expect(result?.response).toBe("PROBE_OK"); + }); + + it("the binding we write is the verified-good shape: registry provider + explicit level, never the fallback provider", () => { + const dir = mkdtempSync(join(tmpdir(), "zcode-discriminator-")); + try { + const f = writeFixtureTree(dir); + const result = prepareZcodeModelBinding({ + cjsPath: f.cjs, + model: encodeZcodeModelChoice(ACCOUNT_PROVIDER_ID, "GLM-5.3", "high"), + attachDir: f.attachDir, + personalConfigPath: f.personalPath, + credentialsPath: f.credentialsPath, + cacheRoot: join(dir, "no-cache"), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + // The registry triple (provider, model, explicit level)… + expect(result.selection.providerId).toBe(ACCOUNT_PROVIDER_ID); + expect(result.selection.modelId).toBe("GLM-5.3"); + expect(result.selection.reasoningLevel).toBe("high"); + // …and never the 400-ing fallback provider from the stderr fixture. + expect(result.selection.providerId).not.toBe(WECHAT_PROVIDER_ID); + expect(wrongBindingStderr).not.toContain(result.selection.providerId); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("zcodeProviderEnvPairSet", () => { + it("true only when both provider-config vars are set (user's zero-code reroute)", () => { + expect( + zcodeProviderEnvPairSet({ + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/tmp/a.json", + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: "/tmp/b.json", + } as unknown as NodeJS.ProcessEnv), + ).toBe(true); + expect( + zcodeProviderEnvPairSet({ + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/tmp/a.json", + } as unknown as NodeJS.ProcessEnv), + ).toBe(false); + expect(zcodeProviderEnvPairSet({} as NodeJS.ProcessEnv)).toBe(false); + }); +}); + +describe("stripZcodeHostProviderEnv", () => { + it("removes both host-injected vars and reports what was present", () => { + const env = { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/host/personal.json", + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: "/host/builtin.json", + PATH: "/usr/bin", + } as unknown as NodeJS.ProcessEnv; + const removed = stripZcodeHostProviderEnv(env); + expect(removed).toEqual([ + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, + ]); + expect(env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + expect(env[ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("leaves a clean environment untouched (empty removal list)", () => { + const env = { PATH: "/usr/bin" } as unknown as NodeJS.ProcessEnv; + expect(stripZcodeHostProviderEnv(env)).toEqual([]); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("drops only the present half of the pair", () => { + const env = { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: "/host/personal.json", + } as unknown as NodeJS.ProcessEnv; + const removed = stripZcodeHostProviderEnv(env); + expect(removed).toEqual([ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]); + expect(env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]).toBeUndefined(); + }); +}); diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index f92b6b59..a1a1a380 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -20,6 +20,15 @@ export class UnsupportedAgentProtocolError extends Error { } } +/** + * The fixed short guide ZCode's `-p` flag carries. The attachment holds the + * real task, so this only points the model at it and pins the deliverable + * shape (final HTML as the reply body; ZCode is agentic and would otherwise + * reach for file-write tools). Keep it short: `-p` takes an argv value. + */ +const ZCODE_PROMPT_GUIDE = + "附件是完整的任务说明。请严格按其中的要求执行,并将最终结果(完整 HTML)直接作为你的回复正文输出。"; + export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { const { model } = _opts; switch (agent) { @@ -126,6 +135,22 @@ export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { // there's no `-` stdin sentinel. invoke.ts appends opts.prompt at // spawn time, so we leave the trailing slot empty here. return ["exec", "--auto", ...(model ? ["--model", model] : [])]; + case "zcode": + // Headless one-shot. `-p` carries only this fixed short guide; the + // full prompt (shared directives + template + user content, 20-30KB+) + // travels in the `--attach` temp file invoke.ts writes, since argv + // length limits would truncate it. `--mode yolo` is `-p`'s default + // anyway; passing it is self-documentation. There is no `--model` + // flag: the model default comes from ZCode's own provider config + // (opts.model is deliberately ignored). + return [ + "-p", + ZCODE_PROMPT_GUIDE, + "--output-format", + "stream-json", + "--mode", + "yolo", + ]; case "hermes": case "kimi": case "devin": @@ -143,6 +168,12 @@ export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { export function envFor(agent: string): NodeJS.ProcessEnv { const base = { ...process.env }; if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true"; + // ZCode's zcode.cjs is an Electron-hosted bundle; without + // ELECTRON_RUN_AS_NODE=1 it boots the full Electron app rather than the CLI + // and the prompt is never executed (verified). Merged into the env (not + // a replacement) so PATH, ZCODE_*, and the provider-config escape-hatch + // vars survive. Scoped to zcode; other agents must not inherit it. + if (agent === "zcode") base.ELECTRON_RUN_AS_NODE = "1"; return base; } @@ -156,12 +187,19 @@ export type AgentParse = * real HTML. Downstream calls `setHtmlFor`, not `appendHtmlFor`. */ | { kind: "html"; text: string } + /** + * Turn-level failure surfaced by the stream itself (e.g. ZCode's + * `turn.failed` event). Carries the human-readable message only; the invoke + * layer forwards it as `{type:"error"}` so the consumer-side failure gate + * (red error state) can fire on it. + */ + | { kind: "error"; message: string } | { kind: "noise" }; /** * Cross-line state that the parser carries between calls. Currently used to * dedupe text deltas: when an agent emits both fine-grained `stream_event` - * `text_delta` blocks AND a final `assistant` message containing the same + * `text_delta` blocks and a final `assistant` message containing the same * text concatenated, we keep the streamed tokens and skip the assistant * message body. Without this dedupe, every Claude/Cursor/Gemini/Qoder run * with `--include-partial-messages` (or the equivalent) writes its output @@ -174,11 +212,19 @@ export type ParseState = { opencodeAccumulatedCacheReadTokens?: number; opencodeAccumulatedCacheWriteTokens?: number; opencodeAccumulatedCost?: number; + /** + * ZCode: toolCallId → toolName, filled when the `model.streaming` + * tool_call event names the tool. `tool.updated` result events carry only a + * toolCallId, so the "工具 X 完成" status line resolves the name through + * this map; a result whose id maps to nothing emits no line at all + * (a nameless status line is noise). + */ + zcodeToolNamesById?: Map; }; /** * Build a stateful per-invocation parser. Feed every stdout line through the - * returned function — it carries the cross-line state needed for dedupe. + * returned function; it holds the cross-line state needed for dedupe. */ export function makeParser(agent: string): (line: string) => AgentParse[] { const state: ParseState = {}; @@ -201,6 +247,10 @@ export function parseLine(agent: string, line: string): AgentParse[] { * Rescue the HTML from the tool_use input so the preview still gets the real * content. Returns an empty string if no Write/create_file tool_use was found * or its input has no usable content field. + * + * Module-internal: shared by the agent parse cases below. (The cli mirror + * keeps its own internal copy; the two files are adapted copies by repo + * convention, not verbatim mirrors.) */ function rescueHtmlFromToolUse( content: Array<{ type?: string; name?: string; input?: unknown }> | undefined, @@ -239,6 +289,123 @@ function rescueHtmlFromToolUse( return parts.join(""); } +/** + * ZCode CLI one-shot (`-p --output-format stream-json`) stdout, one NDJSON + * event envelope per line: + * + * {"eventId":…,"payload":{…},"seq":…,"sessionId":…,"timestamp":…,"type":"model.streaming"} + * + * Events that produce output, dispatched on `type` (set pinned in the + * ZCode open-source contracts, + * apps/zcode-cli/packages/contracts/src/events/session.events.ts): + * - `model.streaming`, dispatched on `payload.kind`: text_delta → delta; + * reasoning_delta → thinking meta (same `thinking` key as the Claude + * path); tool_call → rescue a file-write tool's input HTML, else a status + * line (records toolCallId→toolName for the nameless result event); the + * start/end/finish kinds emit nothing. + * - `tool.updated`, only kind `result`: "工具 X 完成" with the name resolved + * via the tool_call map. + * - `turn.completed`: usage (snake_case remap), duration, resultType; the + * only place usage is emitted. + * - `turn.failed` → error part. `result` → bare terminator (sessionId meta; + * its usage repeats turn.completed and is not re-emitted). + * + * Everything else (session.titleUpdated / session.resumed / session.updated + * plugin-hook frames, turn.started, message.upserted, permission / + * checkpoint families, …) is noise and dropped; so are non-JSON lines (ZCode + * plugins can print arbitrary stdout). + */ +function parseZcodeLine(line: string, state: ParseState): AgentParse[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (!parsed || typeof parsed !== "object") return []; + const obj = parsed as Record; + const payload = obj.payload && typeof obj.payload === "object" ? (obj.payload as Record) : null; + + if (obj.type === "model.streaming" && payload) { + const kind = typeof payload.kind === "string" ? payload.kind : ""; + if (kind === "text_delta" || kind === "reasoning_delta") { + const delta = typeof payload.delta === "string" ? payload.delta : ""; + if (delta.length === 0) return []; + return [ + kind === "text_delta" + ? { kind: "delta", text: delta } + : { kind: "meta", key: "thinking", value: delta }, + ]; + } + if (kind === "tool_call") { + const id = typeof payload.toolCallId === "string" ? payload.toolCallId : ""; + const name = typeof payload.toolName === "string" ? payload.toolName : ""; + if (id && name) { + (state.zcodeToolNamesById ??= new Map()).set(id, name); + } + // A file-write tool call may hold the generated HTML; reuse the same + // rescue logic the other adapters apply to Claude-style tool_use + // blocks (ZCode's Write tool is {file_path, content}, same shape). + const html = rescueHtmlFromToolUse([{ type: "tool_use", name, input: payload.input }]); + if (html) return [{ kind: "html", text: html }]; + if (name) return [{ kind: "meta", key: "status", value: `调用工具 ${name}` }]; + } + return []; + } + + if (obj.type === "tool.updated" && payload) { + if (payload.kind === "result") { + const id = typeof payload.toolCallId === "string" ? payload.toolCallId : ""; + const name = (id && state.zcodeToolNamesById?.get(id)) || ""; + if (name) return [{ kind: "meta", key: "status", value: `工具 ${name} 完成` }]; + } + return []; + } + + if (obj.type === "turn.completed" && payload) { + const out: AgentParse[] = []; + const usage = mapZcodeUsage(payload.usage); + if (usage) out.push({ kind: "meta", key: "usage", value: usage }); + if (typeof payload.duration === "number") { + out.push({ kind: "meta", key: "duration_ms", value: payload.duration }); + } + if (typeof payload.resultType === "string") { + out.push({ kind: "meta", key: "result", value: payload.resultType }); + } + return out; + } + + if (obj.type === "turn.failed") { + const err = payload && typeof payload.error === "object" ? (payload.error as Record) : null; + const message = + err && typeof err.message === "string" && err.message.length > 0 ? err.message : "zcode turn failed"; + return [{ kind: "error", message }]; + } + + if (obj.type === "result") { + if (typeof obj.sessionId === "string" && obj.sessionId.length > 0) { + return [{ kind: "meta", key: "session", value: obj.sessionId }]; + } + return []; + } + + return []; +} + +/** ZCode usage (camelCase) → the snake_case keys the consumer layer reads + * (same key set Claude's `result.usage` carries: input_tokens / + * output_tokens / cache_read_input_tokens / cache_creation_input_tokens). */ +function mapZcodeUsage(usage: unknown): Record | null { + if (!usage || typeof usage !== "object") return null; + const u = usage as Record; + const out: Record = {}; + if (typeof u.inputTokens === "number") out.input_tokens = u.inputTokens; + if (typeof u.outputTokens === "number") out.output_tokens = u.outputTokens; + if (typeof u.cacheReadTokens === "number") out.cache_read_input_tokens = u.cacheReadTokens; + if (typeof u.cacheWriteTokens === "number") out.cache_creation_input_tokens = u.cacheWriteTokens; + return Object.keys(out).length > 0 ? out : null; +} + function parseLineWithState(agent: string, line: string, state: ParseState): AgentParse[] { const trimmed = line.trim(); if (!trimmed) return []; @@ -249,6 +416,16 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age return [{ kind: "delta", text: trimmed.endsWith("\n") ? trimmed : trimmed + "\n" }]; } + // ZCode (argv-attach), NDJSON event envelope, one JSON object per line: + // {"type":"model.streaming","payload":{"kind":"text_delta","delta":"…"},…} + // plus a bare {"type":"result",…} terminator line. See parseZcodeLine for + // the full event set. Handled before the shared JSON.parse so a + // non-JSON line returns [] here rather than a `noise` part (the invoke + // layer forwards noise as `raw`, which would flood the log panel). + if (agent === "zcode") { + return parseZcodeLine(trimmed, state); + } + let parsed: unknown; try { parsed = JSON.parse(trimmed); diff --git a/next/src/lib/agents/detect.ts b/next/src/lib/agents/detect.ts index f98123e5..af1acfbc 100644 --- a/next/src/lib/agents/detect.ts +++ b/next/src/lib/agents/detect.ts @@ -1,6 +1,10 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import path, { delimiter, join } from "node:path"; +import path, { delimiter, join, posix, win32 } from "node:path"; +import { + readZcodePickerState, + type ZcodeNotReadyReason, +} from "./zcode-model-binding"; /** * Per-agent invocation protocol. Determines what `invokeAgent` does with the @@ -10,19 +14,50 @@ import path, { delimiter, join } from "node:path"; * - "argv-message" : prompt goes via `--message ` (openclaw); stdout is * a single multi-line JSON document (not ndjson), parsed * after the child closes. + * - "argv-attach" : ZCode's headless one-shot CLI. The full prompt travels + * in a temp-file attachment (`--attach `, written + * by the invoke layer), `-p` carries only a fixed short + * guide, and stdout is an NDJSON event stream + * (`--output-format stream-json`). * - "acp" : ACP JSON-RPC over stdio (hermes/kimi/devin/kiro/kilo/vibe). * Not implemented in this build — surfaced in detection so * the user sees install instructions, but invoke emits a * clear error pointing them to a supported agent. * - "pi-rpc" : pi's custom JSON-RPC mode. Same status as "acp". */ -export type AgentProtocol = "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc"; +export type AgentProtocol = "stdin" | "argv" | "argv-message" | "argv-attach" | "acp" | "pi-rpc"; -export type ModelOption = { id: string; label: string }; +/** + * A model picker entry. `id`/`label` are the two fields every agent's + * picker reads. `providerId` is optional and ZCode-only: ZCode's chips are + * namespaced per registry provider (`/[/]`), + * and the per-turn binding writes that + * `{ providerId, modelId, reasoningLevel }` into the provider-config clone + * the spawn hands the CLI. Absent for every other agent (their picker ids + * map to a single provider implicitly, or go to `--model `). + */ +export type ModelOption = { id: string; label: string; providerId?: string }; /** Synthetic "let the CLI pick" entry — agent runs without `--model`. */ export const DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (CLI config)" }; +/** + * ZCode's static picker floor. Distinct from {@link DEFAULT_MODEL}: ZCode + * has no `--model` flag and no "CLI config picks" semantics. Default always + * means "whatever ZCode's own default model resolves to", so even the + * floor label must not imply a CLI-side default. + */ +const ZCODE_DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (ZCode default)" }; + +/** + * Sentinel placed in ZCode's `AgentDef.binArgs` where the resolved path to + * the `zcode.cjs` bundle belongs. Filled at detect time (availability) and + * invoke time (the actual spawn) by {@link resolveZcodeBin}. Exported so the + * invoke layer substitutes this same token; a duplicated string literal + * could typo and silently spawn the literal. + */ +export const ZCODE_CJS_SENTINEL = ""; + export type AgentDef = { id: string; label: string; @@ -32,6 +67,14 @@ export type AgentDef = { vendor: string; /** Defaults to "stdin" when omitted. */ protocol?: AgentProtocol; + /** + * Extra leading argv spliced between the bin and the protocol argv. Needed + * for node-script CLIs (e.g. ZCode's `zcode.cjs`) that must be spawned as + * `node -p …` rather than as a standalone exec: argv[1] + * has to be the cjs (Node rejects a bare executable passed as an arg). + * Optional and defaults to absent, so existing adapters are unaffected. + */ + binArgs?: string[]; /** * Curated, evidence-based model list shown in the picker. Always begins * with `DEFAULT_MODEL` (= no `--model` flag → user's CLI config wins). @@ -319,6 +362,30 @@ export const AGENTS: AgentDef[] = [ { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, ], }, + // ZCode (Z.AI), a headless one-shot CLI agent and the only "argv-attach" + // protocol entry today. The CLI is a node bundle (zcode.cjs) spawned as + // `node -p --attach --output-format + // stream-json --mode yolo`, so bin: "node" and binArgs carries just the + // resolved .cjs; argv[1] must be the cjs (Node rejects a bare executable + // passed as an arg). The `` sentinel is filled by + // resolveZcodeBin() at detect time (availability) and invoke time (the + // actual spawn). Never marked unsupported: the adapter rides the generic + // invoke trunk like every argv-family agent. + // + // fallbackModels is the ZCODE_DEFAULT_MODEL static floor: the CLI has no + // --model flag, so per-turn model selection travels as a + // defaultModelSelection clone (detectAgents' argv-attach branch below + + // the invoke layer's paired-env binding). + { + id: "zcode", + label: "ZCode", + bin: "node", + envOverride: "ZCODE_BIN", + vendor: "Z.AI", + protocol: "argv-attach", + binArgs: [ZCODE_CJS_SENTINEL], + fallbackModels: [ZCODE_DEFAULT_MODEL], + }, ]; function userToolchainDirs(): string[] { @@ -433,6 +500,291 @@ export function resolveOnPath(bin: string): string | null { return null; } +// ─── Linux install discovery (.deb + AppImage) ──────────────────────── +// +// ZCode's official Linux distributions are the `.deb` package and the AppImage. +// The two are told apart by a loose `zcode.cjs`: +// - `.deb` installs to /opt/ZCode/ with a loose resources/glm/zcode.cjs next +// to the Electron binary (/opt/ZCode/zcode), the same layout as the +// Windows/macOS installs, so it is probed the same way (on-disk .cjs). +// - AppImage is a single compressed squashfs with the .cjs packed inside, +// reachable only while mounted. There is no loose `.cjs` and no fixed +// filename (`ZCode--linux-.AppImage`), so the AppImage path +// is read from the XDG `.desktop` entry ZCode writes on first GUI launch +// (`~/.local/share/applications/zcode.desktop`); its `Exec=` line holds +// the real path whatever the user named/placed the file. The same bar as +// login: a user must have run ZCode once. Freedesktop.org standard +// honoured by every Linux desktop. + +/** Path to the XDG `.desktop` entry ZCode generates on first GUI launch. */ +const ZCODE_DESKTOP_PATH = posix.join( + homedir(), + ".local", + "share", + "applications", + "zcode.desktop", +); + +/** + * Parse the ZCode AppImage path out of a `.desktop` entry's `Exec=` line. + * Pure (no I/O) so it can be unit-tested with string fixtures. Returns the + * AppImage path, or `null` when the line is absent or malformed. + * + * ZCode writes an entry like: + * + * [Desktop Entry] + * Name=ZCode + * Exec=/home/user/Applications/ZCode-3.7.5-linux-x64.AppImage --no-sandbox %U + * Icon=zcode + * ... + * + * The first token after `Exec=` is the AppImage path (whatever the user + * named/placed the file); trailing flags (`--no-sandbox`, `%U`, …) and the + * freedesktop field codes are stripped. + */ +export function parseZcodeDesktopExec(content: string): string | null { + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + // Skip comments and only match a top-level Exec= key (not e.g. a + // `TryExec=` or a `-Exec=` inside an Action group). A leading whitespace + // is already trimmed; a real desktop key never has an interior space + // before `=`. + if (!line.startsWith("Exec=")) continue; + const rest = line.slice("Exec=".length).trim(); + if (!rest) return null; + // The path is the first token. Per the freedesktop Desktop Entry Spec a + // path containing a reserved character (space) MUST be quoted, so + // tokenize with quote-awareness; splitting on whitespace first would + // truncate `"/home/u/My Apps/ZCode.AppImage"` at the interior space. A + // quoted token (double or single) yields its inner content; otherwise + // the leading non-whitespace run is the path. Trailing flags + // (--no-sandbox, %U, …) and field codes are dropped. Reject a bare + // field-code token (malformed entry). + const m = rest.match(/^"([^"]*)"|^'([^']*)'|^(\S+)/); + const token = m?.[1] ?? m?.[2] ?? m?.[3] ?? ""; + if (!token || token.startsWith("%")) return null; + return token; + } + return null; +} + +/** + * Discover the ZCode AppImage on Linux via the XDG `.desktop` entry ZCode + * generates on first GUI launch. The `Exec=` line holds the real path + * whatever the user named/placed the file, so no filename guessing or glob is + * needed. Returns `null` (never throws) when the entry is missing or + * malformed; the caller treats that as "ZCode not found". + */ +export function discoverZcodeAppImage(): string | null { + try { + if (!existsSync(ZCODE_DESKTOP_PATH)) return null; + return parseZcodeDesktopExec(readFileSync(ZCODE_DESKTOP_PATH, "utf8")); + } catch { + return null; + } +} + +/** + * Locate the ZCode CLI binary. Probe order, first match wins: + * 1. `ZCODE_BIN` env var, a user override (absolute path, else PATH lookup) + * 2. `zcode` on PATH, a defensive probe only. ZCode's official Linux + * distributions are the `.deb` and the AppImage, neither known to ship a + * PATH shim, so this rarely hits and is not a supported install shape. + * 3. Platform default: + * Windows : `%ZCODE_WINDOWS_APP_INSTALL_DIR%\resources\glm\zcode.cjs` + * → `C:\Program Files\ZCode\resources\glm\zcode.cjs` + * macOS : `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs` + * Linux : on-disk `zcode.cjs` at + * `/opt/ZCode/resources/glm/zcode.cjs` (the `.deb` install, + * same loose-file layout as Windows/macOS); else the AppImage + * path from the `.desktop` `Exec=` line (the `.cjs` lives + * inside the mount, so the AppImage binary doubles as the + * availability signal and the Electron driver). + * + * On Windows/macOS (and a Linux `.deb`) the returned path is the `.cjs` bundle. + * On a Linux AppImage install the platform default is the AppImage binary (the + * `.cjs` path is computed post-mount in the invoke layer). A `ZCODE_BIN` or + * on-disk `.cjs` is returned as-is and the invoke layer then uses it directly + * with no mount. + * + * Discovery only. Returns `null` (never throws) when nothing is found. + */ +export function resolveZcodeBin(): string | null { + const env = process.env; + // 1. Explicit user override. + const override = env.ZCODE_BIN?.trim(); + if (override) { + if (existsSync(override)) return override; + const onPath = resolveOnPath(override); + if (onPath) return onPath; + } + // 2. `zcode` on PATH, defensive probe only (ZCode ships as a `.deb` or + // AppImage); kept so a hand-placed link still resolves. The override (1) + // and platform default (3) are the real paths. + const pathHit = resolveOnPath("zcode"); + if (pathHit) return pathHit; + // 3. Platform default. + if (process.platform === "linux") { + // Probe the on-disk `.cjs` before the AppImage mount. A + // loose resources/glm/zcode.cjs at the install root cleanly distinguishes + // a `.deb` (or unpacked) install from an AppImage (the `.cjs` is packed in + // the squashfs, so no loose file sits next to it). The invoke layer's + // `.cjs`-direct branch then runs it without mounting. + for (const candidate of defaultZcodeCjsPaths()) { + if (existsSync(candidate)) return candidate; + } + // AppImage: the `.cjs` lives inside the mount; discover the binary via + // the `.desktop` entry (availability + Electron driver + mount source). + const appImage = discoverZcodeAppImage(); + if (appImage && existsSync(appImage)) return appImage; + return null; + } + for (const candidate of defaultZcodeCjsPaths()) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Per-platform candidate default paths for ZCode's `zcode.cjs`. Exported only + * so tests can assert the exact paths probed; callers should use + * `resolveZcodeBin()`. + * + * macOS/Linux paths are built with `posix` separators so they stay + * forward-slash regardless of the *host* running the probe. These are + * fixed install locations and must not be rewritten when the host is, say, + * Windows running a cross-platform unit test. Windows paths keep backslash + * separators. + */ +export function defaultZcodeCjsPaths(): string[] { + const platform = process.platform; + const env = process.env; + if (platform === "win32") { + const installDir = env.ZCODE_WINDOWS_APP_INSTALL_DIR?.trim(); + const out: string[] = []; + if (installDir) { + out.push(win32.join(installDir, "resources", "glm", "zcode.cjs")); + } + out.push( + win32.join("C:\\Program Files\\ZCode", "resources", "glm", "zcode.cjs"), + ); + return out; + } + if (platform === "darwin") { + return [posix.join("/Applications/ZCode.app", "Contents", "Resources", "glm", "zcode.cjs")]; + } + // The official `.deb` install lays a loose + // resources/glm/zcode.cjs at /opt/ZCode/ (same layout as Windows/macOS), so + // it is probed the same way. An AppImage packs the `.cjs` inside its + // squashfs (no loose file), so when only the AppImage is installed this + // candidate misses and resolveZcodeBin() falls back to `.desktop` discovery. + return [posix.join("/opt/ZCode", "resources", "glm", "zcode.cjs")]; +} + +/** + * Resolve the node binary that drives `node -p …`. Distinct from + * `resolveZcodeBin()` (which locates the `.cjs` bundle): html-anything is an + * external process, and on a clean Windows host `where node` finds nothing + * (only `ZCode.exe` exists). + * + * Probe order: + * 1. `ZCODE_NODE_BIN` env, an explicit user override (absolute path, else + * PATH). + * 2. `node` on PATH, the system Node. Preferred when present: it needs no + * ELECTRON_RUN_AS_NODE env, and is the simplest portable driver. + * 3. The ZCode Electron executable itself (the only node-like binary on a + * clean host) under `ELECTRON_RUN_AS_NODE=1` (merged into the spawn env + * by envFor's zcode line) it behaves as node; no separate `node.exe` + * ships in the install tree. See {@link defaultZcodeElectronExePaths} + * for the per-platform exe locations. + * + * Returns a non-empty path (never `null`, never throws): step 3, the bundled + * Electron exe, is terminal and present whenever detect passed (`zcode.cjs` + * found ⟺ ZCode installed ⟺ the sibling Electron exe exists). The one caller + * that can still miss all three probes is a hand-crafted `ZCODE_BIN` at an + * orphaned `.cjs`; the resolver then returns the canonical install location + * and lets the spawn's own ENOENT surface the real problem, rather than a + * misleading "install Node.js" message. + */ +export function resolveZcodeNodeBin(): string { + const env = process.env; + const override = env.ZCODE_NODE_BIN?.trim(); + if (override) { + if (existsSync(override)) return override; + const onPath = resolveOnPath(override); + if (onPath) return onPath; + } + const pathNode = resolveOnPath("node"); + if (pathNode) return pathNode; + // Step 3: the bundled Electron exe is the terminal fallback. The last + // candidate in defaultZcodeElectronExePaths() is the canonical install + // location, present whenever the caller reached this code via detectAgents() + // (zcode.cjs found ⟺ ZCode installed ⟺ exe exists). Return it on the miss + // path too; see the doc comment above for the orphaned-.cjs rationale. + if (process.platform === "linux") { + // Probe the `.deb` Electron driver (/opt/ZCode/zcode) before + // the AppImage. Same role as ZCode.exe on Windows: under + // ELECTRON_RUN_AS_NODE=1 it acts as node, and a `.deb`-only host has no + // AppImage to mount, so without this probe it could not drive the `.cjs`. + // defaultZcodeElectronExePaths() lists the `.deb` driver first; the + // AppImage is discovered via its `.desktop` entry (the real user-named + // path, not the canonical guess), and the last candidate is the terminal + // fallback so the contract (`string`, never null) holds even for a + // hand-crafted caller that bypassed detect; the spawn's own ENOENT then + // surfaces the real problem. + const electronPaths = defaultZcodeElectronExePaths(); + if (existsSync(electronPaths[0])) return electronPaths[0]; + const appImage = discoverZcodeAppImage(); + if (appImage && existsSync(appImage)) return appImage; + return electronPaths[electronPaths.length - 1]; + } + const electronPaths = defaultZcodeElectronExePaths(); + for (const candidate of electronPaths) { + if (existsSync(candidate)) return candidate; + } + return electronPaths[electronPaths.length - 1]; +} + +/** + * Per-platform candidate default paths for the ZCode Electron executable, + * the node-driver fallback when no system `node` is on PATH. Exported only so + * tests can assert the exact paths probed; callers should use + * `resolveZcodeNodeBin()`. + * + * Mirrors {@link defaultZcodeCjsPaths}'s separator discipline: macOS/Linux + * paths use `posix` separators so they stay forward-slash regardless of the + * host running a cross-platform unit test; Windows paths keep backslashes. + * The exe sits at the install root (not under `resources/glm/` like the `.cjs`): + * Windows : `\ZCode.exe` → `C:\Program Files\ZCode\ZCode.exe` + * macOS : `/Applications/ZCode.app/Contents/MacOS/ZCode` + * Linux : `/opt/ZCode/zcode` (the `.deb` Electron binary) and + * `~/Applications/ZCode.AppImage` (the AppImage is the executable) + */ +export function defaultZcodeElectronExePaths(): string[] { + const platform = process.platform; + const env = process.env; + if (platform === "win32") { + const installDir = env.ZCODE_WINDOWS_APP_INSTALL_DIR?.trim(); + const out: string[] = []; + if (installDir) { + out.push(win32.join(installDir, "ZCode.exe")); + } + out.push(win32.join("C:\\Program Files\\ZCode", "ZCode.exe")); + return out; + } + if (platform === "darwin") { + return [posix.join("/Applications/ZCode.app", "Contents", "MacOS", "ZCode")]; + } + // The `.deb` install ships /opt/ZCode/zcode (the Electron + // binary, driven under ELECTRON_RUN_AS_NODE=1 as node); the AppImage is + // itself the executable. The `.deb` driver is listed first so + // resolveZcodeNodeBin() probes it before AppImage discovery. + return [ + posix.join("/opt/ZCode", "zcode"), + posix.join(homedir(), "Applications", "ZCode.AppImage"), + ]; +} + export type DetectedAgent = { id: string; label: string; @@ -448,11 +800,23 @@ export type DetectedAgent = { models: ModelOption[]; /** True when the adapter cannot be invoked yet (acp / pi-rpc). */ unsupported?: boolean; + /** + * ZCode-only: installed is not the same as ready. True when the registry + * mirror holds at least one usable provider (a paired Coding Plan or a + * keyed personal provider); false with {@link notReadyReason} when the + * user still needs to log in or add a provider in ZCode. The settings + * card renders the amber "已安装 · 未就绪" state from these. Absent for + * every other agent (no readiness notion). + */ + ready?: boolean; + notReadyReason?: ZcodeNotReadyReason; }; export function detectAgents(): DetectedAgent[] { return AGENTS.map((a): DetectedAgent => { const protocol = a.protocol ?? "stdin"; + // "acp" / "pi-rpc" are detection-only; "argv-attach" (ZCode) is + // implemented on the generic invoke trunk and is not unsupported. const unsupported = protocol === "acp" || protocol === "pi-rpc"; const base = { id: a.id, @@ -462,6 +826,66 @@ export function detectAgents(): DetectedAgent[] { models: a.fallbackModels, unsupported: unsupported || undefined, }; + + // ZCode's CLI is a .cjs bundle, not a standalone exec on PATH, so its + // availability is driven by resolveZcodeBin() (which already honours + // ZCODE_BIN, PATH, and platform defaults). The generic PATH branch below + // would wrongly report `node` (bin) as the install, so ZCode gets its own + // detection: available iff the .cjs resolves. resolvedBin is the node + // driver the spawn path will use (node -p …): + // resolveZcodeNodeBin() (the real node or Electron-exe fallback). That + // resolver's return is tightened to `string`: the Electron-exe fallback is + // terminal and present whenever detect passed (zcode.cjs found ⟺ ZCode + // installed ⟺ exe exists), so there is no null to coalesce here. + // + // The picker mirrors ZCode's headless provider registry (the install- + // bundled catalog + the personal provider config + credential key + // names — the same sources the invoke layer binds against; see + // zcode-model-binding.ts). Chips carry provider-namespaced ids + // "/[/]" with the service name in the label; + // the "default" entry binds the registry default at invoke time. The + // Default chip label names what Default currently resolves to (the same + // chain the invoke layer applies), so the chip never promises something + // stale. Reads are gated on availability: an unavailable install keeps + // the static floor (ZCODE_DEFAULT_MODEL), and a not-ready install (no + // usable keyed provider) surfaces the amber card state while keeping the + // static floor. + if (protocol === "argv-attach") { + const cjs = resolveZcodeBin(); + if (cjs) { + const picker = readZcodePickerState({ cjsPath: cjs }); + return { + ...base, + available: true, + path: cjs, + resolvedBin: resolveZcodeNodeBin(), + ready: picker.ready, + notReadyReason: picker.reason ?? undefined, + // Registry chips when they resolved (Default chip first, labelled + // with the resolved default service, model, and level); otherwise + // the static floor. + models: picker.options.length + ? [ + { + id: "default", + label: picker.defaultChoice + ? `Default (${picker.defaultChoice.providerName} ${ + picker.defaultChoice.modelId + }${ + picker.defaultChoice.reasoningLevel + ? `, ${picker.defaultChoice.reasoningLevel}` + : "" + })` + : ZCODE_DEFAULT_MODEL.label, + }, + ...picker.options, + ] + : [ZCODE_DEFAULT_MODEL], + }; + } + return { ...base, available: false }; + } + const override = a.envOverride ? process.env[a.envOverride] : undefined; if (override && existsSync(override)) { return { ...base, available: true, path: override, resolvedBin: a.bin }; diff --git a/next/src/lib/agents/invoke.ts b/next/src/lib/agents/invoke.ts index 22d11d91..2a29d14f 100644 --- a/next/src/lib/agents/invoke.ts +++ b/next/src/lib/agents/invoke.ts @@ -1,7 +1,14 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync } from "node:fs"; -import { resolveOnPath, resolveOpenclawAgentId, AGENTS } from "./detect"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { resolveOnPath, resolveOpenclawAgentId, resolveZcodeBin, resolveZcodeNodeBin, ZCODE_CJS_SENTINEL, AGENTS } from "./detect"; import { buildArgv, envFor, makeParser, UnsupportedAgentProtocolError } from "./argv"; +import { + prepareZcodeModelBinding, + zcodeBindingEnv, + zcodeProviderEnvPairSet, +} from "./zcode-model-binding"; export type InvokeOpts = { agent: string; @@ -29,8 +36,8 @@ type BinResolution = * 3. PATH scan over `def.bin` then `def.fallbackBins` * * If a `binOverride` is set but doesn't resolve, return `override-missing` - * (do not silently fall through) — the user picked an explicit path and - * deserves to see the typo / wrong path instead of mysteriously running a + * (do not silently fall through): the user picked an explicit path and + * deserves to see the typo / wrong path rather than mysteriously running a * different binary. */ function resolveBinForAgent( @@ -63,12 +70,28 @@ function resolveBinForAgent( return { kind: "not-found" }; } +/** + * Quote a single argv element for cmd.exe when `spawn(..., { shell: true })` is + * used on Windows. cmd.exe splits the argv array on whitespace, so an element + * containing a space (notably ZCode's resolved `C:\Program + * Files\ZCode\resources\glm\zcode.cjs`) must be double-quoted. Already-quoted + * elements are left alone; empty elements become `""`. + * + * Only used on zcode's shell fallback path (a `.cmd`/`.bat` node shim): the + * normal zcode resolution yields an absolute `.exe`, which spawns directly + * with no shell and verbatim argv. + */ +function quoteWindowsArg(arg: string): string { + if (arg.length > 0 && arg.startsWith('"') && arg.endsWith('"')) return arg; + return `"${arg}"`; +} + export type InvokeEvent = | { type: "start"; bin: string; argv: string[]; promptBytes: number } | { type: "delta"; text: string } /** * Canonical HTML rescued from a file-write tool call. The client REPLACES - * the task's accumulated html with this payload (not appends) — see + * the task's accumulated html with this payload (not appends); see * [[rescueHtmlFromToolUse]] in argv.ts for why this exists. */ | { type: "html"; text: string } @@ -78,23 +101,75 @@ export type InvokeEvent = | { type: "done"; code: number | null } | { type: "error"; message: string }; +/** + * Silence watchdog (zcode only): a healthy stream-json turn produces + * parsed events continuously (deltas, thinking fragments, tool status), so + * 180s with zero parsed events means the model or CLI hung and the turn + * would hang forever with no teardown. Any stdout line that parses to at + * least one event resets the clock; when it fires the turn errors out, the + * child is killed, and the stream closes. Sibling agents keep their + * historical behavior. + */ +const ZCODE_SILENCE_TIMEOUT_MS = 180_000; + +/** + * Cap on the stderr tail kept for the non-zero-exit error message. + * The full stderr keeps streaming to the log as `stderr` events; this buffer + * only feeds the one-line exit error with its most recent content. + */ +const STDERR_TAIL_CAP = 2_000; +/** How much of that tail the exit-error message actually quotes. */ +const STDERR_HINT_MAX = 500; + export function invokeAgent(opts: InvokeOpts): ReadableStream { const def = AGENTS.find((a) => a.id === opts.agent); if (!def) { return errorStream(`unknown agent: ${opts.agent}`); } - const resolved = resolveBinForAgent(def, opts.binOverride); - if (resolved.kind === "override-missing") { - return errorStream( - `${def.label}: custom path \`${resolved.tried}\` does not exist. Update or clear it in Settings → Custom path.`, - ); - } - if (resolved.kind === "not-found") { - return errorStream( - `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, - ); + + // argv-attach agents (ZCode) spawn `node -p …`, and + // html-anything is an external process: on a clean Windows host + // `where node` finds nothing. ZCODE_BIN (def.envOverride) is the `.cjs` + // override, not a node bin, so these agents resolve their bin separately: + // binOverride (explicit node path) wins, else resolveZcodeNodeBin() (never + // null because its Electron-exe fallback is present whenever detect + // passed). Only the binOverride-missing error remains. Execution then + // continues down the generic trunk below; ZCode has no dedicated protocol + // branch. + let bin: string; + if (def.protocol === "argv-attach") { + if (opts.binOverride && opts.binOverride.trim()) { + const tried = opts.binOverride.trim(); + if (/^([a-zA-Z]:[\\/]|[\\/])/.test(tried)) { + bin = existsSync(tried) ? tried : ""; + } else if (tried.includes("/") || tried.includes("\\") || tried.startsWith(".")) { + const abs = path.resolve(tried); + bin = existsSync(abs) ? abs : ""; + } else { + bin = resolveOnPath(tried) ?? ""; + } + if (!bin) { + return errorStream( + `${def.label}: custom node path \`${tried}\` does not exist. Update or clear it in Settings → Custom path.`, + ); + } + } else { + bin = resolveZcodeNodeBin(); + } + } else { + const resolved = resolveBinForAgent(def, opts.binOverride); + if (resolved.kind === "override-missing") { + return errorStream( + `${def.label}: custom path \`${resolved.tried}\` does not exist. Update or clear it in Settings → Custom path.`, + ); + } + if (resolved.kind === "not-found") { + return errorStream( + `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, + ); + } + bin = resolved.bin; } - const bin: string = resolved.bin; // For openclaw we need an async detection step (resolveOpenclawAgentId) // before buildArgv. Do all of the argv assembly inside the stream's async @@ -102,6 +177,39 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { const env = envFor(opts.agent); const promptViaArgv = def.protocol === "argv"; const promptViaMessageFlag = def.protocol === "argv-message"; + const promptViaAttach = def.protocol === "argv-attach"; + + // Lifted above the ReadableStream so `cancel` (a sibling callback of + // `start` that cannot reach its locals) can tear the argv-attach turn's + // resources down without waiting on `start`: + // - mountChild: on Linux the AppImage mount child stays alive holding the + // FUSE mount for the duration of the turn (null on Windows/macOS). + // - attachTmpDir: the mkdtemp dir holding zcode's prompt.md attachment. + let mountChild: ChildProcessWithoutNullStreams | null = null; + let attachTmpDir: string | null = null; + // The zcode silence-watchdog handle, lifted for the same reason as + // cleanupArgvAttach: `cancel`, a sibling of `start`, must be able to clear + // it without waiting for `start` to run. + let silenceTimer: ReturnType | null = null; + const clearSilenceTimer = () => { + if (silenceTimer !== null) { + clearTimeout(silenceTimer); + silenceTimer = null; + } + }; + const cleanupArgvAttach = () => { + // The mount child holds the FUSE mount alive; kill it so the + // mount unmounts at teardown (per-turn mount+unmount). No-op off-Linux. + try { + mountChild?.kill("SIGTERM"); + } catch {} + if (attachTmpDir) { + try { + rmSync(attachTmpDir, { recursive: true, force: true }); + } catch {} + attachTmpDir = null; + } + }; return new ReadableStream({ async start(controller) { @@ -124,6 +232,52 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } catch {} }; + // argv-attach (ZCode): resolve the .cjs argv[1] before the final argv is + // assembled; on Linux that path is only known after the AppImage + // self-mounts. Reuses resolveZcodeBin() and the + // mount helper unchanged; a ZCODE_BIN pointing at a `.cjs` is used + // directly with no mount. + let cjsPath: string | undefined; + if (promptViaAttach) { + if (process.platform === "linux") { + const resolvedZcode = resolveZcodeBin(); + if (!resolvedZcode) { + safeEnqueue({ + type: "error", + message: + "ZCode AppImage not found. Open the ZCode GUI once (to write its .desktop entry) or set ZCODE_BIN to the AppImage (or the zcode.cjs bundle).", + }); + safeClose(); + return; + } + if (resolvedZcode.endsWith(".cjs")) { + cjsPath = resolvedZcode; + } else { + try { + const mounted = await mountZcodeAppImage(resolvedZcode, { + cwd: opts.cwd, + signal: opts.signal, + }); + mountChild = mounted.mountChild; + cjsPath = path.posix.join( + mounted.mountPoint, + "resources", + "glm", + "zcode.cjs", + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + cleanupArgvAttach(); + safeEnqueue({ type: "error", message }); + safeClose(); + return; + } + } + } else { + cjsPath = resolveZcodeBin() ?? ZCODE_CJS_SENTINEL; + } + } + // Resolve agent-specific argv. For openclaw we first probe `agents // list` to learn the actual agent id (commonly "main") so the CLI's // required `--agent ` is satisfied. @@ -156,28 +310,105 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { // `protocol: "argv-message"` (openclaw today) wants the prompt under // an explicit `--message ` flag. if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; + // `protocol: "argv-attach"` (zcode today): the full prompt (shared + // directives + template + user content, 20-30KB+) is far past command- + // line length limits, so it travels as a temp-file attachment. The + // `.md` enters the model context whole (verified). `-p` (buildArgv) + // already holds the fixed short guide. The mkdtemp dir is removed on + // every exit path via cleanupArgvAttach (close / error / abort / + // cancel); it also holds the per-turn provider-config clone below, so + // the binding is cleaned up with the prompt. + let boundModelMeta: string | null = null; + if (promptViaAttach) { + try { + attachTmpDir = mkdtempSync(path.join(tmpdir(), "html-anything-zcode-")); + writeFileSync(path.join(attachTmpDir, "prompt.md"), opts.prompt, "utf8"); + } catch (err) { + safeEnqueue({ + type: "error", + message: `failed to write the ZCode prompt attachment: ${err instanceof Error ? err.message : String(err)}`, + }); + cleanupArgvAttach(); + safeClose(); + return; + } + argv = [...argv, "--attach", path.join(attachTmpDir, "prompt.md")]; + + // Per-turn model binding (ZCode has no --model flag): clone + paired + // env vars + the identity-bridge credential; any broken link refuses + // the spawn. See zcode-model-binding.ts. A user who pre-set the env + // pair keeps it untouched (zero-code reroute). + if (!zcodeProviderEnvPairSet(process.env)) { + const binding = prepareZcodeModelBinding({ + cjsPath: cjsPath!, + model: opts.model, + attachDir: attachTmpDir, + }); + if (!binding.ok) { + safeEnqueue({ type: "error", message: binding.message }); + cleanupArgvAttach(); + safeClose(); + return; + } + Object.assign(env, zcodeBindingEnv(binding)); + boundModelMeta = binding.selection.reasoningLevel + ? `${binding.selection.modelId}/${binding.selection.reasoningLevel}` + : binding.selection.modelId; + } + } + + // node-script CLIs (ZCode's zcode.cjs) run as `node -p …`; + // binArgs holds the leading argv the bin needs, with the + // `` sentinel filled from the path resolved above + // (the mounted path on Linux). Absent for every existing adapter, so + // their spawn is unchanged. + const leadingArgv = (def.binArgs ?? []).map((arg) => + arg === ZCODE_CJS_SENTINEL && cjsPath ? cjsPath : arg, + ); + const fullArgv = leadingArgv.length ? [...leadingArgv, ...argv] : argv; try { // On Windows, `spawn` cannot launch a `.cmd` / `.bat` shim (which is // what npm installs for most CLI agents) without going through the // shell. Without this, every agent invocation fails with // EINVAL / "spawn 无效的参数". macOS/Linux use direct exec. - // Safety: prompt content is delivered via stdin or `--message - // ` (argv-message), not interpolated into a shell command, - // so this does not introduce a shell-injection vector. - const useShell = process.platform === "win32"; - child = spawn(useShell ? `"${bin}"` : bin!, argv, { - cwd: opts.cwd ?? process.cwd(), - env, - stdio: ["pipe", "pipe", "pipe"], - shell: useShell, - windowsVerbatimArguments: false, - }); + // Safety: prompt content is delivered via stdin, `--message ` + // (argv-message), or a temp-file `--attach` path (argv-attach), and + // never interpolated into a shell command, so this does not introduce + // a shell-injection vector. + // + // Exception: an absolute `.exe` (ZCode's `ZCode.exe` node driver, or + // a real node.exe) spawns directly with no cmd.exe round-trip, so + // spaced install paths (`C:\Program Files\ZCode\ZCode.exe`) work + // untouched and every argv element passes verbatim, with no quoting + // games. Only the bin is quoted on the shell path (the `main` + // baseline); per-element quoting (quoteWindowsArg) applies solely to + // zcode's `.cmd`-shim corner, where the spaced `zcode.cjs` path must + // survive cmd.exe's whitespace split. (The cli mirror deliberately + // differs here: it never quoted the bin on its generic shell path, so + // it gates all quoting, bin included, on the zcode attach case.) + const binIsAbsoluteExe = + process.platform === "win32" && + /^([a-zA-Z]:[\\/]|[\\/])/.test(bin) && + /\.exe$/i.test(bin); + const useShell = process.platform === "win32" && !binIsAbsoluteExe; + child = spawn( + useShell ? `"${bin}"` : bin, + useShell && promptViaAttach ? fullArgv.map(quoteWindowsArg) : fullArgv, + { + cwd: opts.cwd ?? process.cwd(), + env, + stdio: ["pipe", "pipe", "pipe"], + shell: useShell, + windowsVerbatimArguments: false, + }, + ); } catch (err) { safeEnqueue({ type: "error", message: err instanceof Error ? err.message : String(err), }); + cleanupArgvAttach(); safeClose(); return; } @@ -185,15 +416,48 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { safeEnqueue({ type: "start", bin: bin!, - argv, + argv: fullArgv, promptBytes: Buffer.byteLength(opts.prompt, "utf8"), }); + // The bound model as resolved by the per-turn binding (ZCode's stream emits + // no model/session start meta of its own). Emitted so the log panel + // shows which model and level the turn was pinned to. + if (boundModelMeta) { + safeEnqueue({ type: "meta", key: "model", value: boundModelMeta }); + } + + // Arm the silence watchdog only here, after the spawn succeeded, so + // the argv-assembly / Linux-mount / binding window before it can + // never trip the timer (that window has its own bounded failures). + const fireSilence = () => { + silenceTimer = null; + safeEnqueue({ + type: "error", + message: `ZCode produced no stream events for ${ZCODE_SILENCE_TIMEOUT_MS / 1000}s. The turn was terminated (hung model or CLI?).`, + }); + try { + child?.kill("SIGTERM"); + } catch {} + cleanupArgvAttach(); + safeClose(); + }; + const resetSilenceTimer = () => { + // No-op once the turn ended (close/abort cleared the handle) or for + // agents that never armed one. + if (silenceTimer === null) return; + clearTimeout(silenceTimer); + silenceTimer = setTimeout(fireSilence, ZCODE_SILENCE_TIMEOUT_MS); + }; + if (opts.agent === "zcode") { + silenceTimer = setTimeout(fireSilence, ZCODE_SILENCE_TIMEOUT_MS); + } child.stdin.on("error", () => {}); try { // stdin-protocol agents read the prompt from stdin; argv / argv-message - // agents already have it on the command line. - if (!promptViaArgv && !promptViaMessageFlag) child.stdin.write(opts.prompt); + // agents already have it on the command line; argv-attach agents carry + // it in the temp-file attachment, so stdin stays empty for all three. + if (!promptViaArgv && !promptViaMessageFlag && !promptViaAttach) child.stdin.write(opts.prompt); child.stdin.end(); } catch {} @@ -202,19 +466,27 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { const parse = makeParser(opts.agent); let stdoutBuf = ""; + // Rolling stderr tail feeding the non-zero-exit error message. + let stderrTail = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { if (closed) return; stdoutBuf += chunk; - // OpenClaw emits one big multi-line JSON document — accumulate and - // parse it once on close instead of trying to parse each line. + // OpenClaw emits one big multi-line JSON document; accumulate and + // parse it once on close rather than trying to parse each line. if (opts.agent === "openclaw") return; let nl: number; while ((nl = stdoutBuf.indexOf("\n")) !== -1) { const line = stdoutBuf.slice(0, nl); stdoutBuf = stdoutBuf.slice(nl + 1); if (!line) continue; - for (const part of parse(line)) { + const parts = parse(line); + // Any line that parsed to at least one event proves the turn + // is alive, so it resets the zcode silence watchdog (no-op for the + // agents that never armed one). Dropped noise lines do not reset: + // they are exactly the "silence" the watchdog exists to catch. + if (parts.length > 0) resetSilenceTimer(); + for (const part of parts) { // Some agents (bob) may echo the entire prompt back as the first // streamed delta. Suppress that to avoid polluting the user-facing // output with the system prompt. @@ -224,6 +496,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + else if (part.kind === "error") safeEnqueue({ type: "error", message: part.message }); else safeEnqueue({ type: "raw", text: line.slice(0, 240) }); } } @@ -231,15 +504,19 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP); safeEnqueue({ type: "stderr", text: chunk }); }); child.on("error", (err) => { + clearSilenceTimer(); safeEnqueue({ type: "error", message: err.message }); + cleanupArgvAttach(); safeClose(); }); child.on("close", (code) => { + clearSilenceTimer(); if (opts.agent === "openclaw") { // OpenClaw's `agent --local --json` emits one pretty-printed JSON // document on stdout. The visible reply is at @@ -299,25 +576,134 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + else if (part.kind === "error") safeEnqueue({ type: "error", message: part.message }); } } } + // argv-attach teardown: kill the Linux mount holder and remove the + // prompt temp dir (no-op for every other agent). + cleanupArgvAttach(); + // A non-zero exit is a failed turn: emit the error before done + // so the consumer's failure gate renders the red error state (an + // exit-0/unknown-code close keeps the historical done-only shape). + // The message quotes the stderr tail for diagnosability; the full + // stderr has already streamed as `stderr` log events. + if (opts.agent === "zcode" && code != null && code !== 0) { + const hint = stderrTail.trim(); + safeEnqueue({ + type: "error", + message: `ZCode exited with code ${code}.${hint ? ` stderr: ${hint.slice(-STDERR_HINT_MAX)}` : ""}`, + }); + } safeEnqueue({ type: "done", code }); safeClose(); }); const onAbort = () => { + clearSilenceTimer(); try { child?.kill("SIGTERM"); } catch {} + cleanupArgvAttach(); safeClose(); }; opts.signal?.addEventListener("abort", onAbort, { once: true }); }, - cancel() {}, + cancel() { + // Stream consumer cancelled. Release the argv-attach turn's resources + // (mount holder + prompt temp file + silence watchdog); the child + // itself is governed by the abort signal, unchanged generic behavior. + clearSilenceTimer(); + cleanupArgvAttach(); + }, }); } +// ─── ZCode AppImage self-mount (Linux) ──────────────────────────────── +// +// Shared helper for the argv-attach (CLI one-shot) trunk: on Linux the +// zcode.cjs bundle lives inside the AppImage squashfs, so the turn +// self-mounts the AppImage first and spawns node against the mounted +// /resources/glm/zcode.cjs. The generic trunk owns the process +// lifecycle (spawn + kill). + +/** + * Self-mount the ZCode AppImage. Spawns + * `AppImage --appimage-mount`, which prints the FUSE mount point to stdout + * (first line) and stays alive holding the mount for as long as it runs. + * Returns the mount child (killed on teardown) and the resolved mount point. + * + * Bounded to ~5s so a missing FUSE / corrupt AppImage / permission error + * surfaces a clear error event rather than hanging the turn. The + * mount child is detached from the abort signal here only to the extent of + * the timeout; the caller wires `signal` into the surrounding teardown. + */ +async function mountZcodeAppImage( + appImage: string, + opts: { cwd?: string; signal?: AbortSignal }, +): Promise<{ mountChild: ChildProcessWithoutNullStreams; mountPoint: string }> { + const mountChild = spawn(appImage, ["--appimage-mount"], { + cwd: opts.cwd ?? process.cwd(), + // All-pipe stdio keeps the type ChildProcessWithoutNullStreams (matching + // the CLI child). The mount child never reads stdin; an idle pipe + // is harmless and gets torn down with the child on teardown. + stdio: ["pipe", "pipe", "pipe"], + }); + const mountPoint = await new Promise((resolve, reject) => { + let buf = ""; + let settled = false; + const settle = (err: Error | null, value?: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + mountChild.stdout.removeAllListeners(); + mountChild.removeListener("error", onError); + mountChild.removeListener("close", onClose); + if (opts.signal && onSignalAbort) { + opts.signal.removeEventListener("abort", onSignalAbort); + } + if (err) { + try { mountChild.kill("SIGTERM"); } catch {} + reject(err); + } else { + resolve(value!); + } + }; + const timer = setTimeout(() => { + settle(new Error("ZCode AppImage mount timed out (no mount point after 5s). Is FUSE available and is the AppImage executable?")); + }, 5_000); + const onError = (err: Error) => settle(err); + // The mount child should stay alive holding the FUSE mount; ANY exit before + // a mount point is printed is a failure (FUSE missing, corrupt AppImage, + // permission denied, or killed). After success the idempotent guard no-ops. + const onClose = () => + settle(new Error("ZCode AppImage mount exited before producing a mount point (FUSE missing, AppImage corrupt, or permission denied?)")); + // Honor an abort during the mount window (up to 5s). The outer onAbort is + // only registered after this await resolves, so without this wiring a + // cancel during mount would hold the FUSE mount until the timeout fires. + const onSignalAbort = () => settle(new Error("ZCode AppImage mount aborted")); + mountChild.stdout.setEncoding("utf8"); + mountChild.stdout.on("data", (chunk: string) => { + buf += chunk; + const nl = buf.indexOf("\n"); + if (nl !== -1) { + const mp = buf.slice(0, nl).trim(); + if (mp) settle(null, mp); + else settle(new Error("ZCode AppImage mount produced an empty mount point")); + } + }); + mountChild.on("error", onError); + mountChild.on("close", onClose); + if (opts.signal?.aborted) { + settle(new Error("ZCode AppImage mount aborted")); + } else if (opts.signal) { + opts.signal.addEventListener("abort", onSignalAbort, { once: true }); + } + }); + return { mountChild, mountPoint }; +} + + function errorStream(message: string): ReadableStream { return new ReadableStream({ start(controller) { diff --git a/next/src/lib/agents/zcode-model-binding.ts b/next/src/lib/agents/zcode-model-binding.ts new file mode 100644 index 00000000..6e01242c --- /dev/null +++ b/next/src/lib/agents/zcode-model-binding.ts @@ -0,0 +1,1217 @@ +/** + * Per-turn model binding for the ZCode CLI one-shot adapter. The picker + * mirrors ZCode's own headless provider registry — every provider the + * spawned CLI would materialize, in the order it would list them — so the + * chips a user picks from are exactly the routes the turn can take. + * + * The registry has two kinds of members (verified against the ZCode 3.14 + * source drop; receipts below): + * + * - **Account providers**: catalog `providerRules` entries whose + * `access.mode` is `individual-coding-plan`. The headless CLI expands an + * account provider only when the credential store holds its identity key + * AND the api-key key named by that identity — it reads + * `account-provider::identity`, then requires + * `account-provider:coding-plan::account::api-key` + * (standalone-account-provider-runtime.ts). Only `zcode login` writes the + * identity key (OAuth → user id, API key → sha256 prefix); the GUI never + * does, and the team shell keys the GUI auto-lays at login have no + * identity key, so team/start/off-peak never satisfy the pairing. The + * enumeration mirrors that pairing exactly: it lists only what the CLI + * itself would materialize. + * - **Personal providers**: `provider_config.json` `providerRules` entries + * whose `access` carries an `apiKey` (template instances get their key + * material from the personal entry; the template schema has no key of + * its own). Template instances list the template's `builtinModelIds` + * union the entry's `personalModelIds`; custom entries list + * `personalModelIds` alone. + * + * Ordering mirrors the resolver: the account family group first in catalog + * declaration order, then personal providers in the user's `providerOrder` + * (`resolveOwnedOrder`: unordered entries follow in declaration order). + * Model order within a provider is `resolveOwnedOrder(builtinModelIds, + * personalModelIds, modelOrder)`. Reasoning levels come from the catalog's + * all-vendor `modelRules` regex table (last matching rule with values wins, + * case-insensitive full match). A model no rule gives levels to is still + * listed and bindable — the binding then carries no `reasoningLevel`. + * Enabled is the overlay of catalog `builtinProviderModelRules` / + * `templateModelRules` and personal `providerModelRules` (an explicit + * `enabled: false` drops the entry; a missing entry keeps it on), and + * `hidden` providers are filtered like the GUI's picker. + * + * The default chain mirrors the CLI's own (`model-selection-config.ts`): + * the configured `config.defaultModelSelection` while it still selects + * something in this registry (legacy `providerId`s migrated; only CLI + * login / the TUI / import ever write that key — never the GUI), else the + * first registry provider's first model at its highest level. + * + * Binding mechanism (unchanged from the verified flow): clone the user's + * `~/.zcode/v2/provider_config.json` to a temp file, write the exact + * `config.defaultModelSelection` into the clone, and hand the child the + * paired env vars `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` (the clone) and + * `ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` (the bundled catalog file validated + * here). The pair is required by the CLI's runtime-paths pair check. The + * user's real config file is never written; the clone is a per-turn temp + * file owned by the invoke layer's cleanup. + * + * The one real write is the identity bridge, and it only ever fires for an + * account target: if an explicit pick names an account provider whose + * api-key key exists but whose identity key is missing, the bridge appends + * that one key atomically (tmp file + rename, add-only — an existing + * identity value is never modified, and every spawn re-checks first). + * Without the key the headless CLI drops the provider from its registry and + * silently reroutes the turn. `setting.json` is not read at all: its family + * keys are GUI navigation state with no headless consumer. + * + * Refusal chain (any broken link refuses the spawn with an actionable + * message): catalog unreadable → provider config unreadable → no usable + * provider (ready is the single "no keyed service" state) → explicit pick + * resolution (provider without access / model not on the provider / + * unsupported level). Nothing silently falls back. + * + * Files read (all private ZCode formats): the bundled catalog next to the + * install (`/resources/config/provider/zcode-builtin.json`, + * runtime cache as a freshness-ordered second choice; schemaVersion must + * be 1), `~/.zcode/v2/provider_config.json`, and + * `~/.zcode/v2/credentials.json`. From credentials only key names are + * read, plus the value of the identity keys (a plaintext account id by + * ZCode's own convention — `zcode login` writes it unencrypted); the + * api-key values are encrypted at rest and are never read. + */ + +import { + mkdirSync, + readFileSync, + readdirSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +/** Narrow `unknown` to a plain JSON object: not null, not an array. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string" && v.length > 0) + : []; +} + +/** Env var carrying the personal provider config (name from ZCode's runtime-paths.ts). */ +export const ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV = "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"; +/** Env var carrying the builtin provider catalog (name from ZCode's runtime-paths.ts). */ +export const ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"; + +/** + * Why a detected install is not ready to run turns. Single state (v3): the + * registry mirror holds no usable provider, whatever the reason (nothing + * logged in, nothing keyed, catalog gone). The settings card carries one + * guidance line for it. + */ +export type ZcodeNotReadyReason = "no-usable-provider"; + +/** One enumerated model: its id plus the reasoning levels the catalog's + * `modelRules` give it. Empty `levels` = no level table; the model stays + * selectable and binds without a `reasoningLevel`. */ +export interface ZcodeRegistryModel { + modelId: string; + levels: string[]; +} + +/** One usable provider in registry order, as the headless CLI would list it. */ +export interface ZcodeRegistryProvider { + providerId: string; + /** Catalog `providerName` for account providers, the personal entry's name otherwise. */ + providerName: string; + kind: "account" | "personal"; + /** Account providers only: the identity the credential pair was built from. */ + accountIdentity?: string; + models: ZcodeRegistryModel[]; +} + +/** One picker chip as the detect layers render it (ModelOption-shaped). */ +export interface ZcodeModelOption { + /** `"/"` or `"//"`. */ + id: string; + /** `" ([, ])"` — the service name + * disambiguates the same model id across providers. */ + label: string; + providerId: string; +} + +/** What the "Default" chip currently resolves to (the same chain a default + * pick binds): the provider, its display name, the model, and the level + * (null when the model has no level table). */ +export interface ZcodeDefaultChoice { + providerId: string; + providerName: string; + modelId: string; + reasoningLevel: string | null; +} + +/** The exact binding written into the temp clone (CLI's ModelSelection + * shape; `reasoningLevel` null = omit the option entirely). */ +export interface ZcodeModelSelection { + providerId: string; + modelId: string; + reasoningLevel: string | null; +} + +/** The bundled catalog file we validated against, and where it came from. */ +export interface ZcodeCatalogFile { + path: string; + source: "install" | "cache"; + data: unknown; +} + +/** ─── Default paths (self-contained) ─── */ + +export function defaultZcodePersonalConfigPath(): string { + return join(homedir(), ".zcode", "v2", "provider_config.json"); +} +export function defaultZcodeCredentialsPath(): string { + return join(homedir(), ".zcode", "v2", "credentials.json"); +} +export function defaultZcodeRuntimeProviderDir(): string { + return join(homedir(), ".zcode", "v2", "runtime", "provider"); +} + +/** Shared tolerant JSON reader: null on missing/unreadable/malformed. */ +function readJsonFile(filePath: string): unknown { + let text: string; + try { + text = readFileSync(filePath, "utf8"); + } catch { + return null; + } + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +/** ─── Bundled catalog resolution: install file first, cache by freshness ─── */ + +/** + * The bundled catalog written by the same install as the cjs we spawn: + * `/resources/config/provider/zcode-builtin.json` where the cjs is + * `/resources/glm/zcode.cjs`. Holds for Windows, macOS, .deb, and + * the Linux AppImage mount alike; the GUI host passes exactly this path + * (per desktopProviderConfig.ts). + */ +export function zcodeBundledCatalogPathForCjs(cjsPath: string): string { + return join(dirname(dirname(cjsPath)), "config", "provider", "zcode-builtin.json"); +} + +/** + * Cache platform segment (resolveZCodeBuiltinClientPlatform): windows|linux| + * darwin + x86_64|aarch64. Exported only so tests can build the cache tree the + * resolver scans; callers should use `resolveZcodeCatalogFile()`. + */ +export function zcodeCachePlatform(): string { + const target = process.platform === "win32" ? "windows" : process.platform; + const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch; + return `${target}-${arch}`; +} + +/** Numeric-aware version-dir compare ("3.14.10" > "3.14.9" > "3.9.2"). */ +function compareVersionDirs(a: string, b: string): number { + const sa = a.split(/[.-]/); + const sb = b.split(/[.-]/); + for (let i = 0; i < Math.max(sa.length, sb.length); i++) { + const na = Number(sa[i]); + const nb = Number(sb[i]); + const va = Number.isFinite(na) ? na : null; + const vb = Number.isFinite(nb) ? nb : null; + if (va === null && vb === null) { + const c = sa[i]!.localeCompare(sb[i]!); + if (c !== 0) return c; + } else if (va === null) return -1; + else if (vb === null) return 1; + else if (va !== vb) return va - vb; + } + return 0; +} + +function isCatalogShape(data: unknown): boolean { + if (!isRecord(data)) return false; + // schemaVersion gate (v1 unchanged to date). + if (data.schemaVersion !== 1) return false; + return isRecord(data.config); +} + +/** + * Locate + read the builtin catalog. Order: the install-bundled file + * first (version-consistent with the spawned cjs by construction), then the + * runtime cache by freshness (highest version dir, newest file within it). + * Returns null when neither yields a schema-valid file; that is the + * catalog-unreadable refusal link. + */ +export function resolveZcodeCatalogFile(opts: { + cjsPath: string; + cacheRoot?: string; +}): ZcodeCatalogFile | null { + const installPath = zcodeBundledCatalogPathForCjs(opts.cjsPath); + const installData = readJsonFile(installPath); + if (isCatalogShape(installData)) { + return { path: installPath, source: "install", data: installData }; + } + const cacheRoot = opts.cacheRoot ?? defaultZcodeRuntimeProviderDir(); + const platformDir = join(cacheRoot, zcodeCachePlatform()); + let versions: string[] = []; + try { + versions = readdirSync(platformDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(compareVersionDirs) + .reverse(); + } catch { + return null; + } + for (const version of versions) { + let candidates: string[] = []; + try { + candidates = readdirSync(join(platformDir, version), { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith("endpoint-")) + .map((e) => join(platformDir, version, e.name, "zcode-builtin.json")); + } catch { + continue; + } + // Freshest file mtime wins within a version dir. + let best: { path: string; mtime: number } | null = null; + for (const candidate of candidates) { + try { + const mtime = statSync(candidate).mtimeMs; + if (!best || mtime > best.mtime) best = { path: candidate, mtime }; + } catch { + // unreadable candidate; skip + } + } + if (best) { + const data = readJsonFile(best.path); + if (isCatalogShape(data)) return { path: best.path, source: "cache", data }; + } + } + return null; +} + +/** ─── Registry mirror (pure): providers, models, levels, ordering ─── */ + +/** + * `resolveOwnedOrder` from ZCode's owned-order.ts, mirrored: unordered + * builtin ids stay ahead of the user's order, unordered personal ids + * follow it, duplicates keep first occurrence. Keeps the adapter's model + * and provider ordering byte-compatible with the GUI's reorder semantics. + */ +function resolveOwnedOrder( + builtinIds: readonly string[], + personalIds: readonly string[], + requestedOrder: readonly string[], +): string[] { + const uniqueInOrder = (values: readonly string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; + }; + const builtin = uniqueInOrder(builtinIds); + const builtinSet = new Set(builtin); + const personal = uniqueInOrder(personalIds).filter((id) => !builtinSet.has(id)); + const members = new Set([...builtin, ...personal]); + const ordered = uniqueInOrder(requestedOrder).filter((id) => members.has(id)); + const orderedSet = new Set(ordered); + return [ + ...builtin.filter((id) => !orderedSet.has(id)), + ...ordered, + ...personal.filter((id) => !orderedSet.has(id)), + ]; +} + +interface RuleTables { + modelRules: unknown[]; + builtinProviderModelRules: unknown[]; + templateModelRules: unknown[]; + personalProviderModelRules: unknown[]; +} + +function readRuleTables(catalog: unknown, personal: unknown): RuleTables { + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const catalogModelRules = isRecord(catalogConfig.modelConfigRules) + ? catalogConfig.modelConfigRules + : {}; + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const personalModelRules = isRecord(personalConfig.modelConfigRules) + ? personalConfig.modelConfigRules + : {}; + return { + modelRules: Array.isArray(catalogModelRules.modelRules) ? catalogModelRules.modelRules : [], + builtinProviderModelRules: Array.isArray(catalogModelRules.builtinProviderModelRules) + ? catalogModelRules.builtinProviderModelRules + : [], + templateModelRules: Array.isArray(catalogModelRules.templateModelRules) + ? catalogModelRules.templateModelRules + : [], + personalProviderModelRules: Array.isArray(personalModelRules.providerModelRules) + ? personalModelRules.providerModelRules + : [], + }; +} + +/** + * The reasoning levels for a model id from the catalog's all-vendor + * `modelRules` regex table: the LAST rule (array order, per the engine's + * overlay semantics) whose `^(?:modelMatch)$` case-insensitively + * full-matches the id and specifies non-empty + * `config.optionSpecs.reasoningLevel.values`. Invalid regexes are skipped + * (tolerant). A model no rule gives values to has no levels — still + * selectable, bound without a reasoningLevel. + */ +function resolveLevels(modelRules: readonly unknown[], modelId: string): string[] { + let levels: string[] = []; + for (const rule of modelRules) { + if (!isRecord(rule)) continue; + const modelMatch = typeof rule.modelMatch === "string" ? rule.modelMatch : null; + if (!modelMatch) continue; + let matches: boolean; + try { + matches = new RegExp(`^(?:${modelMatch})$`, "i").test(modelId); + } catch { + continue; + } + if (!matches) continue; + const values = extractLevelValues(rule.config); + if (values) levels = values; + } + return levels; +} + +function extractLevelValues(config: unknown): string[] | null { + if (!isRecord(config) || !isRecord(config.optionSpecs)) return null; + const spec = isRecord(config.optionSpecs.reasoningLevel) + ? config.optionSpecs.reasoningLevel + : null; + if (!spec || !Array.isArray(spec.values)) return null; + const values = spec.values.filter( + (v): v is string => typeof v === "string" && v.trim().length > 0, + ); + return values.length > 0 ? values : null; +} + +/** + * The enabled model table for one provider: the ordered id union with the + * three enabled tables overlaid last-write-wins in catalog-first order + * (builtinProviderModelRules → templateModelRules → personal + * providerModelRules, mirroring composeEffective's [...builtin, ...personal]), + * so an explicit `enabled: false` drops a model and a later explicit + * `enabled: true` re-enables it; a missing entry keeps it on. Each listed + * model carries its `modelRules` levels. + */ +function enabledModelsFor( + tables: RuleTables, + input: { + providerId: string; + templateId: string | null; + builtinModelIds: readonly string[]; + personalModelIds: readonly string[]; + modelOrder: readonly string[]; + }, +): ZcodeRegistryModel[] { + const ids = resolveOwnedOrder(input.builtinModelIds, input.personalModelIds, input.modelOrder); + const enabled = new Map(); + const overlay = ( + rules: readonly unknown[], + matches: (rule: Record) => boolean, + ) => { + for (const rule of rules) { + if (!isRecord(rule)) continue; + if (!matches(rule) || typeof rule.modelId !== "string") continue; + const config = isRecord(rule.config) ? rule.config : null; + if (config && typeof config.enabled === "boolean") enabled.set(rule.modelId, config.enabled); + } + }; + overlay(tables.builtinProviderModelRules, (r) => r.providerId === input.providerId); + if (input.templateId) { + overlay(tables.templateModelRules, (r) => r.templateId === input.templateId); + } + overlay(tables.personalProviderModelRules, (r) => r.providerId === input.providerId); + const models: ZcodeRegistryModel[] = []; + for (const modelId of ids) { + if (enabled.get(modelId) === false) continue; + models.push({ modelId, levels: resolveLevels(tables.modelRules, modelId) }); + } + return models; +} + +/** A catalog account provider entry (already filtered to the headless + * expansion mode), with its model table computed. */ +interface AccountCandidate { + providerId: string; + providerName: string; + models: ZcodeRegistryModel[]; +} + +function accountCandidates(catalog: unknown, tables: RuleTables): AccountCandidate[] { + const out: AccountCandidate[] = []; + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const providerConfigRules = isRecord(catalogConfig.providerConfigRules) + ? catalogConfig.providerConfigRules + : {}; + if (!Array.isArray(providerConfigRules.providerRules)) return out; + for (const rule of providerConfigRules.providerRules) { + if (!isRecord(rule)) continue; + const providerId = nonEmptyString(rule.providerId); + if (!providerId) continue; + const config = isRecord(rule.config) ? rule.config : null; + const access = config && isRecord(config.access) ? config.access : null; + // The headless CLI only expands individual coding plans; team / start / + // off-peak account providers never materialize standalone. + if (!access || access.mode !== "individual-coding-plan") continue; + const providerName = nonEmptyString(rule.providerName) ?? providerId; + const models = enabledModelsFor(tables, { + providerId, + templateId: null, + builtinModelIds: config ? stringArray(config.builtinModelIds) : [], + personalModelIds: [], + modelOrder: [], + }); + out.push({ providerId, providerName, models }); + } + return out; +} + +/** A personal provider_config.json entry, parsed tolerantly. Entries with a + * missing template or disabled/hidden/keyless config stay in this list (an + * explicit pick of them must fail with the provider refusal, not a stale + * one) but are filtered out of the registry. */ +interface PersonalCandidate { + providerId: string; + templateId: string | null; + providerName: string; + apiKey: string | null; + enabled: boolean; + hidden: boolean; + models: ZcodeRegistryModel[]; +} + +function personalCandidates(catalog: unknown, personal: unknown, tables: RuleTables): PersonalCandidate[] { + const byId = new Map(); + const catalogConfig = isRecord(catalog) && isRecord(catalog.config) ? catalog.config : {}; + const providerConfigRules = isRecord(catalogConfig.providerConfigRules) + ? catalogConfig.providerConfigRules + : {}; + const templateRules = Array.isArray(providerConfigRules.templateRules) + ? providerConfigRules.templateRules + : []; + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const personalProviderRules = + isRecord(personalConfig.providerConfigRules) && + Array.isArray(personalConfig.providerConfigRules.providerRules) + ? personalConfig.providerConfigRules.providerRules + : []; + for (const rule of personalProviderRules) { + if (!isRecord(rule)) continue; + const providerId = nonEmptyString(rule.providerId); + if (!providerId || byId.has(providerId)) continue; + const config = isRecord(rule.config) ? rule.config : null; + const access = config && isRecord(config.access) ? config.access : null; + const apiKey = access ? nonEmptyString(access.apiKey) : null; + const templateId = nonEmptyString(rule.templateId); + let templateBuiltinIds: string[] = []; + if (templateId) { + const template = templateRules.find( + (t) => isRecord(t) && t.templateId === templateId && isRecord(t.config), + ); + if (template && isRecord(template.config)) { + templateBuiltinIds = stringArray(template.config.builtinModelIds); + } + } + const models = enabledModelsFor(tables, { + providerId, + templateId, + builtinModelIds: templateBuiltinIds, + personalModelIds: config ? stringArray(config.personalModelIds) : [], + modelOrder: config ? stringArray(config.modelOrder) : [], + }); + byId.set(providerId, { + providerId, + templateId, + providerName: nonEmptyString(rule.providerName) ?? providerId, + apiKey, + enabled: !(config?.enabled === false), + hidden: config?.visibility === "hidden", + models, + }); + } + return [...byId.values()]; +} + +/** + * The account credential pair mirror: the identity key's value (a plaintext + * account id by ZCode's own write convention) plus the existence of the + * api-key key it names — exactly the entitlement check the headless CLI + * performs (standalone-account-provider-runtime.ts reads the identity key, + * then requires the api-key key built from it). Null when the provider is + * not materialized standalone. + */ +function accountPair( + credentials: Record, + providerId: string, +): { identity: string } | null { + const raw = credentials[`account-provider:${providerId}:identity`]; + const identity = typeof raw === "string" ? raw.trim() : ""; + if (!identity) return null; + const apiKeyKey = + `account-provider:coding-plan:${providerId}:account:${encodeURIComponent(identity)}:api-key`; + return credentials[apiKeyKey] !== undefined ? { identity } : null; +} + +/** + * Find ANY coding-plan api-key key for the provider and recover the account + * identity from its name — the identity bridge's raw material. The GUI + * writes these keys with the identity embedded (encodeURIComponent) in the + * name, so no credential value is read. + */ +function findCodingPlanApiKeyEntry( + credentials: Record, + providerId: string, +): { key: string; identity: string } | null { + const prefix = `account-provider:coding-plan:${providerId}:account:`; + for (const key of Object.keys(credentials)) { + if (!key.startsWith(prefix) || !key.endsWith(":api-key")) continue; + const raw = key.slice(prefix.length, -":api-key".length); + try { + return { key, identity: decodeURIComponent(raw) }; + } catch { + return { key, identity: raw }; + } + } + return null; +} + +/** + * Enumerate the registry the headless CLI would build: paired account + * individual plans (catalog declaration order) first, then keyed personal + * providers (user `providerOrder`, unordered entries in declaration + * order). Providers whose model table empties out under the enabled + * overlays are dropped, like the CLI's registry builder. Pure; tolerant of + * every malformed input (worst case: an empty registry → the single + * no-usable-provider refusal). + */ +export function enumerateZcodeRegistry( + catalog: unknown, + personal: unknown, + credentials: unknown, +): ZcodeRegistryProvider[] { + const tables = readRuleTables(catalog, personal); + const credentialRecord = isRecord(credentials) ? credentials : {}; + const out: ZcodeRegistryProvider[] = []; + for (const account of accountCandidates(catalog, tables)) { + if (account.models.length === 0) continue; + const pair = accountPair(credentialRecord, account.providerId); + if (!pair) continue; + out.push({ + providerId: account.providerId, + providerName: account.providerName, + kind: "account", + accountIdentity: pair.identity, + models: account.models, + }); + } + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const candidates = personalCandidates(catalog, personal, tables); + const ordered = resolveOwnedOrder( + [], + candidates.map((c) => c.providerId), + stringArray(personalConfig.providerOrder), + ); + const byId = new Map(candidates.map((c) => [c.providerId, c])); + for (const providerId of ordered) { + const entry = byId.get(providerId); + if (!entry) continue; + if (entry.hidden || !entry.enabled || !entry.apiKey) continue; + if (entry.models.length === 0) continue; + out.push({ + providerId: entry.providerId, + providerName: entry.providerName, + kind: "personal", + models: entry.models, + }); + } + return out; +} + +/** ─── Default chain (pure) ─── */ + +/** + * The default-selection chain shared by the Default-chip label and a + * default pick: the configured `config.defaultModelSelection` while it + * still selects a provider+model in this registry (legacy provider ids + * migrated; the GUI never writes the key — only CLI login / TUI / import + * do), else the first registry provider's first model at its highest + * level (the CLI's own `registry-fallback`). A configured level the model + * does not support invalidates the configured default, not the turn. + */ +export function resolveZcodeDefaultSelection( + registry: readonly ZcodeRegistryProvider[], + personal: unknown, +): ZcodeDefaultChoice | null { + const first = registry[0]?.models[0]; + if (!first) return null; + const fallback = (): ZcodeDefaultChoice => ({ + providerId: registry[0]!.providerId, + providerName: registry[0]!.providerName, + modelId: first.modelId, + reasoningLevel: first.levels[first.levels.length - 1] ?? null, + }); + const personalConfig = isRecord(personal) && isRecord(personal.config) ? personal.config : {}; + const configured = isRecord(personalConfig.defaultModelSelection) + ? personalConfig.defaultModelSelection + : null; + if (!configured) return fallback(); + const providerId = + typeof configured.providerId === "string" + ? migrateLegacyZcodeProviderId(configured.providerId) + : null; + const provider = providerId ? registry.find((p) => p.providerId === providerId) : undefined; + const model = + provider && typeof configured.modelId === "string" + ? provider.models.find((m) => m.modelId === configured.modelId) + : undefined; + if (!provider || !model) return fallback(); + const configuredLevel = + isRecord(configured.options) && typeof configured.options.reasoningLevel === "string" + ? configured.options.reasoningLevel + : null; + if (configuredLevel && !model.levels.includes(configuredLevel)) return fallback(); + return { + providerId: provider.providerId, + providerName: provider.providerName, + modelId: model.modelId, + reasoningLevel: configuredLevel ?? model.levels[model.levels.length - 1] ?? null, + }; +} + +/** ─── Picker id codec ─── */ + +/** + * Encode a (providerId, modelId, level?) picker choice into the single + * string the UI store persists and the invoke layer receives as `model`: + * `"/[/]"` — the provider-namespace convention + * the openclaw (`openrouter/anthropic/…`) and opencode (`anthropic/…`) + * pickers use, so the same model id served by two providers never collides. + * Level-less models (no `modelRules` entry) encode without the suffix. + */ +export function encodeZcodeModelChoice( + providerId: string, + modelId: string, + reasoningLevel?: string | null, +): string { + return reasoningLevel + ? `${providerId}/${modelId}/${reasoningLevel}` + : `${providerId}/${modelId}`; +} + +/** + * Structural decode: the first segment is always the providerId (ZCode + * provider ids never contain `/`); the rest is the model id plus an + * optional trailing level. Model ids CAN contain `/` (the catalog's + * openrouter templates list vendor-prefixed ids like + * `anthropic/claude-fable-5.1`), so the split point is not fixed here — + * validation against the registry picks the reading. Returns null for ids + * that cannot even be structurally split. + */ +export function decodeZcodeModelChoice(id: string): { providerId: string } | null { + const segments = id.split("/"); + if (segments.length < 2 || segments.some((s) => s.length === 0)) return null; + return { providerId: segments[0]! }; +} + +/** ─── Picker state (the detect layer's one call) ─── */ + +/** The detect layer's composed ZCode state. */ +export interface ZcodePickerState { + /** True when the registry mirror holds ≥1 usable provider. */ + ready: boolean; + reason: ZcodeNotReadyReason | null; + /** Chips in registry order; empty when not ready (caller keeps the + * static floor and the invoke-time refusal reports the error). */ + options: ZcodeModelOption[]; + /** What "Default" resolves to right now; null when not ready. */ + defaultChoice: ZcodeDefaultChoice | null; +} + +/** + * Compose the picker state for a resolved cjs: enumerate the registry + * (catalog + personal config + credential key names), expand it into + * provider-namespaced chips, and resolve the Default label with the same + * chain a default pick binds. Read-only; shared by the next + cli detect + * mirrors so the two front ends cannot drift. + */ +export function readZcodePickerState(opts: { + cjsPath: string; + personalConfigPath?: string; + credentialsPath?: string; + cacheRoot?: string; +}): ZcodePickerState { + const notReady: ZcodePickerState = { + ready: false, + reason: "no-usable-provider", + options: [], + defaultChoice: null, + }; + const catalog = resolveZcodeCatalogFile({ cjsPath: opts.cjsPath, cacheRoot: opts.cacheRoot }); + if (!catalog) return notReady; + const personal = readJsonFile(opts.personalConfigPath ?? defaultZcodePersonalConfigPath()); + if (!isRecord(personal)) return notReady; + const credentials = readJsonFile(opts.credentialsPath ?? defaultZcodeCredentialsPath()); + const registry = enumerateZcodeRegistry(catalog.data, personal, credentials); + if (registry.length === 0) return notReady; + const options: ZcodeModelOption[] = []; + for (const provider of registry) { + for (const model of provider.models) { + const levelChips = model.levels.length ? model.levels : [null]; + for (const level of levelChips) { + options.push({ + id: encodeZcodeModelChoice(provider.providerId, model.modelId, level), + label: level + ? `${model.modelId} (${provider.providerName}, ${level})` + : `${model.modelId} (${provider.providerName})`, + providerId: provider.providerId, + }); + } + } + } + return { + ready: true, + reason: null, + options, + defaultChoice: resolveZcodeDefaultSelection(registry, personal), + }; +} + +/** ─── Legacy provider-id migration (one-way, read boundary only) ─── */ + +const LEGACY_PROVIDER_IDS: Record = { + "builtin:bigmodel-coding-plan": "account:bigmodel-individual-coding-plan", + "builtin:zai-coding-plan": "account:zai-individual-coding-plan", + "builtin:bigmodel-start-plan": "account:bigmodel-start-plan", + "builtin:zai-start-plan": "account:zai-start-plan", +}; + +/** + * Migrate legacy Coding-Plan provider ids to the current `account:*` ids + * (migrateLegacyModelProviderId semantics: known legacy ids map one-way; + * anything else passes through). Needed because a CLI-login-written + * `defaultModelSelection` on an upgraded install may still carry the + * legacy id. + */ +export function migrateLegacyZcodeProviderId(providerId: string): string { + return LEGACY_PROVIDER_IDS[providerId] ?? providerId; +} + +/** ─── Per-turn binding preparation (the invoke layer's one call) ─── */ + +/** The refusal links, in resolution order. */ +export type ZcodeBindingRefusal = + | "catalog-unreadable" + | "provider-config-unreadable" + | "no-usable-provider" + | "provider-no-access" + | "model-not-on-provider" + | "level-unsupported"; + +export type ZcodeBindingResult = + | { + ok: true; + /** The exact selection written into the clone (and returned for logging/tests). */ + selection: ZcodeModelSelection; + /** Path of the temp clone; its directory is the caller's to clean up. */ + clonePath: string; + /** The catalog file validated against, also the paired builtin env value. */ + builtinCatalogPath: string; + } + | { + ok: false; + code: ZcodeBindingRefusal; + /** Failure message (English, matching sibling invoke errors). */ + message: string; + }; + +/** Everything an explicit pick resolved to, plus the bridge material when + * the target is an account provider that still needs its identity key. */ +interface ResolvedSelection { + selection: ZcodeModelSelection; + /** Account target: the identity to ensure before spawning. */ + bridgeIdentity?: string; +} + +/** Resolve the pick's tail against one provider's model table: the first + * reading (shortest model id, then level-less) that validates becomes the + * selection (optionally carrying bridge material); a validated model with + * an unsupported level is the level refusal; no reading fits is the + * model-not-on-provider refusal. */ +function pickFromModels( + raw: string, + providerId: string, + models: readonly ZcodeRegistryModel[], + bridgeIdentity?: string, +): { ok: true; value: ResolvedSelection } | { ok: false; code: ZcodeBindingRefusal; message: string } { + const outcome = matchModelReadings(raw, models); + if (outcome.match) { + return { + ok: true, + value: { + selection: { + providerId, + modelId: outcome.match.modelId, + reasoningLevel: outcome.match.reasoningLevel, + }, + ...(bridgeIdentity ? { bridgeIdentity } : {}), + }, + }; + } + if (outcome.levelIssue) return { ok: false, code: "level-unsupported", message: outcome.levelIssue }; + return { + ok: false, + code: "model-not-on-provider", + message: modelNotOnProviderMessage(providerId), + }; +} + +/** + * Resolve an explicit picker id against the candidate tables. Model ids may + * contain `/` (openrouter-style ids), so the split between model id and + * trailing level is settled by validation: the shortest model reading whose + * provider+model exist wins, with a valid level preferred over an invalid + * one at the same reading. Personal providers that exist but are not + * usable (key removed / disabled / hidden / no enabled models) and account + * providers without their credential pair surface as `provider-no-access` + * rather than a stale-pick error, so the message can name the fix. + */ +function resolveExplicitPick( + raw: string, + input: { + registry: readonly ZcodeRegistryProvider[]; + accounts: readonly AccountCandidate[]; + personals: readonly PersonalCandidate[]; + credentials: Record; + }, +): { ok: true; value: ResolvedSelection } | { ok: false; code: ZcodeBindingRefusal; message: string } { + const decoded = decodeZcodeModelChoice(raw); + if (!decoded) { + return { + ok: false, + code: "model-not-on-provider", + message: + `ZCode: the saved model choice "${raw}" is stale or malformed (expected "/[/]"). Re-scan agents in Settings and pick a model again, then retry.`, + }; + } + const providerId = decoded.providerId; + + // Prefer the usable registry: its models already passed the enabled + // overlays and its account entries proved their credential pair. + const registryEntry = input.registry.find((p) => p.providerId === providerId); + if (registryEntry) { + return pickFromModels(raw, providerId, registryEntry.models); + } + + // Known account provider, not in the registry: its credential pair did + // not hold. An api-key key alone is still bindable — the identity bridge + // below completes the pair — but no key at all (or a stale identity + // value) is the provider-no-access refusal. + const account = input.accounts.find((a) => a.providerId === providerId); + if (account) { + const apiKeyEntry = findCodingPlanApiKeyEntry(input.credentials, providerId); + if (!apiKeyEntry) { + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: no saved Coding Plan credential for ${providerId}. Open ZCode, log in to your Coding Plan, then retry.`, + }; + } + const pair = accountPair(input.credentials, providerId); + if (pair) { + // Paired but not in the registry: every model was disabled. Nothing + // bindable remains on the provider. + return { + ok: false, + code: "model-not-on-provider", + message: modelNotOnProviderMessage(providerId), + }; + } + const identityRaw = input.credentials[`account-provider:${providerId}:identity`]; + if (typeof identityRaw === "string" && identityRaw.trim()) { + // An identity value exists but names no api-key key: add-only means + // the bridge must not overwrite it, and binding anyway would let the + // CLI drop the provider and silently reroute. Refuse instead. + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: the saved identity for ${providerId} is stale (its api-key credential is missing). Log in again in ZCode (zcode login), then retry.`, + }; + } + return pickFromModels(raw, providerId, account.models, apiKeyEntry.identity); + } + + // Known personal provider, not usable right now (key removed / disabled / + // hidden / no enabled models left). + const personal = input.personals.find((p) => p.providerId === providerId); + if (personal) { + return { + ok: false, + code: "provider-no-access", + message: + `ZCode: provider ${personal.providerName} (${providerId}) has no usable model right now (missing API key, disabled, or every model disabled). Open ZCode, restore its key or enable a model, then rescan and retry.`, + }; + } + + return { + ok: false, + code: "model-not-on-provider", + message: + `ZCode: the saved model choice "${raw}" does not match any provider in your ZCode config. Re-scan agents in Settings and pick a model again, then retry.`, + }; +} + +function modelNotOnProviderMessage(providerId: string): string { + return `ZCode: that model is not available on ${providerId}. Re-scan agents in Settings and pick another model, then retry.`; +} + +/** + * Try every reading of the id's tail as (modelId, level?): shortest model + * first, level-less reading last. A reading whose model exists but whose + * level is unsupported is remembered so the refusal can list the supported + * levels; a fully valid reading wins immediately. + */ +function matchModelReadings( + raw: string, + models: readonly ZcodeRegistryModel[], +): { + match: { modelId: string; reasoningLevel: string | null } | null; + levelIssue: string | null; +} { + const segments = raw.split("/"); + let levelIssue: string | null = null; + // Level readings: level = segments[k], model = segments[1..k); then the + // level-less reading model = segments[1..n). + for (let k = 1; k <= segments.length - 1; k++) { + const modelId = segments.slice(1, k).join("/"); + const level = segments[k]!; + const entry = models.find((m) => m.modelId === modelId); + if (!entry) continue; + if (entry.levels.includes(level)) { + return { match: { modelId: entry.modelId, reasoningLevel: level }, levelIssue: null }; + } + levelIssue = + `ZCode: reasoning level "${level}" is not supported for ${entry.modelId} on this provider (supported: ${ + entry.levels.join(", ") || "none" + }). Re-scan agents in Settings and pick again.`; + } + const levelless = segments.slice(1).join("/"); + const entry = models.find((m) => m.modelId === levelless); + if (entry) return { match: { modelId: entry.modelId, reasoningLevel: null }, levelIssue: null }; + return { match: null, levelIssue }; +} + +/** + * Resolve + write the per-turn binding: the refusal chain, then one temp + * artifact — a clone of the user's personal provider config with the exact + * `config.defaultModelSelection` — plus, for an account target, the one + * sanctioned credential write (the identity bridge; see module header). + * + * `model` semantics: undefined / "default" binds the registry default — + * the CLI-login/TUI-written `defaultModelSelection` while it still selects + * something in the registry (legacy provider ids migrated), else the first + * registry provider's first model at its highest level. Any other string + * is an explicit pick and must validate against the registry, else the + * matching refusal fires; a bare or stale id never silently reroutes. + */ +export function prepareZcodeModelBinding(opts: { + cjsPath: string; + /** Picker id: undefined | "default" | "/[/]". */ + model?: string; + /** Directory for the temp artifacts, the invoke layer's attach temp dir. + * Its existing cleanup removes everything with the prompt file. */ + attachDir: string; + personalConfigPath?: string; + credentialsPath?: string; + cacheRoot?: string; +}): ZcodeBindingResult { + const catalog = resolveZcodeCatalogFile({ cjsPath: opts.cjsPath, cacheRoot: opts.cacheRoot }); + if (!catalog) { + return { + ok: false, + code: "catalog-unreadable", + message: + "ZCode: the bundled provider catalog could not be read (neither next to the install nor in the runtime cache). Reinstall ZCode or point ZCODE_BIN at a full install, then retry.", + }; + } + const personal = readJsonFile(opts.personalConfigPath ?? defaultZcodePersonalConfigPath()); + if (!isRecord(personal) || !isRecord(personal.config)) { + return { + ok: false, + code: "provider-config-unreadable", + message: + "ZCode: the provider config (~/.zcode/v2/provider_config.json) is missing or malformed. Open the ZCode GUI once so it can initialize the config, then retry.", + }; + } + const credentials = readJsonFile(opts.credentialsPath ?? defaultZcodeCredentialsPath()); + const credentialRecord = isRecord(credentials) ? credentials : {}; + const tables = readRuleTables(catalog.data, personal); + const accounts = accountCandidates(catalog.data, tables); + const personals = personalCandidates(catalog.data, personal, tables); + const registry = enumerateZcodeRegistry(catalog.data, personal, credentialRecord); + + let resolved: ResolvedSelection; + if (opts.model === undefined || opts.model === "default") { + const noUsableProvider = (): ZcodeBindingResult => ({ + ok: false, + code: "no-usable-provider", + message: + "ZCode: no usable model provider. Open ZCode, log in or add a model provider, then retry.", + }); + if (registry.length === 0) return noUsableProvider(); + const def = resolveZcodeDefaultSelection(registry, personal); + // registry[0].models[0] exists by construction, so def is never null here. + if (!def) return noUsableProvider(); + const account = registry.find((p) => p.providerId === def.providerId && p.kind === "account"); + resolved = { + selection: { + providerId: def.providerId, + modelId: def.modelId, + reasoningLevel: def.reasoningLevel, + }, + ...(account?.accountIdentity ? { bridgeIdentity: account.accountIdentity } : {}), + }; + } else { + const outcome = resolveExplicitPick(opts.model, { + registry, + accounts, + personals, + credentials: credentialRecord, + }); + if (!outcome.ok) return outcome; + resolved = outcome.value; + } + + // Identity bridge on the real credential store: account targets only, + // add-only (an existing identity value is never modified), re-checked + // every turn. A default/registry account target already proved its pair, + // so this writes nothing; an explicit account pick whose identity key + // vanished between scans gets it back — without the key the headless + // registry drops the provider and silently reroutes the turn. + const bridgeIdentity = resolved.bridgeIdentity; + if (bridgeIdentity) { + const identityKey = `account-provider:${resolved.selection.providerId}:identity`; + const existing = credentialRecord[identityKey]; + if (typeof existing !== "string" || !existing.trim()) { + const credentialsPath = opts.credentialsPath ?? defaultZcodeCredentialsPath(); + try { + const bridged = { ...credentialRecord, [identityKey]: bridgeIdentity }; + const tmpPath = `${credentialsPath}.html-anything-tmp`; + writeFileSync(tmpPath, JSON.stringify(bridged, null, 2), "utf8"); + renameSync(tmpPath, credentialsPath); + } catch (err) { + return { + ok: false, + code: "provider-no-access", + message: `ZCode: failed to add the Coding Plan identity credential to ${credentialsPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + } + } + + // Deep-clone via JSON round-trip: the source is freshly-parsed JSON anyway. + const clone = JSON.parse(JSON.stringify(personal)) as { config?: Record }; + clone.config = { + ...clone.config, + defaultModelSelection: resolved.selection.reasoningLevel + ? { + providerId: resolved.selection.providerId, + modelId: resolved.selection.modelId, + options: { reasoningLevel: resolved.selection.reasoningLevel }, + } + : { + providerId: resolved.selection.providerId, + modelId: resolved.selection.modelId, + }, + }; + mkdirSync(opts.attachDir, { recursive: true }); + const clonePath = join(opts.attachDir, "provider-config.clone.json"); + try { + writeFileSync(clonePath, JSON.stringify(clone, null, 2), "utf8"); + } catch (err) { + return { + ok: false, + code: "provider-config-unreadable", + message: `ZCode: failed to write the temp provider-config clone: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + return { + ok: true, + selection: resolved.selection, + clonePath, + builtinCatalogPath: catalog.path, + }; +} + +/** True when the environment already carries both provider-config vars; that + * is the user's zero-code reroute. The invoke layer then passes them through + * untouched and skips its own binding. */ +export function zcodeProviderEnvPairSet(env: Readonly): boolean { + const personal = env[ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]?.trim(); + const builtin = env[ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]?.trim(); + return Boolean(personal && builtin); +} + +/** Convenience: the paired env values for a prepared binding. */ +export function zcodeBindingEnv( + result: Extract, +): Record { + return { + [ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV]: result.clonePath, + [ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV]: result.builtinCatalogPath, + }; +} + +/** Variable names the ZCode desktop host exports into every terminal it + * spawns. A dev server started from one of those terminals inherits them, + * and the invoke layer then passes the host's provider config through + * instead of binding itself, masking real first-run behaviour. */ +const ZCODE_HOST_INJECTED_PROVIDER_ENV_VARS: readonly string[] = [ + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE_ENV, + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE_ENV, +]; + +/** Remove the host-injected provider-config vars from `env` (mutating) and + * return the names that were actually present. Dev-server startup calls + * this once so the server behaves as if launched from a clean terminal; + * when neither var is set it is a no-op. */ +export function stripZcodeHostProviderEnv(env: NodeJS.ProcessEnv): string[] { + const removed: string[] = []; + for (const name of ZCODE_HOST_INJECTED_PROVIDER_ENV_VARS) { + if (env[name] !== undefined) { + delete env[name]; + removed.push(name); + } + } + return removed; +} diff --git a/next/src/lib/i18n.ts b/next/src/lib/i18n.ts index 683e5f13..fc632a5d 100644 --- a/next/src/lib/i18n.ts +++ b/next/src/lib/i18n.ts @@ -69,11 +69,15 @@ export interface Dict { "model.label": string; "model.defaultHint.prefix": string; "model.defaultHint.suffix": string; + "model.defaultHint.zcode": string; "model.defaultLabel": string; + "model.staleNotice": string; // Agent card "agent.selected": string; "agent.notInstalled": string; + "agent.notReady": string; + "agent.notReady.noUsableProvider": string; "agent.customBin.eyebrow": string; "agent.customBin.subtitle": string; "agent.customBin.detected": string; @@ -85,6 +89,7 @@ export interface Dict { "protocol.argvMessage": string; "protocol.acp": string; "protocol.piRpc": string; + "protocol.argvAttach": string; // Settings modal "settings.eyebrow": string; @@ -429,10 +434,15 @@ const en: Dict = { "model.label": "Pick a model for {agent}", "model.defaultHint.prefix": "Default omits ", "model.defaultHint.suffix": " and lets the CLI pick.", + "model.defaultHint.zcode": "Default uses the first model in the list.", "model.defaultLabel": "Default (CLI config)", + "model.staleNotice": "The saved model is no longer in this agent's list; reverted to Default.", "agent.selected": "Selected", "agent.notInstalled": "Not installed", + "agent.notReady": "Installed · not ready", + "agent.notReady.noUsableProvider": + "ZCode has no usable model provider yet. Open ZCode, log in or add a model provider, then rescan.", "agent.customBin.eyebrow": "custom path", "agent.customBin.subtitle": "Override the auto-detected binary for {agent}", "agent.customBin.detected": "Auto-detected:", @@ -445,6 +455,7 @@ const en: Dict = { "protocol.argvMessage": "argv · batch JSON", "protocol.acp": "ACP JSON-RPC · not wired", "protocol.piRpc": "pi-rpc · not wired", + "protocol.argvAttach": "argv-attach · stream-json", "settings.eyebrow": "Settings", "settings.titlePart1": "Configure", @@ -791,10 +802,15 @@ const zhCN: Dict = { "model.label": "为 {agent} 选个模型", "model.defaultHint.prefix": "选 Default 时不传 ", "model.defaultHint.suffix": ", 由 CLI 自己挑", + "model.defaultHint.zcode": "选 Default 时用列表里的第一个模型。", "model.defaultLabel": "Default (CLI config)", + "model.staleNotice": "保存的模型已不在该 agent 列表中,已回退为 Default。", "agent.selected": "SELECTED", "agent.notInstalled": "未安装", + "agent.notReady": "已安装 · 未就绪", + "agent.notReady.noUsableProvider": + "ZCode 里还没有可用的模型服务。打开 ZCode 登录或添加一个模型 provider,然后重新扫描。", "agent.customBin.eyebrow": "自定义路径", "agent.customBin.subtitle": "手动指定 {agent} 的二进制路径", "agent.customBin.detected": "自动检测到:", @@ -806,6 +822,7 @@ const zhCN: Dict = { "protocol.argvMessage": "argv · 整段 JSON", "protocol.acp": "ACP JSON-RPC · 暂未接入", "protocol.piRpc": "pi-rpc · 暂未接入", + "protocol.argvAttach": "argv-attach · stream-json", "settings.eyebrow": "设置", "settings.titlePart1": "配置", diff --git a/next/src/lib/store.ts b/next/src/lib/store.ts index 162ab443..5f85e068 100644 --- a/next/src/lib/store.ts +++ b/next/src/lib/store.ts @@ -5,7 +5,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { deleteTaskRuns, putRun } from "@/lib/history/db"; -export type ModelOption = { id: string; label: string }; +/** + * A model picker entry. Mirrors the server-side ModelOption in + * `lib/agents/detect.ts`. `providerId` is ZCode-only: the plan chips + * carry the GUI-selected plan's catalog provider for the per-turn binding. + * Absent for every other agent. + */ +export type ModelOption = { id: string; label: string; providerId?: string }; export type AgentInfo = { id: string; @@ -14,11 +20,19 @@ export type AgentInfo = { available: boolean; path?: string; /** UI uses this to badge unsupported / batch adapters. Mirrors AgentProtocol on the server. */ - protocol: "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc"; + protocol: "stdin" | "argv" | "argv-message" | "argv-attach" | "acp" | "pi-rpc"; /** Curated model list for the picker. Always begins with `default`. */ models: ModelOption[]; /** True for ACP / pi-rpc adapters where Convert returns a friendly error. */ unsupported?: boolean; + /** + * ZCode-only: installed is not the same as ready. True when the registry + * mirror holds at least one usable provider (a paired Coding Plan or a + * keyed personal provider). False + notReadyReason renders the amber + * "已安装 · 未就绪" card state. Absent for every other agent. + */ + ready?: boolean; + notReadyReason?: import("./agents/zcode-model-binding").ZcodeNotReadyReason; }; export type ConvertStatus = "idle" | "running" | "done" | "error"; @@ -71,9 +85,9 @@ export type Task = { status: ConvertStatus; log: LogEntry[]; stats: RunStats; - // sample-derived fields — when populated, the next convert switches to + // sample-derived fields: when populated, the next convert switches to // diff-edit mode and asks the agent to make minimal changes to baseHtml - // instead of regenerating from scratch. + // rather than regenerating from scratch. baseContent?: string; baseHtml?: string; /** id of the source sample (if any), so the gallery can mark it loaded */ diff --git a/next/src/lib/use-convert.ts b/next/src/lib/use-convert.ts index 061da5f9..b43b53e6 100644 --- a/next/src/lib/use-convert.ts +++ b/next/src/lib/use-convert.ts @@ -65,7 +65,7 @@ export function useConvert() { // diff-edit mode: if the task was loaded from a sample (or the user has // already converted once) AND the content has actually changed, we ship // the previous (baseContent, baseHtml) so the API can ask the agent for - // minimal edits instead of a fresh regeneration. This preserves the + // minimal edits rather than a fresh regeneration. This preserves the // design system AND saves output tokens. const task = store.tasks.find((t) => t.id === taskId); const isEdit = @@ -113,6 +113,11 @@ export function useConvert() { const dec = new TextDecoder(); let buf = ""; let lastEvent = ""; + // zcode gate: a refused binding / failed turn must end the task in + // red error state, not a fake-green "done". Other agents keep the + // historical behavior (their CLIs may exit non-zero after usable + // output; html-anything does not re-interpret them here). + let turnFailed = false; while (true) { const { value, done } = await reader.read(); @@ -139,15 +144,16 @@ export function useConvert() { } catch { continue; } - handleEvent(taskId, event, data, startedAt); + if (handleEvent(taskId, event, data, startedAt, req.agent)) turnFailed = true; } } const endedAt = Date.now(); useStore.getState().patchStatsFor(taskId, { endedAt, durationMs: endedAt - startedAt }); - useStore.getState().setStatusFor(taskId, "done"); + useStore.getState().setStatusFor(taskId, turnFailed ? "error" : "done"); // record the just-finished (content, html) as the new diff-edit baseline - // so the user's next edit goes through diff mode instead of full regen - useStore.getState().commitBaseFor(taskId); + // so the user's next edit goes through diff mode rather than a full regen + // (never on a failed turn; the partial output is not a baseline) + if (!turnFailed) useStore.getState().commitBaseFor(taskId); } catch (err) { if ((err as Error)?.name === "AbortError") { useStore.getState().pushLogFor(taskId, { kind: "info", text: "已取消" }); @@ -169,7 +175,38 @@ export function useConvert() { return { run, cancel }; } -function handleEvent(taskId: string, event: string, data: unknown, startedAt: number) { +/** + * zcode gate: does this SSE event mark the turn failed? An `error` event + * (refused binding / spawn failure / stream error) or a non-zero exit, gated + * on zcode only. Other agents keep the historical behavior (their CLIs may + * exit non-zero after usable output; the app does not re-interpret them + * here). Exported for unit tests. + */ +export function zcodeTurnFailed( + agent: string, + event: string, + data: Record, +): boolean { + if (agent !== "zcode") return false; + if (event === "error") return true; + if (event === "done") { + const code = data.code; + return code != null && code !== 0; + } + return false; +} + +/** + * Fold one SSE event into the task's log/stats. Returns true when the event + * marks the turn failed (see {@link zcodeTurnFailed}). + */ +function handleEvent( + taskId: string, + event: string, + data: unknown, + startedAt: number, + agent: string, +): boolean { const d = data as Record; const store = useStore.getState(); const elapsed = Date.now() - startedAt; @@ -202,9 +239,9 @@ function handleEvent(taskId: string, event: string, data: unknown, startedAt: nu break; } case "html": { - // Agent decided to write the HTML to a file via the Write tool instead - // of streaming it. The parser rescued the file's content from the - // tool_use input — REPLACE the accumulated text (preamble + "已输出至 …" + // Agent decided to write the HTML to a file via the Write tool rather + // than streaming it. The parser rescued the file's content from the + // tool_use input; REPLACE the accumulated text (preamble + "已输出至 …" // confirmation) so the preview shows the real document. if (typeof d.text === "string") { store.setHtmlFor(taskId, d.text); @@ -276,6 +313,7 @@ function handleEvent(taskId: string, event: string, data: unknown, startedAt: nu break; } } + return zcodeTurnFailed(agent, event, d); } function formatBytes(n: number) { diff --git a/next/src/lib/use-draft.ts b/next/src/lib/use-draft.ts index 510264c9..ebc80c4b 100644 --- a/next/src/lib/use-draft.ts +++ b/next/src/lib/use-draft.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { useStore } from "./store"; +import { resolveAgentModel } from "./agent-models"; type DraftReq = { instruction: string; @@ -14,7 +15,7 @@ type DraftStatus = "idle" | "running" | "done" | "error"; * Streams a markdown draft from the selected coding agent and appends it * to the active task's textarea content as it arrives. Mirrors the SSE * conventions of `useConvert()` (start / delta / meta / done events) but - * targets `task.content` instead of `task.html` and uses `/api/draft`, + * targets `task.content` rather than `task.html` and uses `/api/draft`, * which prompts the agent for plain markdown — never HTML. */ export function useDraft() { @@ -41,11 +42,10 @@ export function useDraft() { return; } const taskId = store.activeTaskId; - const agentModels = store.agentModels; - const model = - agentModels[agent] && agentModels[agent] !== "default" - ? agentModels[agent] - : undefined; + // A stale persisted pick falls back to Default and is never sent on. + const agentInfo = store.agents.find((a) => a.id === agent); + const resolvedModel = resolveAgentModel(agentInfo?.models, store.agentModels[agent]); + const model = resolvedModel !== "default" ? resolvedModel : undefined; const binOverride = store.agentBinOverrides[agent]?.trim() || undefined; const ctl = new AbortController();