From f6ea229f7229ad02ed3714902c2dbbd159b3c494 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 02:22:52 +0800 Subject: [PATCH 1/4] fix(config): O93 support agent.reasoningBudget in config set/unset/show/env --- src/cli/config-commands.ts | 29 +++++- src/cli/config-snapshot.ts | 5 + src/cli/config.ts | 37 ++++++-- test/cli-setup-commands.spec.mjs | 2 +- test/config-reasoning-budget-o93.spec.mjs | 106 ++++++++++++++++++++++ 5 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 test/config-reasoning-budget-o93.spec.mjs diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index 7f3f9993..356ab06e 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -22,6 +22,8 @@ import { type CliConfigOverrides, type CliProviderPreset, type ConfigFile, + parseReasoningBudget, + REASONING_BUDGET_VALUES, } from './config.js'; import { errorMessage } from '../errors.js'; import { isZhLocale, parseLanguageSetting, uiText } from './cli-locale.js'; @@ -75,6 +77,8 @@ function serializeResolvedConfig( maxAgentTurnsSource: resolved.maxAgentTurnsSource, contextTokens: resolved.contextTokens, contextTokensSource: resolved.contextTokensSource, + reasoningBudget: resolved.reasoningBudget ?? 'adaptive', + reasoningBudgetSource: resolved.reasoningBudgetSource ?? 'default', compactionSettings: { ...resolved.compactionSettings }, compactionSettingsSource: resolved.compactionSettingsSource, configWarnings: auditResolvedCliConfig(resolved), @@ -378,7 +382,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s group: 'providers, models & fallback', vars: [ 'MOSS_BEST_OF_N', - 'MOSS_REASONING_BUDGET', + 'MOSS_REASONING_BUDGET (off|adaptive|high; overrides config agent.reasoningBudget)', 'MOSS_MODEL_BALANCED', 'MOSS_MODEL_CHEAP', 'MOSS_MODEL_STRONG', @@ -476,6 +480,8 @@ const ENV_GROUP_ZH: Record = { }; const ENV_NOTE_ZH: Record = { + 'off|adaptive|high; overrides config agent.reasoningBudget': + 'off|adaptive|high;优先于配置 agent.reasoningBudget', 'process env only; a project .env cannot set this': '仅进程环境;项目 .env 不能设置', 'process env or --config-file; a project .env cannot set this': '进程环境或 --config-file;项目 .env 不能设置', @@ -596,6 +602,7 @@ export function renderConfigHelp(zh = false): string { ' moss config set guardrails.input.redactPatterns SECRET=[^\\\\s]+', ' moss config set agent.maxTurns 96', ' moss config set agent.contextTokens 200000', + ' moss config set agent.reasoningBudget high # off|adaptive|high', ' moss config set agent.compaction.reserveTokens 20000', ' moss config set rdkDocs false', ' moss config set rdkDocs.package ../rdk-docs-mcp', @@ -901,8 +908,8 @@ function buildProjectConfigTemplate(): ConfigFile { function supportedConfigKeys(): string { return uiText( - 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language', - '支持的键:provider、model、baseUrl、apiKey、apiKeyEnv、profile、workspace、safetyMode、approvalPolicy、trustedTools、deniedTools、permissions.defaultMode、permissions.allow、permissions.ask、permissions.deny、permissions.deviceTrust、permissions.trustedDevices、rdkDocs、rdkDocs.enabled、rdkDocs.package、promptCache、promptCacheDebug、guardrails.input.blockPatterns、guardrails.input.redactPatterns、guardrails.output.blockPatterns、guardrails.output.redactPatterns、agent.maxTurns、agent.contextTokens、agent.compaction.reserveTokens、agent.compaction.keepRecentTokens、language' + 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.reasoningBudget, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language', + '支持的键:provider、model、baseUrl、apiKey、apiKeyEnv、profile、workspace、safetyMode、approvalPolicy、trustedTools、deniedTools、permissions.defaultMode、permissions.allow、permissions.ask、permissions.deny、permissions.deviceTrust、permissions.trustedDevices、rdkDocs、rdkDocs.enabled、rdkDocs.package、promptCache、promptCacheDebug、guardrails.input.blockPatterns、guardrails.input.redactPatterns、guardrails.output.blockPatterns、guardrails.output.redactPatterns、agent.maxTurns、agent.contextTokens、agent.reasoningBudget、agent.compaction.reserveTokens、agent.compaction.keepRecentTokens、language' ); } @@ -1260,6 +1267,19 @@ function applyConfigSetPair( } catch (err) { return { ok: false, messages: [errorMessage(err)] }; } + } else if (key === 'agent.reasoningBudget') { + const budget = parseReasoningBudget(value); + if (!budget) { + return { + ok: false, + messages: [ + isZhLocale() + ? `agent.reasoningBudget 取值无效:${value}。可选值:${REASONING_BUDGET_VALUES.join('、')}` + : `Unsupported agent.reasoningBudget value: ${value}. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}`, + ], + }; + } + next.agent = { ...next.agent, reasoningBudget: budget }; } else if (key === 'agent.maxTurns' || key === 'agent.contextTokens') { const parsed = parseConfigPositiveInteger(value, key); if (!parsed.ok) { @@ -1485,6 +1505,9 @@ export function runConfigUnset(args: string[], startDir = process.cwd()): void { } else if (key === 'agent.contextTokens') { next.agent = { ...current.agent }; delete next.agent.contextTokens; + } else if (key === 'agent.reasoningBudget') { + next.agent = { ...current.agent }; + delete next.agent.reasoningBudget; } else if (key === 'agent.compaction.reserveTokens') { next.agent = { ...current.agent, compaction: { ...current.agent?.compaction } }; delete next.agent.compaction?.reserveTokens; diff --git a/src/cli/config-snapshot.ts b/src/cli/config-snapshot.ts index 4aa192a2..ba597b34 100644 --- a/src/cli/config-snapshot.ts +++ b/src/cli/config-snapshot.ts @@ -276,6 +276,11 @@ const FIELDS = { 'context tokens', (c: ResolvedCliConfig) => `${c.contextTokens} ${sourceNote(c.contextTokensSource)}`, ], + reasoningBudget: [ + 'reasoning budget', + (c: ResolvedCliConfig) => + `${c.reasoningBudget ?? 'adaptive'} ${sourceNote(c.reasoningBudgetSource ?? 'default')}`, + ], maxOutput: [ 'max output', (c: ResolvedCliConfig) => diff --git a/src/cli/config.ts b/src/cli/config.ts index b267afa5..f3d34f66 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -660,6 +660,17 @@ function mergeModelOutputConfigs( return Object.keys(merged).length > 0 ? merged : undefined; } +export const REASONING_BUDGET_VALUES = ['off', 'adaptive', 'high'] as const; +export type ReasoningBudget = (typeof REASONING_BUDGET_VALUES)[number]; + +export function parseReasoningBudget(value: unknown): ReasoningBudget | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase().trim(); + return (REASONING_BUDGET_VALUES as readonly string[]).includes(normalized) + ? (normalized as ReasoningBudget) + : undefined; +} + function mergeAgentRuntimeConfig( userAgent: ConfigFile['agent'], projectAgent: ConfigFile['agent'] @@ -858,6 +869,11 @@ export function fieldSourcesFor( mark('promptCache', user.promptCache !== undefined, project.promptCache !== undefined, true); mark('guardrails', user.guardrails !== undefined, project.guardrails !== undefined, true); mark('agent.maxTurns', user.agent?.maxTurns !== undefined, project.agent?.maxTurns !== undefined); + mark( + 'agent.reasoningBudget', + user.agent?.reasoningBudget !== undefined, + project.agent?.reasoningBudget !== undefined + ); mark( 'agent.contextTokens', user.agent?.contextTokens !== undefined, @@ -1535,6 +1551,7 @@ export interface ResolvedCliConfig { bestOfN?: number; /** v0.10 W4 (config agent.reasoningBudget + MOSS_REASONING_BUDGET env). */ reasoningBudget?: 'off' | 'adaptive' | 'high'; + reasoningBudgetSource?: string; /** v0.12 (agent.modelTiers + MOSS_MODEL_CHEAP/BALANCED/STRONG env). */ modelTiers?: { cheap?: string; balanced?: string; strong?: string }; modelTiersSource?: string; @@ -2411,13 +2428,18 @@ export function resolveCliConfig( apiKeyFromProjectEnv = true; } } - const rawReasoningBudget = (env.MOSS_REASONING_BUDGET ?? '').toLowerCase().trim(); - const reasoningBudget = - rawReasoningBudget === 'off' || - rawReasoningBudget === 'adaptive' || - rawReasoningBudget === 'high' - ? rawReasoningBudget - : activeConfig.agent?.reasoningBudget; + // Same precedence as agent.maxTurns / agent.contextTokens: a valid + // MOSS_REASONING_BUDGET env wins over config agent.reasoningBudget + // (project layer over user layer), then the runtime default (adaptive). + const envReasoningBudget = parseReasoningBudget(env.MOSS_REASONING_BUDGET); + const configReasoningBudget = parseReasoningBudget(activeConfig.agent?.reasoningBudget); + const reasoningBudget = envReasoningBudget ?? configReasoningBudget; + const reasoningBudgetSource = + envReasoningBudget !== undefined + ? 'MOSS_REASONING_BUDGET' + : configReasoningBudget !== undefined + ? fileLayer('agent.reasoningBudget') + : 'default'; const runBudget = budgetMaxTokens !== undefined || budgetMaxToolCalls !== undefined || @@ -2526,6 +2548,7 @@ export function resolveCliConfig( ...(runBudget ? { budget: runBudget } : {}), ...(bestOfN !== undefined ? { bestOfN } : {}), ...(reasoningBudget ? { reasoningBudget } : {}), + reasoningBudgetSource, ...(hasModelTiers ? { modelTiers, modelTiersSource } : { modelTiersSource }), compactionSettingsSource, configPath: configPaths?.configPath ?? resolveConfigPath(undefined, env), diff --git a/test/cli-setup-commands.spec.mjs b/test/cli-setup-commands.spec.mjs index 9e939ee4..89ad134b 100644 --- a/test/cli-setup-commands.spec.mjs +++ b/test/cli-setup-commands.spec.mjs @@ -78,7 +78,7 @@ const EXPECTED_USAGE = [ // Verbatim current "supported keys" help text (probe: unknown-key error path). const EXPECTED_SUPPORTED_KEYS = [ - 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language', + 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.reasoningBudget, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language', 'Run `moss config --help` for supported keys and usage.', '', ].join('\n'); diff --git a/test/config-reasoning-budget-o93.spec.mjs b/test/config-reasoning-budget-o93.spec.mjs new file mode 100644 index 00000000..94777a31 --- /dev/null +++ b/test/config-reasoning-budget-o93.spec.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * O93: agent.reasoningBudget must be settable via `moss config set`, shown by + * `moss config show` / `moss config env`, and resolved with the same + * precedence as agent.maxTurns (MOSS_REASONING_BUDGET env > config file). + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import * as setup from '../dist/cli/setup.js'; +import { renderConfigEnv, renderConfigJson } from '../dist/cli/config-commands.js'; +import { resolveCliConfig } from '../dist/cli/config.js'; + +function capture(fn) { + const captured = { out: '', err: '' }; + const prevOut = process.stdout.write; + const prevErr = process.stderr.write; + const prevExit = process.exitCode; + process.stdout.write = (c) => ((captured.out += String(c)), true); + process.stderr.write = (c) => ((captured.err += String(c)), true); + process.exitCode = 0; + try { + fn(); + return { ...captured, exitCode: process.exitCode }; + } finally { + process.stdout.write = prevOut; + process.stderr.write = prevErr; + process.exitCode = prevExit ?? 0; + } +} + +function withProjectDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o93-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const cfgPath = (dir) => path.join(dir, '.moss', 'config.json'); +const readCfg = (dir) => JSON.parse(fs.readFileSync(cfgPath(dir), 'utf8')); + +test('config set agent.reasoningBudget accepts off|adaptive|high and unset removes it', () => { + withProjectDir((dir) => { + for (const value of ['off', 'adaptive', 'high', 'HIGH']) { + const r = capture(() => + setup.runConfigSet(['--project', 'agent.reasoningBudget', value], dir) + ); + assert.equal(r.exitCode, 0, `${value} accepted: ${r.err}`); + assert.equal(readCfg(dir).agent.reasoningBudget, value.toLowerCase()); + } + const u = capture(() => setup.runConfigUnset(['--project', 'agent.reasoningBudget'], dir)); + assert.equal(u.exitCode, 0); + assert.equal(readCfg(dir).agent?.reasoningBudget, undefined); + }); +}); + +test('config set agent.reasoningBudget rejects invalid values and lists the choices', () => { + withProjectDir((dir) => { + const r = capture(() => setup.runConfigSet(['--project', 'agent.reasoningBudget', 'max'], dir)); + assert.equal(r.exitCode, 1); + assert.match(r.err, /agent\.reasoningBudget/); + assert.match(r.err, /off/); + assert.match(r.err, /adaptive/); + assert.match(r.err, /high/); + assert.equal(fs.existsSync(cfgPath(dir)), false, 'invalid value writes nothing'); + }); +}); + +test('config show reports agent.reasoningBudget with its source; env wins over config', () => { + withProjectDir((dir) => { + capture(() => setup.runConfigSet(['--project', 'agent.reasoningBudget', 'high'], dir)); + const env = { MOSS_CONFIG_FILE: cfgPath(dir) }; + const shown = JSON.parse(renderConfigJson(undefined, env, dir, {})); + assert.equal(shown.reasoningBudget, 'high'); + assert.notEqual(shown.reasoningBudgetSource, 'default'); + assert.notEqual(shown.reasoningBudgetSource, 'MOSS_REASONING_BUDGET'); + + const withEnv = JSON.parse( + renderConfigJson(undefined, { ...env, MOSS_REASONING_BUDGET: 'off' }, dir, {}) + ); + assert.equal(withEnv.reasoningBudget, 'off'); + assert.equal(withEnv.reasoningBudgetSource, 'MOSS_REASONING_BUDGET'); + + // Invalid env falls back to config (same as before O93). + const badEnv = JSON.parse( + renderConfigJson(undefined, { ...env, MOSS_REASONING_BUDGET: 'bogus' }, dir, {}) + ); + assert.equal(badEnv.reasoningBudget, 'high'); + }); +}); + +test('resolver ignores an invalid agent.reasoningBudget in the config file', () => { + const r = resolveCliConfig({}, { agent: { reasoningBudget: 'turbo' } }); + assert.equal(r.reasoningBudget, undefined); + assert.equal(r.reasoningBudgetSource, 'default'); +}); + +test('config env documents MOSS_REASONING_BUDGET and its config key', () => { + const text = renderConfigEnv(); + assert.match(text, /MOSS_REASONING_BUDGET[^\n]*off\|adaptive\|high[^\n]*agent\.reasoningBudget/); +}); From 5e607b3cf7ded10e634ff187a1266551e95dfff6 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 02:39:40 +0800 Subject: [PATCH 2/4] test(ui): O93 allow reasoningBudget literal tokens in zh surface scan --- test/cli-ui-language.spec.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/cli-ui-language.spec.mjs b/test/cli-ui-language.spec.mjs index 893d353c..5a460777 100644 --- a/test/cli-ui-language.spec.mjs +++ b/test/cli-ui-language.spec.mjs @@ -656,6 +656,7 @@ baseurl apikey apikeyenv profile workspace safetymode approvalpolicy trustedtool permissions defaultmode allow ask deny devicetrust trusteddevices rdkdocs enabled package promptcache promptcachedebug guardrails input output blockpatterns redactpatterns agent maxturns contexttokens maxoutputtokens compaction reservetokens keeprecenttokens language +reasoningbudget off adaptive high task_define record_evidence device_deploy search_code workspace-write balanced cautious autonomous manual acceptedits full never prompt moss_lang moss_config_dir moss_config_file moss_no_bundled_default editor From 8eb25fda430cbfba07776978a1d4fe962fb5a9d3 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 03:14:44 +0800 Subject: [PATCH 3/4] fix(config): O93 complete UX/QA gates for agent.reasoningBudget Help (en/zh) explains meaning and cost/latency; text config show lists value+source; untrusted projects drop reasoningBudget with the usual skipped-settings tip; empty set lists off|adaptive|high; invalid MOSS_REASONING_BUDGET warns once (stderr + configWarnings) then falls back. --- src/cli/config-commands.ts | 40 ++++- src/cli/config-snapshot.ts | 1 + src/cli/config.ts | 53 ++++++- src/cli/onboarding.ts | 2 + src/cli/setup-wizard.ts | 1 + test/config-reasoning-budget-o93.spec.mjs | 176 +++++++++++++++++++++- 6 files changed, 259 insertions(+), 14 deletions(-) diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index 356ab06e..81f90c75 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -31,6 +31,7 @@ import { guessModelProvider, print, renderAuthStatus, sanitizeBaseUrl } from './ import { withoutSecret } from './config-snapshot.js'; import { parsePermissionRuleSpec } from './permission-rules.js'; import { parseCliInteractionMode } from './interaction-mode.js'; +import { untrustedFolderLine } from './workspace-trust.js'; function serializeResolvedConfig( resolved: ReturnType @@ -602,6 +603,8 @@ export function renderConfigHelp(zh = false): string { ' moss config set guardrails.input.redactPatterns SECRET=[^\\\\s]+', ' moss config set agent.maxTurns 96', ' moss config set agent.contextTokens 200000', + ' # agent.reasoningBudget — how hard the model thinks (off|adaptive|high).', + ' # Higher values spend more tokens, take longer, and cost more; off is cheapest/fastest.', ' moss config set agent.reasoningBudget high # off|adaptive|high', ' moss config set agent.compaction.reserveTokens 20000', ' moss config set rdkDocs false', @@ -656,6 +659,11 @@ function renderConfigHelpZh(): string { ' `moss config set baseUrl https://your-gateway.example` # /v1 可写可不写(https://主机/v1 也可以);不必带 /chat/completions', ' `moss config set rdkDocs false`', ' `moss config set rdkDocs.package ../rdk-docs-mcp`', + ' `moss config set agent.maxTurns 96`', + ' `moss config set agent.contextTokens 200000`', + ' # agent.reasoningBudget — 推理强度(可选值 off|adaptive|high)。', + ' # 越高越慢、消耗越多、费用越高;off 最快最省。不受信项目里的该项会被忽略。', + ' `moss config set agent.reasoningBudget high` # 可选值:off|adaptive|high', ' `moss config set language auto|en|zh` # 只写用户配置(不能 --project,项目 .env 也不能设置)', ' # 界面语言优先级:--lang > MOSS_LANG > `language` > 系统区域。auto 仅在区域以 zh 开头时用中文。', ].join('\n'); @@ -666,7 +674,15 @@ export function runConfigShow( options: { json?: boolean; overrides?: CliConfigOverrides } = {} ): void { const overrides = options.overrides ?? {}; + // Tip when an untrusted project tried to set routing fields (incl. reasoningBudget). + const loaded = loadCliConfigFile(process.env, process.argv.slice(2), startDir); + const ignored = loaded.ignoredProjectRouting ?? []; + if (ignored.length > 0) { + const line = untrustedFolderLine(ignored, isZhLocale()); + if (line) console.error(line); + } if (options.json) { + // Pass undefined so renderConfigJson reloads with fieldSources intact. standardOutput.write(`${renderConfigJson(undefined, process.env, startDir, overrides)}\n`); return; } @@ -1268,6 +1284,16 @@ function applyConfigSetPair( return { ok: false, messages: [errorMessage(err)] }; } } else if (key === 'agent.reasoningBudget') { + if (!value.trim()) { + return { + ok: false, + messages: [ + isZhLocale() + ? `agent.reasoningBudget:值不能为空。可选值:${REASONING_BUDGET_VALUES.join('、')}` + : `agent.reasoningBudget: value must not be empty. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}`, + ], + }; + } const budget = parseReasoningBudget(value); if (!budget) { return { @@ -1342,9 +1368,17 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void { return; } if (!value) { - print( - `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.` - ); + if (key === 'agent.reasoningBudget') { + print( + isZhLocale() + ? `config agent.reasoningBudget:值不能为空。可选值:${REASONING_BUDGET_VALUES.join('、')}` + : `config agent.reasoningBudget: value must not be empty. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}` + ); + } else { + print( + `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.` + ); + } process.exitCode = 1; return; } diff --git a/src/cli/config-snapshot.ts b/src/cli/config-snapshot.ts index ba597b34..deb336bf 100644 --- a/src/cli/config-snapshot.ts +++ b/src/cli/config-snapshot.ts @@ -88,6 +88,7 @@ const ZH_FIELD: Record = { guardrails: '护栏', 'max turns': '最大轮次', 'context tokens': '上下文词元', + 'reasoning budget': '推理强度', 'max output': '最大输出', compaction: '压缩', 'model tiers': '模型分级', diff --git a/src/cli/config.ts b/src/cli/config.ts index f3d34f66..f4721ab8 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -51,7 +51,7 @@ import { recordDotenvOrigin, } from '../utils/startup-env.js'; import { officialEnvOffers } from './env-credentials.js'; -import { setupCopy } from './cli-locale.js'; +import { isZhLocale, setupCopy } from './cli-locale.js'; import { isDeviceTrustEnv, parseDeviceTrustList } from '../safety/device-trust.js'; import type { PricingConfig } from './model-pricing.js'; import type { StatusLineConfig } from './status-line.js'; @@ -671,6 +671,19 @@ export function parseReasoningBudget(value: unknown): ReasoningBudget | undefine : undefined; } +const warnedInvalidReasoningBudgetEnv = new Set(); + +/** MOSS_LANG-style: warn once per process for an invalid MOSS_REASONING_BUDGET. */ +function warnInvalidReasoningBudgetEnv(raw: string): void { + if (warnedInvalidReasoningBudgetEnv.has(raw)) return; + warnedInvalidReasoningBudgetEnv.add(raw); + const zh = isZhLocale(); + const msg = zh + ? `[moss] MOSS_REASONING_BUDGET 只能是 off、adaptive 或 high,收到「${raw}」,已回退到配置或默认 adaptive。` + : `[moss] MOSS_REASONING_BUDGET must be off|adaptive|high, got "${raw}"; falling back to config or default adaptive.`; + console.error(msg); +} + function mergeAgentRuntimeConfig( userAgent: ConfigFile['agent'], projectAgent: ConfigFile['agent'] @@ -723,6 +736,9 @@ export function listProjectRoutingConfigFields(project: ConfigFile): string[] { if (project.agent?.maxOutputTokens !== undefined) ignored.push('maxOutputTokens'); const models = project.agent?.models; if (models && Object.keys(models).length > 0) ignored.push('models'); + // Untrusted folders cannot raise (or set) reasoning intensity — same + // stance as modelTiers / maxOutputTokens: drop any project value. + if (project.agent?.reasoningBudget !== undefined) ignored.push('reasoningBudget'); return ignored; } @@ -733,7 +749,13 @@ function omitProjectRoutingConfig(project: ConfigFile): ConfigFile { // A project cap can force a truncation or reserve most of the context // window. An untrusted folder does not get to set either knob. User // config, the process environment, and a trusted project still can. - const { modelTiers: _tiers, maxOutputTokens: _cap, models: _models, ...rest } = next.agent; + const { + modelTiers: _tiers, + maxOutputTokens: _cap, + models: _models, + reasoningBudget: _rb, + ...rest + } = next.agent; next.agent = Object.keys(rest).length > 0 ? rest : undefined; } return next; @@ -1552,6 +1574,8 @@ export interface ResolvedCliConfig { /** v0.10 W4 (config agent.reasoningBudget + MOSS_REASONING_BUDGET env). */ reasoningBudget?: 'off' | 'adaptive' | 'high'; reasoningBudgetSource?: string; + /** Set when MOSS_REASONING_BUDGET was present but not off|adaptive|high. */ + invalidReasoningBudgetEnv?: string; /** v0.12 (agent.modelTiers + MOSS_MODEL_CHEAP/BALANCED/STRONG env). */ modelTiers?: { cheap?: string; balanced?: string; strong?: string }; modelTiersSource?: string; @@ -1622,9 +1646,21 @@ export function auditResolvedCliConfig( | 'deniedTools' | 'deniedToolsSource' | 'permissions' - > + > & + Partial> ): CliConfigAuditWarning[] { const warnings: CliConfigAuditWarning[] = []; + if (config.invalidReasoningBudgetEnv) { + warnings.push({ + code: 'reasoningBudget.env_invalid', + severity: 'warn', + source: 'MOSS_REASONING_BUDGET', + message: uiText( + `MOSS_REASONING_BUDGET "${config.invalidReasoningBudgetEnv}" is not off|adaptive|high; using config or default adaptive`, + `MOSS_REASONING_BUDGET「${config.invalidReasoningBudgetEnv}」不是 off|adaptive|high;已回退到配置或默认 adaptive` + ), + }); + } // v0.26 audit re-check (PRD W1 告警重校): the old blanket auto-approval // warning would fire for every factory-default user now that full is the // default. The warning only fires when the auto-approval stance came from @@ -2431,7 +2467,15 @@ export function resolveCliConfig( // Same precedence as agent.maxTurns / agent.contextTokens: a valid // MOSS_REASONING_BUDGET env wins over config agent.reasoningBudget // (project layer over user layer), then the runtime default (adaptive). - const envReasoningBudget = parseReasoningBudget(env.MOSS_REASONING_BUDGET); + const rawReasoningBudgetEnv = env.MOSS_REASONING_BUDGET; + const envReasoningBudget = parseReasoningBudget(rawReasoningBudgetEnv); + const invalidReasoningBudgetEnv = + typeof rawReasoningBudgetEnv === 'string' && + rawReasoningBudgetEnv.trim() !== '' && + envReasoningBudget === undefined + ? rawReasoningBudgetEnv.trim() + : undefined; + if (invalidReasoningBudgetEnv) warnInvalidReasoningBudgetEnv(invalidReasoningBudgetEnv); const configReasoningBudget = parseReasoningBudget(activeConfig.agent?.reasoningBudget); const reasoningBudget = envReasoningBudget ?? configReasoningBudget; const reasoningBudgetSource = @@ -2549,6 +2593,7 @@ export function resolveCliConfig( ...(bestOfN !== undefined ? { bestOfN } : {}), ...(reasoningBudget ? { reasoningBudget } : {}), reasoningBudgetSource, + ...(invalidReasoningBudgetEnv ? { invalidReasoningBudgetEnv } : {}), ...(hasModelTiers ? { modelTiers, modelTiersSource } : { modelTiersSource }), compactionSettingsSource, configPath: configPaths?.configPath ?? resolveConfigPath(undefined, env), diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index f4a569ca..51b70f7f 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -298,6 +298,7 @@ export function renderCliStatus( 'guardrails', 'maxTurns', 'contextTokens', + 'reasoningBudget', 'maxOutput', 'compaction', ]), @@ -533,6 +534,7 @@ export function renderCliPermissions( 'guardrails', 'maxTurns', 'contextTokens', + 'reasoningBudget', 'maxOutput', 'compaction', 'warnings', diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts index 0b647991..5f0cde0c 100644 --- a/src/cli/setup-wizard.ts +++ b/src/cli/setup-wizard.ts @@ -327,6 +327,7 @@ export function renderAuthStatus( 'guardrails', 'maxTurns', 'contextTokens', + 'reasoningBudget', 'compaction', 'modelTiers', 'warnings', diff --git a/test/config-reasoning-budget-o93.spec.mjs b/test/config-reasoning-budget-o93.spec.mjs index 94777a31..aa0d2564 100644 --- a/test/config-reasoning-budget-o93.spec.mjs +++ b/test/config-reasoning-budget-o93.spec.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** * O93: agent.reasoningBudget must be settable via `moss config set`, shown by - * `moss config show` / `moss config env`, and resolved with the same + * `moss config show` / `moss config env` / help, and resolved with the same * precedence as agent.maxTurns (MOSS_REASONING_BUDGET env > config file). + * Untrusted projects cannot set reasoningBudget (dropped like modelTiers). */ import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -11,8 +12,13 @@ import path from 'node:path'; import { test } from 'node:test'; import * as setup from '../dist/cli/setup.js'; -import { renderConfigEnv, renderConfigJson } from '../dist/cli/config-commands.js'; -import { resolveCliConfig } from '../dist/cli/config.js'; +import { + renderConfigEnv, + renderConfigHelp, + renderConfigJson, +} from '../dist/cli/config-commands.js'; +import { loadCliConfigFile, resolveCliConfig } from '../dist/cli/config.js'; +import { renderAuthStatus } from '../dist/cli/setup-wizard.js'; function capture(fn) { const captured = { out: '', err: '' }; @@ -41,8 +47,25 @@ function withProjectDir(fn) { } } +function withHomeAndWs(fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o93-trust-')); + const configDir = path.join(root, 'cfg'); + const ws = path.join(root, 'ws'); + fs.mkdirSync(path.join(ws, '.moss'), { recursive: true }); + fs.mkdirSync(configDir, { recursive: true }); + try { + return fn({ root, configDir, ws }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + const cfgPath = (dir) => path.join(dir, '.moss', 'config.json'); const readCfg = (dir) => JSON.parse(fs.readFileSync(cfgPath(dir), 'utf8')); +const put = (file, obj) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(obj, null, 2)}\n`); +}; test('config set agent.reasoningBudget accepts off|adaptive|high and unset removes it', () => { withProjectDir((dir) => { @@ -71,6 +94,18 @@ test('config set agent.reasoningBudget rejects invalid values and lists the choi }); }); +test('config set agent.reasoningBudget with empty value lists the choices', () => { + withProjectDir((dir) => { + const r = capture(() => setup.runConfigSet(['--project', 'agent.reasoningBudget'], dir)); + assert.equal(r.exitCode, 1); + assert.match(r.err, /agent\.reasoningBudget/); + assert.match(r.err, /off/); + assert.match(r.err, /adaptive/); + assert.match(r.err, /high/); + assert.doesNotMatch(r.err, /moss config --help/); + }); +}); + test('config show reports agent.reasoningBudget with its source; env wins over config', () => { withProjectDir((dir) => { capture(() => setup.runConfigSet(['--project', 'agent.reasoningBudget', 'high'], dir)); @@ -86,11 +121,33 @@ test('config show reports agent.reasoningBudget with its source; env wins over c assert.equal(withEnv.reasoningBudget, 'off'); assert.equal(withEnv.reasoningBudgetSource, 'MOSS_REASONING_BUDGET'); - // Invalid env falls back to config (same as before O93). - const badEnv = JSON.parse( - renderConfigJson(undefined, { ...env, MOSS_REASONING_BUDGET: 'bogus' }, dir, {}) + // Invalid env falls back to config and surfaces a config warning. + const badEnv = { ...env, MOSS_REASONING_BUDGET: 'bogus' }; + const badShown = JSON.parse(renderConfigJson(undefined, badEnv, dir, {})); + assert.equal(badShown.reasoningBudget, 'high'); + assert.ok( + (badShown.configWarnings ?? []).some((w) => w.code === 'reasoningBudget.env_invalid'), + JSON.stringify(badShown.configWarnings) ); - assert.equal(badEnv.reasoningBudget, 'high'); + }); +}); + +test('config show plain text includes reasoning budget value and source', () => { + withProjectDir((dir) => { + capture(() => setup.runConfigSet(['--project', 'agent.reasoningBudget', 'high'], dir)); + const prev = { ...process.env }; + process.env.MOSS_CONFIG_FILE = cfgPath(dir); + try { + const text = renderAuthStatus(undefined, process.env, dir, {}, '[config]'); + assert.match(text, /reasoning budget|推理强度/i); + assert.match(text, /\bhigh\b/); + assert.match(text, /agent\.reasoningBudget|project|user|MOSS_/i); + } finally { + for (const k of Object.keys(process.env)) { + if (!(k in prev)) delete process.env[k]; + } + Object.assign(process.env, prev); + } }); }); @@ -100,7 +157,112 @@ test('resolver ignores an invalid agent.reasoningBudget in the config file', () assert.equal(r.reasoningBudgetSource, 'default'); }); +test('invalid MOSS_REASONING_BUDGET warns once on stderr and falls back', () => { + const r = capture(() => { + const resolved = resolveCliConfig( + { MOSS_REASONING_BUDGET: 'turbo' }, + { agent: { reasoningBudget: 'off' } } + ); + assert.equal(resolved.reasoningBudget, 'off'); + assert.equal(resolved.invalidReasoningBudgetEnv, 'turbo'); + }); + assert.match(r.err, /MOSS_REASONING_BUDGET/); + assert.match(r.err, /off\|adaptive\|high|off、adaptive|high/); + // second resolve with same value must not spam + const r2 = capture(() => { + resolveCliConfig({ MOSS_REASONING_BUDGET: 'turbo' }, { agent: { reasoningBudget: 'off' } }); + }); + assert.equal(r2.err, ''); +}); + test('config env documents MOSS_REASONING_BUDGET and its config key', () => { const text = renderConfigEnv(); assert.match(text, /MOSS_REASONING_BUDGET[^\n]*off\|adaptive\|high[^\n]*agent\.reasoningBudget/); }); + +test('en and zh config help mention agent.reasoningBudget and off|adaptive|high', () => { + const en = renderConfigHelp(false); + assert.match(en, /agent\.reasoningBudget/); + assert.match(en, /off\|adaptive\|high/); + assert.match(en, /how hard the model thinks|tokens|cost|longer/i); + const zhHelp = renderConfigHelp(true); + assert.match(zhHelp, /agent\.reasoningBudget/); + assert.match(zhHelp, /off\|adaptive\|high/); + assert.match(zhHelp, /可选值/); + assert.match(zhHelp, /推理强度|费用|越慢/); +}); + +test('config show stderr tips when untrusted project reasoningBudget is skipped', () => { + withHomeAndWs(({ configDir, ws }) => { + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const prev = { ...process.env }; + Object.assign(process.env, { + HOME: path.dirname(configDir), + MOSS_CONFIG_DIR: configDir, + }); + delete process.env.MOSS_TRUST_WORKSPACE; + try { + const r = capture(() => setup.runConfigShow(ws, {})); + assert.match(r.err, /reasoningBudget/); + assert.match(r.err, /isn't trusted|未信任|skipped|跳过|project settings/i); + assert.match(r.out, /reasoning budget|推理强度/i); + assert.doesNotMatch(r.out, /\bhigh\b/); + } finally { + for (const k of Object.keys(process.env)) { + if (!(k in prev)) delete process.env[k]; + } + Object.assign(process.env, prev); + } + }); +}); + +test('untrusted project reasoningBudget is dropped; trusted project applies', () => { + withHomeAndWs(({ configDir, ws }) => { + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + agent: { reasoningBudget: 'off' }, + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const env = { HOME: path.dirname(configDir), MOSS_CONFIG_DIR: configDir }; + const ignored = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); + assert.ok( + ignored.ignoredProjectRouting?.includes('reasoningBudget'), + String(ignored.ignoredProjectRouting) + ); + assert.equal(ignored.config.agent?.reasoningBudget, 'off'); + const resolvedIgnored = resolveCliConfig(env, ignored.config, {}, ignored); + assert.equal(resolvedIgnored.reasoningBudget, 'off'); + + // User default (no reasoningBudget) + untrusted project high → not high + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + const ignoredDefault = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); + assert.ok(ignoredDefault.ignoredProjectRouting?.includes('reasoningBudget')); + assert.equal(ignoredDefault.config.agent?.reasoningBudget, undefined); + assert.notEqual( + resolveCliConfig(env, ignoredDefault.config, {}, ignoredDefault).reasoningBudget, + 'high' + ); + + const trusted = loadCliConfigFile(env, ['--trust-workspace'], ws); + assert.equal(trusted.config.agent?.reasoningBudget, 'high'); + assert.equal(resolveCliConfig(env, trusted.config, {}, trusted).reasoningBudget, 'high'); + }); +}); From 40c2ded193977a2928e66a203b89708c4752ba3a Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 03:42:55 +0800 Subject: [PATCH 4/4] fix(config): O93 untrusted project may only lower reasoningBudget Untrusted folders keep a project agent.reasoningBudget when it is <= the user effective value (unset user => adaptive); a raise is dropped into ignoredProjectRouting so config show, -p, and TUI startup share the same untrusted-folder notice. Help clarifies off = do not escalate (not disable provider thinking). QA cases + revert-red covered in test/config-reasoning-budget-o93.spec.mjs. --- src/cli/config-commands.ts | 14 +- src/cli/config.ts | 39 ++-- test/config-reasoning-budget-o93.spec.mjs | 221 ++++++++++++++++++---- 3 files changed, 219 insertions(+), 55 deletions(-) diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index 81f90c75..63d6ce68 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -603,8 +603,12 @@ export function renderConfigHelp(zh = false): string { ' moss config set guardrails.input.redactPatterns SECRET=[^\\\\s]+', ' moss config set agent.maxTurns 96', ' moss config set agent.contextTokens 200000', - ' # agent.reasoningBudget — how hard the model thinks (off|adaptive|high).', - ' # Higher values spend more tokens, take longer, and cost more; off is cheapest/fastest.', + ' # agent.reasoningBudget — Moss adaptive-reasoning knob (off|adaptive|high).', + ' # high forces reasoning=high on outbound calls; adaptive may escalate to high under', + " # verify/turn-error pressure; off means do NOT escalate (keeps the model's own", + ' # reasoning level — it does not disable provider-native thinking).', + ' # Higher budgets spend more tokens, take longer, and cost more.', + ' # An untrusted project can only lower it; a raise is ignored with a notice.', ' moss config set agent.reasoningBudget high # off|adaptive|high', ' moss config set agent.compaction.reserveTokens 20000', ' moss config set rdkDocs false', @@ -661,8 +665,10 @@ function renderConfigHelpZh(): string { ' `moss config set rdkDocs.package ../rdk-docs-mcp`', ' `moss config set agent.maxTurns 96`', ' `moss config set agent.contextTokens 200000`', - ' # agent.reasoningBudget — 推理强度(可选值 off|adaptive|high)。', - ' # 越高越慢、消耗越多、费用越高;off 最快最省。不受信项目里的该项会被忽略。', + ' # agent.reasoningBudget — Moss 自适应推理档位(可选值 off|adaptive|high)。', + ' # high:出站推理档固定为 high;adaptive:验证失败或回合出错时可能升到 high;', + ' # off:不升档,保留模型自身推理档(不是关掉模型自带思考)。越高越慢、费用越高。', + ' # 未信任的项目只能把它调低;调高会被忽略并提示。', ' `moss config set agent.reasoningBudget high` # 可选值:off|adaptive|high', ' `moss config set language auto|en|zh` # 只写用户配置(不能 --project,项目 .env 也不能设置)', ' # 界面语言优先级:--lang > MOSS_LANG > `language` > 系统区域。auto 仅在区域以 zh 开头时用中文。', diff --git a/src/cli/config.ts b/src/cli/config.ts index f4721ab8..4f09f4f1 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -736,12 +736,29 @@ export function listProjectRoutingConfigFields(project: ConfigFile): string[] { if (project.agent?.maxOutputTokens !== undefined) ignored.push('maxOutputTokens'); const models = project.agent?.models; if (models && Object.keys(models).length > 0) ignored.push('models'); - // Untrusted folders cannot raise (or set) reasoning intensity — same - // stance as modelTiers / maxOutputTokens: drop any project value. - if (project.agent?.reasoningBudget !== undefined) ignored.push('reasoningBudget'); return ignored; } +const REASONING_BUDGET_RANK: Record = { off: 0, adaptive: 1, high: 2 }; + +/** + * An untrusted project may only lower reasoning intensity (off < adaptive < + * high). The user's effective value is the user config value, or the + * runtime default `adaptive` when unset. Same shape as projectProfileLoosens. + */ +function projectReasoningBudgetRaises(user: ConfigFile, project: ConfigFile): boolean { + const projectBudget = parseReasoningBudget(project.agent?.reasoningBudget); + if (!projectBudget) return false; + const userBudget = parseReasoningBudget(user.agent?.reasoningBudget) ?? 'adaptive'; + return REASONING_BUDGET_RANK[projectBudget] > REASONING_BUDGET_RANK[userBudget]; +} + +function omitRaisingProjectReasoningBudget(user: ConfigFile, project: ConfigFile): ConfigFile { + if (!project.agent || !projectReasoningBudgetRaises(user, project)) return project; + const { reasoningBudget: _rb, ...rest } = project.agent; + return { ...project, agent: Object.keys(rest).length > 0 ? rest : undefined }; +} + function omitProjectRoutingConfig(project: ConfigFile): ConfigFile { const next: ConfigFile = { ...project }; for (const key of PROJECT_ROUTING_CONFIG_KEYS) delete next[key]; @@ -749,13 +766,7 @@ function omitProjectRoutingConfig(project: ConfigFile): ConfigFile { // A project cap can force a truncation or reserve most of the context // window. An untrusted folder does not get to set either knob. User // config, the process environment, and a trusted project still can. - const { - modelTiers: _tiers, - maxOutputTokens: _cap, - models: _models, - reasoningBudget: _rb, - ...rest - } = next.agent; + const { modelTiers: _tiers, maxOutputTokens: _cap, models: _models, ...rest } = next.agent; next.agent = Object.keys(rest).length > 0 ? rest : undefined; } return next; @@ -1254,12 +1265,18 @@ export function loadCliConfigFile( const rawProject = loadConfigFile(projectConfigPath); const trusted = projectRoutingTrusted(env, argv, startDir, options?.trustProjectRouting); const ignoredProjectRouting = trusted ? [] : listProjectRoutingConfigFields(rawProject); + if (!trusted && projectReasoningBudgetRaises(userConfig, rawProject)) { + ignoredProjectRouting.push('reasoningBudget'); + } const droppedProjectPermissions = trusted ? [] : listDroppedProjectPermissionFields(userConfig, rawProject); const projectConfig = trusted ? rawProject - : omitLooseningProjectPermissions(userConfig, omitProjectRoutingConfig(rawProject)); + : omitRaisingProjectReasoningBudget( + userConfig, + omitLooseningProjectPermissions(userConfig, omitProjectRoutingConfig(rawProject)) + ); const merged = mergeConfigFiles(projectConfig, userConfig, options); const sources = fieldSourcesFor(userConfig, projectConfig); const keyLayer = diff --git a/test/config-reasoning-budget-o93.spec.mjs b/test/config-reasoning-budget-o93.spec.mjs index aa0d2564..e97d6258 100644 --- a/test/config-reasoning-budget-o93.spec.mjs +++ b/test/config-reasoning-budget-o93.spec.mjs @@ -3,7 +3,8 @@ * O93: agent.reasoningBudget must be settable via `moss config set`, shown by * `moss config show` / `moss config env` / help, and resolved with the same * precedence as agent.maxTurns (MOSS_REASONING_BUDGET env > config file). - * Untrusted projects cannot set reasoningBudget (dropped like modelTiers). + * Untrusted projects may only lower reasoningBudget (off < adaptive < high); + * a raise is ignored and listed on the untrusted-folder notice. */ import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -184,85 +185,225 @@ test('en and zh config help mention agent.reasoningBudget and off|adaptive|high' const en = renderConfigHelp(false); assert.match(en, /agent\.reasoningBudget/); assert.match(en, /off\|adaptive\|high/); - assert.match(en, /how hard the model thinks|tokens|cost|longer/i); + assert.match(en, /adaptive-reasoning|do NOT escalate|tokens|cost/i); + assert.match(en, /does not disable provider-native thinking|keeps the model's own/i); const zhHelp = renderConfigHelp(true); assert.match(zhHelp, /agent\.reasoningBudget/); assert.match(zhHelp, /off\|adaptive\|high/); assert.match(zhHelp, /可选值/); - assert.match(zhHelp, /推理强度|费用|越慢/); + assert.match(zhHelp, /自适应推理|不升档|费用|越慢/); + assert.match(zhHelp, /不是关掉模型自带思考/); }); -test('config show stderr tips when untrusted project reasoningBudget is skipped', () => { +test('QA1: untrusted project off with user high applies off (source project)', () => { withHomeAndWs(({ configDir, ws }) => { put(path.join(configDir, 'config.json'), { provider: 'openai-compatible', model: 'user-model', baseUrl: 'https://user.example/v1', apiKey: 'user-key', - }); - put(path.join(ws, '.moss', 'config.json'), { agent: { reasoningBudget: 'high' }, }); - const prev = { ...process.env }; - Object.assign(process.env, { - HOME: path.dirname(configDir), - MOSS_CONFIG_DIR: configDir, + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'off' }, }); - delete process.env.MOSS_TRUST_WORKSPACE; - try { - const r = capture(() => setup.runConfigShow(ws, {})); - assert.match(r.err, /reasoningBudget/); - assert.match(r.err, /isn't trusted|未信任|skipped|跳过|project settings/i); - assert.match(r.out, /reasoning budget|推理强度/i); - assert.doesNotMatch(r.out, /\bhigh\b/); - } finally { - for (const k of Object.keys(process.env)) { - if (!(k in prev)) delete process.env[k]; - } - Object.assign(process.env, prev); - } + const env = { HOME: path.dirname(configDir), MOSS_CONFIG_DIR: configDir }; + const loaded = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); + assert.ok( + !(loaded.ignoredProjectRouting ?? []).includes('reasoningBudget'), + `off must not be ignored: ${loaded.ignoredProjectRouting}` + ); + assert.equal(loaded.config.agent?.reasoningBudget, 'off'); + assert.equal(loaded.fieldSources['agent.reasoningBudget'], 'project'); + const resolved = resolveCliConfig(env, loaded.config, {}, loaded); + assert.equal(resolved.reasoningBudget, 'off'); + assert.match(resolved.reasoningBudgetSource ?? '', /project|agent\.reasoningBudget/); }); }); -test('untrusted project reasoningBudget is dropped; trusted project applies', () => { +test('QA2: untrusted project high with user unset stays adaptive and is ignored', () => { withHomeAndWs(({ configDir, ws }) => { put(path.join(configDir, 'config.json'), { provider: 'openai-compatible', model: 'user-model', baseUrl: 'https://user.example/v1', apiKey: 'user-key', - agent: { reasoningBudget: 'off' }, }); put(path.join(ws, '.moss', 'config.json'), { agent: { reasoningBudget: 'high' }, }); const env = { HOME: path.dirname(configDir), MOSS_CONFIG_DIR: configDir }; - const ignored = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); + const loaded = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); assert.ok( - ignored.ignoredProjectRouting?.includes('reasoningBudget'), - String(ignored.ignoredProjectRouting) + loaded.ignoredProjectRouting?.includes('reasoningBudget'), + String(loaded.ignoredProjectRouting) + ); + assert.equal(loaded.config.agent?.reasoningBudget, undefined); + const resolved = resolveCliConfig(env, loaded.config, {}, loaded); + assert.equal(resolved.reasoningBudget, undefined); + assert.equal(resolved.reasoningBudgetSource, 'default'); + }); +}); + +test('QA2b: -p run prints untrusted tip listing reasoningBudget when project raises', async () => { + const { spawn } = await import('node:child_process'); + const { fileURLToPath } = await import('node:url'); + const { isolatedCliEnv } = await import('./helpers/isolated-cli-env.mjs'); + const cli = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'cli.js'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o93-p-')); + const configDir = path.join(root, 'cfg'); + const ws = path.join(root, 'ws'); + fs.mkdirSync(path.join(ws, '.moss'), { recursive: true }); + fs.mkdirSync(configDir, { recursive: true }); + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'stub-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const env = isolatedCliEnv({ + prefix: 'moss-o93-p-', + overrides: { + HOME: root, + MOSS_CONFIG_DIR: configDir, + MOSS_NO_TUI: '1', + MOSS_NO_COLOR: '1', + }, + }); + delete env.MOSS_TRUST_WORKSPACE; + try { + const printed = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, '--mock', '-p', 'hi', '--max-turns', '1'], { + cwd: ws, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`timed out\n${stderr}`)); + }, 25000); + child.stdout.on('data', (b) => (stdout += b.toString())); + child.stderr.on('data', (b) => (stderr += b.toString())); + child.on('error', reject); + child.on('exit', (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); + assert.match( + printed.stderr, + /This folder isn't trusted, so its project settings were skipped:/ ); - assert.equal(ignored.config.agent?.reasoningBudget, 'off'); - const resolvedIgnored = resolveCliConfig(env, ignored.config, {}, ignored); - assert.equal(resolvedIgnored.reasoningBudget, 'off'); + assert.match(printed.stderr, /reasoningBudget/); + assert.doesNotMatch(printed.stderr, /[\u4e00-\u9fff]/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); - // User default (no reasoningBudget) + untrusted project high → not high +test('QA2c: TUI/startup notice line lists reasoningBudget when it was raised', async () => { + const { untrustedFolderLine } = await import('../dist/cli/workspace-trust.js'); + withHomeAndWs(({ configDir, ws }) => { put(path.join(configDir, 'config.json'), { provider: 'openai-compatible', model: 'user-model', baseUrl: 'https://user.example/v1', apiKey: 'user-key', }); - const ignoredDefault = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); - assert.ok(ignoredDefault.ignoredProjectRouting?.includes('reasoningBudget')); - assert.equal(ignoredDefault.config.agent?.reasoningBudget, undefined); - assert.notEqual( - resolveCliConfig(env, ignoredDefault.config, {}, ignoredDefault).reasoningBudget, - 'high' - ); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const env = { HOME: path.dirname(configDir), MOSS_CONFIG_DIR: configDir }; + const loaded = loadCliConfigFile(env, [], ws, { trustProjectRouting: false }); + assert.ok(loaded.ignoredProjectRouting?.includes('reasoningBudget')); + // Same assembly as cli-main / TUI startup: ignored routing → untrustedFolderLine. + const line = untrustedFolderLine(loaded.ignoredProjectRouting ?? [], false); + assert.match(line, /isn't trusted/); + assert.match(line, /reasoningBudget/); + const zh = untrustedFolderLine(loaded.ignoredProjectRouting ?? [], true); + assert.match(zh, /未信任|跳过/); + assert.match(zh, /reasoningBudget/); + }); +}); + +test('QA3: trusted project high applies', () => { + withHomeAndWs(({ configDir, ws }) => { + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const env = { HOME: path.dirname(configDir), MOSS_CONFIG_DIR: configDir }; + const trusted = loadCliConfigFile(env, ['--trust-workspace'], ws); + assert.ok(!(trusted.ignoredProjectRouting ?? []).includes('reasoningBudget')); + assert.equal(trusted.config.agent?.reasoningBudget, 'high'); + const resolved = resolveCliConfig(env, trusted.config, {}, trusted); + assert.equal(resolved.reasoningBudget, 'high'); + }); +}); +test('QA4: MOSS_REASONING_BUDGET still overrides project value', () => { + withHomeAndWs(({ configDir, ws }) => { + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const env = { + HOME: path.dirname(configDir), + MOSS_CONFIG_DIR: configDir, + MOSS_REASONING_BUDGET: 'off', + MOSS_TRUST_WORKSPACE: '1', + }; const trusted = loadCliConfigFile(env, ['--trust-workspace'], ws); assert.equal(trusted.config.agent?.reasoningBudget, 'high'); - assert.equal(resolveCliConfig(env, trusted.config, {}, trusted).reasoningBudget, 'high'); + const resolved = resolveCliConfig(env, trusted.config, {}, trusted); + assert.equal(resolved.reasoningBudget, 'off'); + assert.equal(resolved.reasoningBudgetSource, 'MOSS_REASONING_BUDGET'); + }); +}); + +test('config show stderr tips when untrusted project reasoningBudget raise is skipped', () => { + withHomeAndWs(({ configDir, ws }) => { + put(path.join(configDir, 'config.json'), { + provider: 'openai-compatible', + model: 'user-model', + baseUrl: 'https://user.example/v1', + apiKey: 'user-key', + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'high' }, + }); + const prev = { ...process.env }; + Object.assign(process.env, { + HOME: path.dirname(configDir), + MOSS_CONFIG_DIR: configDir, + }); + delete process.env.MOSS_TRUST_WORKSPACE; + try { + const r = capture(() => setup.runConfigShow(ws, {})); + assert.match(r.err, /reasoningBudget/); + assert.match(r.err, /isn't trusted|未信任|skipped|跳过|project settings/i); + assert.match(r.out, /reasoning budget|推理强度/i); + assert.doesNotMatch(r.out, /\bhigh\b/); + } finally { + for (const k of Object.keys(process.env)) { + if (!(k in prev)) delete process.env[k]; + } + Object.assign(process.env, prev); + } }); });