diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index 7f3f9993..63d6ce68 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'; @@ -29,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 @@ -75,6 +78,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 +383,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 +481,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 +603,13 @@ 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 — 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', ' moss config set rdkDocs.package ../rdk-docs-mcp', @@ -649,6 +663,13 @@ 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 — 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 开头时用中文。', ].join('\n'); @@ -659,7 +680,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; } @@ -901,8 +930,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 +1289,29 @@ function applyConfigSetPair( } catch (err) { 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 { + 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) { @@ -1322,9 +1374,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; } @@ -1485,6 +1545,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..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': '模型分级', @@ -276,6 +277,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..4f09f4f1 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'; @@ -660,6 +660,30 @@ 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; +} + +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'] @@ -715,6 +739,26 @@ export function listProjectRoutingConfigFields(project: ConfigFile): string[] { 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]; @@ -858,6 +902,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, @@ -1216,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 = @@ -1535,6 +1590,9 @@ export interface ResolvedCliConfig { bestOfN?: number; /** 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; @@ -1605,9 +1663,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 @@ -2411,13 +2481,26 @@ 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 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 = + envReasoningBudget !== undefined + ? 'MOSS_REASONING_BUDGET' + : configReasoningBudget !== undefined + ? fileLayer('agent.reasoningBudget') + : 'default'; const runBudget = budgetMaxTokens !== undefined || budgetMaxToolCalls !== undefined || @@ -2526,6 +2609,8 @@ export function resolveCliConfig( ...(runBudget ? { budget: runBudget } : {}), ...(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/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/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 diff --git a/test/config-reasoning-budget-o93.spec.mjs b/test/config-reasoning-budget-o93.spec.mjs new file mode 100644 index 00000000..e97d6258 --- /dev/null +++ b/test/config-reasoning-budget-o93.spec.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +/** + * 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 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'; +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, + 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: '' }; + 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 }); + } +} + +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) => { + 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 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)); + 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 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) + ); + }); +}); + +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); + } + }); +}); + +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('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, /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, /不是关掉模型自带思考/); +}); + +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', + agent: { reasoningBudget: 'high' }, + }); + put(path.join(ws, '.moss', 'config.json'), { + agent: { reasoningBudget: 'off' }, + }); + 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('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', + }); + 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'), + 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.match(printed.stderr, /reasoningBudget/); + assert.doesNotMatch(printed.stderr, /[\u4e00-\u9fff]/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +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', + }); + 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'); + 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); + } + }); +});