From 7e900b95726744f7878b6daab26833b9776c6dd5 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Mon, 26 Oct 2026 12:42:58 +0800 Subject: [PATCH] feat(prompt): add --effort for headless model reasoning Add a Claude Code-style --effort flag for -p/--prompt runs: - parse --effort alongside --model and pass it through ZCODE_CLI_PROMPT_EFFORT - apply model+effort through a transient object selection so the requested reasoning level survives setModel's default fill - resolve the current or resumed model when only --effort is given - validate the level against the model catalog before sending a prompt and list the available levels - support auto to keep the current/default effort Update the runtime patch with a legacy upgrade path, runtime checks, docs and unit tests. Verified: bun run test:unit (902 pass), bun run typecheck, bun run check, and real -p runs for model+effort, effort-only, resume+effort and unsupported levels. --- README.md | 13 ++- docs/CONFIGURATION.md | 1 + docs/CONFIGURATION.zh-CN.md | 1 + scripts/check-runtime.ts | 1 + scripts/sync-runtime.ts | 68 +++++++++++--- src/launcher.ts | 11 ++- src/prompt-model.ts | 65 +++++++++++--- test/prompt-model.test.ts | 171 ++++++++++++++++++++++++++++++------ 8 files changed, 276 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ab8a7b7..45f004f 100644 --- a/README.md +++ b/README.md @@ -480,16 +480,25 @@ not available on `PATH`. ## Configuration -Select a model for one headless prompt without changing the saved default: +Select a model and reasoning effort for one headless prompt without changing the +saved default: ```bash -zcode --model account:zai-individual-coding-plan/GLM-5.3-Flash --prompt 'Reply with exactly: ok' +zcode --model account:zai-individual-coding-plan/GLM-5.3-Flash --effort high --prompt 'Reply with exactly: ok' ``` `--model` requires a full provider/model ID and `--prompt` (or `-p`). It also overrides a resumed session's model for that run. Unknown or unavailable models fail before a prompt is sent, rather than falling back to the default. +`--effort` selects the model's reasoning level for that prompt. Levels are +model-specific (for example `disabled`, `low`, `high` and `max`); run the TUI's +`/effort list` for the current model, or inspect `optionSpecs.reasoningLevel` in +the provider catalog. `--effort auto` keeps the current or resumed default. +Without `--effort`, `--model` uses the model's default level. Both flags require +`--prompt` (or `-p`) and leave the saved default for new sessions unchanged; the +run's model and effort are recorded on that session. + ZCode reads MCP servers, hooks, plugins, permissions, network, storage and display settings from `~/.zcode/cli/setting.json` (or `%USERPROFILE%\.zcode\cli\setting.json` on Windows). Project settings use diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 81f7ea3..0164fed 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -101,6 +101,7 @@ reported rather than replaced by old settings. | Providers and model metadata | Shared native `provider_config.json` | | Default model in `/settings` | Writes the shared default and applies it to the current session | | `/model`, model cycling and reasoning effort | Change/persist this session's native selection; shared default stays unchanged | +| `--model` / `--effort` on `--prompt` | Per-run model and reasoning-level override; the saved default for new sessions stays unchanged | | `/new` | Reads the current shared default | | Resume/restart | Restores the session's model and reasoning options | | Language and memory | Reads Desktop `localePreference` / `memoryEnabled`; explicit CLI settings override these values | diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index fe3e4b4..073e35d 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -84,6 +84,7 @@ CLI 专属字段迁移到 `~/.zcode/cli/setting.json`,省略 provider、main/l | Provider 和模型元数据 | 共享的原生 `provider_config.json` | | `/settings` 中的默认模型 | 写入共享默认选择,并应用到当前会话 | | `/model`、模型循环切换、推理强度 | 修改并保存当前会话选择,不改共享默认值 | +| `--prompt` 上的 `--model`/`--effort` | 仅覆盖本次运行的模型和推理强度;不改共享默认值(会话会记录本次选择) | | `/new` | 读取当前共享默认模型 | | 恢复会话/重启 | 恢复该会话保存的模型和推理选项 | | 语言与记忆 | 读取桌面端 `localePreference`/`memoryEnabled`,显式 CLI 设置优先 | diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 9a555fd..d85aa88 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -117,6 +117,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !runtimeSource.includes('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"') || !runtimeSource.includes("ZCODE_CLI_CREDENTIALS_FILE") || !runtimeSource.includes("ZCODE_CLI_PROMPT_MODEL") + || !runtimeSource.includes("ZCODE_CLI_PROMPT_EFFORT") || !runtimeSource.includes(".loadSessionTranscript=async()=>await(await") || !runtimeSource.includes('"loadSessionContextMessages"') || !runtimeSource.includes(".readGoal=async()=>await(await") diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index f85a305..1895148 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -1401,22 +1401,69 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string { } const restoredPromptModelSwitch = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&\(([A-Za-z_$][\w$]*)\?\.cliDefault&&await \1\.prepareResume\(\),!\2\?\.transient\|\|\2\?\.cliDefault\)\?/u; +const promptModelMarker = "$zCliDefaultPromptModel"; +const promptEffortMarker = "$zCliDefaultPromptEffort"; +const legacyPromptModelInsertion = /let \$zCliDefaultPromptModel=process\.env\.ZCODE_CLI_PROMPT_MODEL;if\(\$zCliDefaultPromptModel\)await ([A-Za-z_$][\w$]*)\.setModel\(\$zCliDefaultPromptModel,\{transient:true,cliDefault:true\}\);/u; + +function promptOverrideInsertion(app: string): string { + // The string selection path intentionally fills a missing reasoning level + // with the model default. An explicit --effort must use the object path so + // the requested level survives; transient avoids a separate persisted + // model-default change. + return [ + `let ${promptModelMarker}=process.env.ZCODE_CLI_PROMPT_MODEL;`, + `let ${promptEffortMarker}=process.env.ZCODE_CLI_PROMPT_EFFORT;`, + `if(${promptEffortMarker}&&${promptEffortMarker}.toLowerCase()==="auto")${promptEffortMarker}=void 0;`, + `if(${promptModelMarker}&&!${promptEffortMarker}){await ${app}.setModel(${promptModelMarker},{transient:true,cliDefault:true});}`, + `else if(${promptEffortMarker}){`, + `let $zPromptSelection,$zPromptCurrent;`, + `if(${promptModelMarker}){`, + `let $zPromptSlash=${promptModelMarker}.indexOf("/");`, + `if($zPromptSlash<=0||$zPromptSlash===${promptModelMarker}.length-1)throw new Error("Invalid prompt model selection.");`, + `$zPromptSelection={providerId:${promptModelMarker}.slice(0,$zPromptSlash),modelId:${promptModelMarker}.slice($zPromptSlash+1)};`, + `}else{`, + `await ${app}.readExecutionState?.();`, + `$zPromptCurrent=${app}.getCurrentModelOption?.();`, + `if(!$zPromptCurrent?.ref)throw new Error("Cannot apply --effort without a current model. Pass --model or select a model first.");`, + `$zPromptSelection={...$zPromptCurrent.ref};`, + `}`, + `let $zPromptOption=${app}.getModelOption?.($zPromptSelection);`, + `let $zPromptLevels=($zPromptOption?.reasoning?.levels??$zPromptCurrent?.reasoning?.levels??[]).map($zLevel=>$zLevel.value);`, + `if($zPromptOption&&$zPromptLevels.length===0)throw new Error("Model "+$zPromptSelection.providerId+"/"+$zPromptSelection.modelId+" does not support reasoning effort.");`, + `let $zPromptLevel=$zPromptLevels.find($zLevel=>$zLevel===${promptEffortMarker})??$zPromptLevels.find($zLevel=>$zLevel.toLowerCase()===${promptEffortMarker}.toLowerCase());`, + `if($zPromptLevels.length>0&&!$zPromptLevel)throw new Error("Unsupported reasoning effort: "+${promptEffortMarker}+". Available: "+$zPromptLevels.join(", ")+".");`, + `await ${app}.setModel({...$zPromptSelection,options:{...$zPromptSelection.options,reasoningLevel:$zPromptLevel??${promptEffortMarker}}},{transient:true,cliDefault:true});`, + `}` + ].join(""); +} export function patchRuntimePromptModel(runtime: string): string { - const marker = "$zCliDefaultPromptModel"; - if (runtime.includes(marker) && restoredPromptModelSwitch.test(runtime)) return runtime; + if (runtime.includes(promptEffortMarker) && restoredPromptModelSwitch.test(runtime)) return runtime; const anchor = /if\([A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.traceId,[A-Za-z_$][\w$]*\.memoryBench/u.exec(runtime); if (!anchor) throw new Error("ZCode runtime is incompatible with the prompt model patch."); // cli-default prompt admission reads CliDefaultSelection, not the runtime - // selection. A transient --model override must update that default without + // selection. A transient prompt override must update that default without // persisting a session model change. const switchAnchor = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&(?:!([A-Za-z_$][\w$]*)\?\.transient|\(!([A-Za-z_$][\w$]*)\?\.transient\|\|\3\?\.cliDefault\))\?/u.exec(runtime); - if (!switchAnchor) throw new Error("ZCode runtime is incompatible with the prompt model patch (cli-default selection anchor missing)."); - const insertion = `let ${marker}=process.env.ZCODE_CLI_PROMPT_MODEL;if(${marker})await ${anchor[1]}.setModel(${marker},{transient:true,cliDefault:true});`; - let patched = runtime.includes(marker) ? runtime : runtime.slice(0, anchor.index) + insertion + runtime.slice(anchor.index); - const context = switchAnchor[1]; - const options = switchAnchor[2] ?? switchAnchor[3]; - patched = patched.replace(switchAnchor[0], `${context}.cliDefaultSelection&&(${options}?.cliDefault&&await ${context}.prepareResume(),!${options}?.transient||${options}?.cliDefault)?`); + const switchAlreadyPatched = restoredPromptModelSwitch.test(runtime); + if (!switchAnchor && !switchAlreadyPatched) { + throw new Error("ZCode runtime is incompatible with the prompt model patch (cli-default selection anchor missing)."); + } + const insertion = promptOverrideInsertion(anchor[1]); + let patched: string; + if (legacyPromptModelInsertion.test(runtime)) { + // Upgrade a runtime that only has the earlier --model insertion. + patched = runtime.replace(legacyPromptModelInsertion, insertion); + } else if (runtime.includes(promptModelMarker)) { + throw new Error("ZCode runtime is incompatible with the prompt model patch (partial prompt override)."); + } else { + patched = runtime.slice(0, anchor.index) + insertion + runtime.slice(anchor.index); + } + if (switchAnchor) { + const context = switchAnchor[1]; + const options = switchAnchor[2] ?? switchAnchor[3]; + patched = patched.replace(switchAnchor[0], `${context}.cliDefaultSelection&&(${options}?.cliDefault&&await ${context}.prepareResume(),!${options}?.transient||${options}?.cliDefault)?`); + } return patched; } @@ -1869,7 +1916,8 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ id: "prompt-model", requirement: "required", apply: patchRuntimePromptModel, - verify: (runtime) => runtime.includes("$zCliDefaultPromptModel") && runtime.includes("cliDefault:true") + verify: (runtime) => runtime.includes(promptModelMarker) && runtime.includes(promptEffortMarker) + && runtime.includes("ZCODE_CLI_PROMPT_EFFORT") && runtime.includes("cliDefault:true") && restoredPromptModelSwitch.test(runtime) }, { diff --git a/src/launcher.ts b/src/launcher.ts index 2601315..586f35c 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -34,7 +34,7 @@ import { import { requestAppServer } from "./app-server-client.ts"; import { runPluginCommand } from "./plugin-cli.ts"; import { missingCodingPlanKey } from "./prompt-preflight.ts"; -import { extractPromptModel } from "./prompt-model.ts"; +import { extractPromptOverrides } from "./prompt-model.ts"; import { capabilitiesFromExtractionMetadata, type RuntimeCliOptionType @@ -290,6 +290,7 @@ function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}, inspectConfiguration delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN; delete env.ZCODE_CLI_MIGRATE_CONFIG; delete env.ZCODE_CLI_PROMPT_MODEL; + delete env.ZCODE_CLI_PROMPT_EFFORT; const distributionVersion = readDistributionVersion(); const appVersion = readJsonVersion(extractionMetadataPath, "appVersion"); const inherited: NodeJS.ProcessEnv = { @@ -488,10 +489,12 @@ export async function main(args: string[]): Promise { } let promptModel: string | undefined; + let promptEffort: string | undefined; try { - const parsed = extractPromptModel(args, readRuntimeCliOptionTypes()); + const parsed = extractPromptOverrides(args, readRuntimeCliOptionTypes()); args = parsed.args; promptModel = parsed.model; + promptEffort = parsed.effort; } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); return 1; @@ -639,10 +642,12 @@ export async function main(args: string[]): Promise { const runtimeArgs = withDefaultBrowserUse(login.args); const code = await runRuntime(node, runtimeArgs, { ...firstRunSetupEnv(setupPending, runtimeArgs), - ...(promptModel ? { ZCODE_CLI_PROMPT_MODEL: promptModel } : {}) + ...(promptModel ? { ZCODE_CLI_PROMPT_MODEL: promptModel } : {}), + ...(promptEffort ? { ZCODE_CLI_PROMPT_EFFORT: promptEffort } : {}) }); if (code === 0 && (args.includes("--help") || args.includes("-h"))) { console.log("\n --model Select a model for this --prompt run without changing the saved default."); + console.log(" --effort Select the model reasoning effort for this --prompt run; auto keeps the default."); } return code; } catch (error) { diff --git a/src/prompt-model.ts b/src/prompt-model.ts index b7b1f14..205b4c6 100644 --- a/src/prompt-model.ts +++ b/src/prompt-model.ts @@ -1,20 +1,57 @@ import { parseArgs } from "node:util"; import type { RuntimeCliOptionType } from "./runtime-capabilities.ts"; -export function extractPromptModel( +export interface PromptOverrides { + args: string[]; + effort?: string; + model?: string; +} + +function removalIndexes(token: { index: number; inlineValue?: boolean }): number[] { + return token.inlineValue ? [token.index] : [token.index, token.index + 1]; +} + +export function extractPromptOverrides( args: string[], optionTypes: Readonly> -): { args: string[]; model?: string } { +): PromptOverrides { const options = Object.fromEntries(Object.entries(optionTypes).map(([name, type]) => [name, { type }])); - const parsed = parseArgs({ args, allowPositionals: true, strict: false, tokens: true, - options: { ...options, prompt: { type: "string", short: "p" }, model: { type: "string" } } }); - const tokens = parsed.tokens.filter(token => token.kind === "option" && token.name === "model"); - if (tokens.length === 0) return { args }; - if (tokens.length !== 1) throw new Error("Specify --model only once."); - if (typeof parsed.values.prompt !== "string") throw new Error("--model requires --prompt or -p."); - const model = String(parsed.values.model).trim(); - if (!/^[^/\s]+\/\S+$/u.test(model)) throw new Error("--model must be a provider/model ID."); - const token = tokens[0]!; - const removed = new Set([token.index]); - if (token.kind === "option" && !token.inlineValue) removed.add(token.index + 1); - return { args: args.filter((_, index) => !removed.has(index)), model }; + const parsed = parseArgs({ + args, + allowPositionals: true, + strict: false, + tokens: true, + options: { + ...options, + prompt: { type: "string", short: "p" }, + model: { type: "string" }, + effort: { type: "string" } + } + }); + const modelTokens = parsed.tokens.filter(token => token.kind === "option" && token.name === "model"); + const effortTokens = parsed.tokens.filter(token => token.kind === "option" && token.name === "effort"); + if (modelTokens.length === 0 && effortTokens.length === 0) return { args }; + if (modelTokens.length !== 0 && modelTokens.length !== 1) throw new Error("Specify --model only once."); + if (effortTokens.length !== 0 && effortTokens.length !== 1) throw new Error("Specify --effort only once."); + if (typeof parsed.values.prompt !== "string") { + throw new Error(`${modelTokens.length ? "--model" : "--effort"} requires --prompt or -p.`); + } + + const removed = new Set(); + let model: string | undefined; + let effort: string | undefined; + if (modelTokens.length === 1) { + if (typeof parsed.values.model !== "string") throw new Error("--model must be a provider/model ID."); + model = parsed.values.model.trim(); + if (!/^[^/\s]+\/\S+$/u.test(model)) throw new Error("--model must be a provider/model ID."); + for (const index of removalIndexes(modelTokens[0]!)) removed.add(index); + } + if (effortTokens.length === 1) { + if (typeof parsed.values.effort !== "string") throw new Error("--effort requires a value."); + effort = parsed.values.effort.trim(); + if (!effort || effort.startsWith("-") || /\s/u.test(effort)) { + throw new Error("--effort requires a single effort level."); + } + for (const index of removalIndexes(effortTokens[0]!)) removed.add(index); + } + return { args: args.filter((_, index) => !removed.has(index)), model, effort }; } diff --git a/test/prompt-model.test.ts b/test/prompt-model.test.ts index cf4cffa..cce0d06 100644 --- a/test/prompt-model.test.ts +++ b/test/prompt-model.test.ts @@ -1,37 +1,56 @@ import { expect, test } from "bun:test"; -import { extractPromptModel } from "../src/prompt-model.ts"; +import { extractPromptOverrides } from "../src/prompt-model.ts"; import { patchRuntimePromptModel } from "../scripts/sync-runtime.ts"; const model = "account:zai-start-plan/GLM-5.3-Flash"; const options = { prompt: "string", resume: "string" } as const; -test("extracts a per-prompt model while preserving prompt text and resume arguments", () => { - expect(extractPromptModel(["--model", model, "-p", "ok", "--resume", "session"], options)) - .toEqual({ args: ["-p", "ok", "--resume", "session"], model }); - expect(extractPromptModel([`--model=${model}`, "--prompt=ok"], options)) - .toEqual({ args: ["--prompt=ok"], model }); - expect(extractPromptModel(["--prompt", "--model"], options)) +test("extracts per-prompt model and effort while preserving prompt text and resume arguments", () => { + expect(extractPromptOverrides(["--model", model, "--effort", "high", "-p", "ok", "--resume", "session"], options)) + .toEqual({ args: ["-p", "ok", "--resume", "session"], model, effort: "high" }); + expect(extractPromptOverrides([`--model=${model}`, "--effort=high", "--prompt=ok"], options)) + .toEqual({ args: ["--prompt=ok"], model, effort: "high" }); + expect(extractPromptOverrides(["--model", model, "-p", "ok"], options)) + .toEqual({ args: ["-p", "ok"], model }); + expect(extractPromptOverrides(["--effort", "high", "-p", "ok"], options)) + .toEqual({ args: ["-p", "ok"], effort: "high" }); + expect(extractPromptOverrides(["--prompt", "--model"], options)) .toEqual({ args: ["--prompt", "--model"] }); - expect(() => extractPromptModel(["--model", "glm", "-p", "ok"], options)).toThrow(/provider\/model/); - expect(() => extractPromptModel(["--model", model], options)).toThrow(/requires --prompt/); - expect(() => extractPromptModel(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/); + expect(extractPromptOverrides(["--prompt", "--effort"], options)) + .toEqual({ args: ["--prompt", "--effort"] }); + expect(() => extractPromptOverrides(["--model", "glm", "-p", "ok"], options)).toThrow(/provider\/model/); + expect(() => extractPromptOverrides(["--model", model], options)).toThrow(/requires --prompt/); + expect(() => extractPromptOverrides(["--effort", "high"], options)).toThrow(/requires --prompt/); + expect(() => extractPromptOverrides(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/); + expect(() => extractPromptOverrides(["--effort", "high", "--effort", "low", "-p", "ok"], options)).toThrow(/only once/); + expect(() => extractPromptOverrides(["--effort", "", "-p", "ok"], options)).toThrow(/single effort level/); + expect(() => extractPromptOverrides(["--model", model, "--prompt", "ok", "--effort"], options)).toThrow(/requires a value/); + expect(() => extractPromptOverrides(["--effort", "--model", model, "-p", "ok"], options)).toThrow(/single effort level/); }); +const source = [ + 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");', + 'return app.submitPrompt("ok")}', + 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' +].join(""); + +function load(env: Record) { + return new Function("process", `${patchRuntimePromptModel(source)};return {run,setModel}`)({ env }) as { + run: (app: unknown, options: Record) => Promise; + setModel: (context: unknown, options: unknown, selection: unknown) => Promise; + }; +} + +function reasoningModel(providerId = "p", modelId = "m", levels = ["low", "high", "max"]) { + const reasoning = { levels: levels.map(value => ({ value, label: value })) }; + return { ref: { providerId, modelId }, reasoning }; +} + test("switches the cli-default selection before submitting a prompt and propagates invalid models", async () => { - const source = [ - 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}', - 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' - ].join(""); - const patched = patchRuntimePromptModel(source); - expect(patchRuntimePromptModel(patched)).toBe(patched); - expect(patchRuntimePromptModel(patched.replace("f?.cliDefault&&await e.prepareResume(),", ""))).toBe(patched); - expect(() => patchRuntimePromptModel("unsupported")).toThrow(/incompatible/); - expect(() => patchRuntimePromptModel('async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}')) - .toThrow(/cli-default selection anchor missing/); - const load = (env: Record) => new Function("process", `${patched};return {run,setModel}`)({ env }); const events: unknown[] = []; const app = { - traceId: "fixture", setModel: async (...args: unknown[]) => { events.push(args); }, + traceId: "fixture", + setModel: async (...args: unknown[]) => { events.push(args); }, submitPrompt: async () => { events.push("prompt"); return "ok"; } }; expect(await load({ ZCODE_CLI_PROMPT_MODEL: model }).run(app, {})).toBe("ok"); @@ -70,11 +89,111 @@ test("switches the cli-default selection before submitting a prompt and propagat expect(resumes).toBe(1); }); +test("applies model and effort in one transient selection", async () => { + const events: unknown[] = []; + const app = { + traceId: "fixture", + getModelOption: (selection: unknown) => { events.push(["getModelOption", selection]); return reasoningModel("account:zai-start-plan", "GLM-5.3-Flash", ["low", "high", "max"]); }, + setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, + submitPrompt: async () => { events.push("prompt"); return "ok"; } + }; + expect(await load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})).toBe("ok"); + expect(events).toEqual([ + ["getModelOption", { providerId: "account:zai-start-plan", modelId: "GLM-5.3-Flash" }], + ["setModel", { + providerId: "account:zai-start-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "high" } + }, { transient: true, cliDefault: true }], + "prompt" + ]); +}); + +test("applies effort to the resumed current model without a model override", async () => { + const events: unknown[] = []; + const current = reasoningModel("saved-provider", "saved-model", ["disabled", "low", "high"]); + const app = { + traceId: "fixture", + readExecutionState: async () => { events.push("resume"); return { mode: "build", planEnabled: false }; }, + getCurrentModelOption: () => { events.push("getCurrentModelOption"); return current; }, + getModelOption: (selection: unknown) => { events.push(["getModelOption", selection]); return current; }, + setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, + submitPrompt: async () => { events.push("prompt"); return "ok"; } + }; + expect(await load({ ZCODE_CLI_PROMPT_EFFORT: "HIGH" }).run(app, {})).toBe("ok"); + expect(events).toEqual([ + "resume", + "getCurrentModelOption", + ["getModelOption", { providerId: "saved-provider", modelId: "saved-model" }], + ["setModel", { + providerId: "saved-provider", + modelId: "saved-model", + options: { reasoningLevel: "high" } + }, { transient: true, cliDefault: true }], + "prompt" + ]); +}); + +test("rejects an unsupported effort before sending the prompt", async () => { + const events: unknown[] = []; + const app = { + traceId: "fixture", + getModelOption: () => reasoningModel("p", "m", ["low", "high"]), + setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, + submitPrompt: async () => { events.push("prompt"); return "ok"; } + }; + await expect(load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "medium" }).run(app, {})) + .rejects.toThrow(/Unsupported reasoning effort: medium\. Available: low, high\./); + expect(events).toEqual([]); +}); + +test("rejects effort for a model without reasoning levels", async () => { + const app = { + traceId: "fixture", + getModelOption: () => ({ ref: { providerId: "p", modelId: "m" }, reasoning: { levels: [] } }), + setModel: async () => { throw new Error("should not be called"); }, + submitPrompt: async () => { throw new Error("should not be called"); } + }; + await expect(load({ ZCODE_CLI_PROMPT_MODEL: model, ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})) + .rejects.toThrow(/does not support reasoning effort/); +}); + +test("rejects effort-only runs without a current model", async () => { + const app = { + traceId: "fixture", + readExecutionState: async () => ({ mode: "build", planEnabled: false }), + getCurrentModelOption: () => undefined, + setModel: async () => { throw new Error("should not be called"); }, + submitPrompt: async () => { throw new Error("should not be called"); } + }; + await expect(load({ ZCODE_CLI_PROMPT_EFFORT: "high" }).run(app, {})) + .rejects.toThrow(/without a current model/); +}); + +test("auto leaves the current effort unchanged", async () => { + const events: unknown[] = []; + const app = { + traceId: "fixture", + setModel: async (...args: unknown[]) => { events.push(["setModel", ...args]); }, + submitPrompt: async () => { events.push("prompt"); return "ok"; } + }; + expect(await load({ ZCODE_CLI_PROMPT_EFFORT: "auto" }).run(app, {})).toBe("ok"); + expect(events).toEqual(["prompt"]); +}); + +test("upgrades a runtime that only has the earlier model override", () => { + const legacy = source.replace( + 'return app.submitPrompt("ok")}', + 'let $zCliDefaultPromptModel=process.env.ZCODE_CLI_PROMPT_MODEL;if($zCliDefaultPromptModel)await app.setModel($zCliDefaultPromptModel,{transient:true,cliDefault:true});return app.submitPrompt("ok")}' + ); + const patched = patchRuntimePromptModel(legacy); + expect(patched).toContain("$zCliDefaultPromptEffort"); + expect(patched).toContain("ZCODE_CLI_PROMPT_EFFORT"); + expect(patched).not.toContain("if($zCliDefaultPromptModel)await app.setModel($zCliDefaultPromptModel"); + expect(patchRuntimePromptModel(patched)).toBe(patched); +}); + test.each([true, false])("applies a prompt override after lazy resume restores the shared CLI selection (saved valid: %p)", async valid => { - const source = [ - 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}', - 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' - ].join(""); const loaded = new Function("process", `${patchRuntimePromptModel(source)};return {run,setModel}`)({ env: { ZCODE_CLI_PROMPT_MODEL: model } });