From f5855aefe50df17029c3d4142481e469d6ba7bd8 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:51:31 +0800 Subject: [PATCH 1/9] feat: add codex context budget controls --- cli.js | 83 +++++++++++++++- tests/e2e/test-config.js | 27 +++++- tests/unit/config-tabs-ui.test.mjs | 50 ++++++++++ web-ui/app.js | 147 ++++++++++++++++++++++++++++- web-ui/index.html | 56 +++++++++-- web-ui/styles.css | 76 +++++++++++---- 6 files changed, 408 insertions(+), 31 deletions(-) diff --git a/cli.js b/cli.js index 3c85082..ede816a 100644 --- a/cli.js +++ b/cli.js @@ -93,6 +93,8 @@ const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json'); const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json'); const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl'); const DEFAULT_CLAUDE_MODEL = 'glm-4.7'; +const DEFAULT_MODEL_CONTEXT_WINDOW = 190000; +const DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT = 185000; const CODEX_BACKUP_NAME = 'codex-config'; const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4']; @@ -226,6 +228,8 @@ function resolveWebHost(options = {}) { const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex" model_reasoning_effort = "high" +model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW} +model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT} disable_response_storage = true approval_policy = "never" sandbox_mode = "danger-full-access" @@ -3168,6 +3172,8 @@ function buildDefaultConfigContent(initializedAt) { model_provider = "openai" model = "${defaultModel}" +model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW} +model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT} [model_providers.openai] name = "openai" @@ -3333,6 +3339,40 @@ function applyReasoningEffortToTemplate(template, reasoningEffort) { return content; } +function normalizePositiveIntegerParam(value) { + if (value === undefined || value === null) { + return null; + } + const text = typeof value === 'number' + ? String(value) + : (typeof value === 'string' ? value.trim() : String(value).trim()); + if (!text) { + return null; + } + if (!/^\d+$/.test(text)) { + return null; + } + const parsed = Number.parseInt(text, 10); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function applyPositiveIntegerConfigToTemplate(template, key, value) { + let content = typeof template === 'string' ? template : ''; + const normalized = normalizePositiveIntegerParam(value); + if (!key || normalized === null) { + return content; + } + + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^\\s*${escapedKey}\\s*=\\s*[^\\n]*\\n?`, 'gmi'); + content = content.replace(pattern, ''); + content = content.replace(/^\s*\n*/, ''); + return `${key} = ${normalized}\n${content}`; +} + function getConfigTemplate(params = {}) { let content = EMPTY_CONFIG_FALLBACK_TEMPLATE; if (fs.existsSync(CONFIG_FILE)) { @@ -3352,11 +3392,34 @@ function getConfigTemplate(params = {}) { if (typeof params.reasoningEffort === 'string') { template = applyReasoningEffortToTemplate(template, params.reasoningEffort); } + if (params.modelAutoCompactTokenLimit !== undefined) { + template = applyPositiveIntegerConfigToTemplate( + template, + 'model_auto_compact_token_limit', + params.modelAutoCompactTokenLimit + ); + } + if (params.modelContextWindow !== undefined) { + template = applyPositiveIntegerConfigToTemplate( + template, + 'model_context_window', + params.modelContextWindow + ); + } return { template }; } +function readPositiveIntegerConfigValue(config, key) { + if (!config || typeof config !== 'object' || !key) { + return ''; + } + const raw = config[key]; + const normalized = normalizePositiveIntegerParam(raw); + return normalized === null ? '' : normalized; +} + function applyConfigTemplate(params = {}) { const template = typeof params.template === 'string' ? params.template : ''; if (!template.trim()) { @@ -9981,11 +10044,15 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser const config = statusConfigResult.config; const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : ''; const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : ''; + const modelContextWindow = readPositiveIntegerConfigValue(config, 'model_context_window'); + const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(config, 'model_auto_compact_token_limit'); result = { provider: config.model_provider || '未设置', model: config.model || '未设置', serviceTier, modelReasoningEffort, + modelContextWindow, + modelAutoCompactTokenLimit, configReady: !statusConfigResult.isVirtual, configErrorType: statusConfigResult.errorType || '', configNotice: statusConfigResult.reason || '', @@ -11464,11 +11531,15 @@ function buildMcpStatusPayload() { const config = statusConfigResult.config; const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : ''; const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : ''; + const modelContextWindow = readPositiveIntegerConfigValue(config, 'model_context_window'); + const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(config, 'model_auto_compact_token_limit'); return { provider: config.model_provider || '未设置', model: config.model || '未设置', serviceTier, modelReasoningEffort, + modelContextWindow, + modelAutoCompactTokenLimit, configReady: !statusConfigResult.isVirtual, configErrorType: statusConfigResult.errorType || '', configNotice: statusConfigResult.reason || '', @@ -11566,6 +11637,8 @@ const BUILTIN_WORKFLOW_DEFINITIONS = Object.freeze({ model: { type: 'string' }, serviceTier: { type: 'string' }, reasoningEffort: { type: 'string' }, + modelContextWindow: { type: ['string', 'number'] }, + modelAutoCompactTokenLimit: { type: ['string', 'number'] }, apply: { type: 'boolean' } }, required: ['provider'], @@ -11580,7 +11653,9 @@ const BUILTIN_WORKFLOW_DEFINITIONS = Object.freeze({ provider: '{{input.provider}}', model: '{{input.model}}', serviceTier: '{{input.serviceTier}}', - reasoningEffort: '{{input.reasoningEffort}}' + reasoningEffort: '{{input.reasoningEffort}}', + modelContextWindow: '{{input.modelContextWindow}}', + modelAutoCompactTokenLimit: '{{input.modelAutoCompactTokenLimit}}' } }, { @@ -12149,7 +12224,7 @@ function createMcpTools(options = {}) { pushTool({ name: 'codexmate.config.template.get', - description: 'Get Codex config template with optional provider/model/service tier/reasoning effort.', + description: 'Get Codex config template with optional provider/model/service tier/reasoning effort/context budget.', readOnly: true, inputSchema: { type: 'object', @@ -12157,7 +12232,9 @@ function createMcpTools(options = {}) { provider: { type: 'string' }, model: { type: 'string' }, serviceTier: { type: 'string' }, - reasoningEffort: { type: 'string' } + reasoningEffort: { type: 'string' }, + modelContextWindow: { type: ['string', 'number'] }, + modelAutoCompactTokenLimit: { type: ['string', 'number'] } }, additionalProperties: false }, diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index d1e2c08..ea17d4a 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -22,6 +22,10 @@ module.exports = async function testConfig(ctx) { assert(typeof apiStatus.configReady === 'boolean', 'api status configReady missing'); assert('modelReasoningEffort' in apiStatus, 'api status modelReasoningEffort missing'); assert('serviceTier' in apiStatus, 'api status serviceTier missing'); + assert('modelContextWindow' in apiStatus, 'api status modelContextWindow missing'); + assert('modelAutoCompactTokenLimit' in apiStatus, 'api status modelAutoCompactTokenLimit missing'); + assert(apiStatus.modelContextWindow === 190000, 'api status modelContextWindow mismatch'); + assert(apiStatus.modelAutoCompactTokenLimit === 185000, 'api status modelAutoCompactTokenLimit mismatch'); // ========== List API Tests ========== const apiList = await api('list'); @@ -83,26 +87,45 @@ module.exports = async function testConfig(ctx) { assert(typeof templateReasoningXhigh.template === 'string', 'get-config-template(reasoning xhigh) missing template'); assert(/^\s*model_reasoning_effort\s*=\s*"xhigh"\s*$/m.test(templateReasoningXhigh.template), 'get-config-template(reasoning xhigh) missing model_reasoning_effort'); + // ========== Get Config Template Tests - Context Budget ========== + const templateContextBudget = await api('get-config-template', { + provider: 'shadow', + model: 'shadow-model', + modelContextWindow: 200000, + modelAutoCompactTokenLimit: 195000 + }); + assert(typeof templateContextBudget.template === 'string', 'get-config-template(context budget) missing template'); + assert(/^\s*model_context_window\s*=\s*200000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_context_window'); + assert(/^\s*model_auto_compact_token_limit\s*=\s*195000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_auto_compact_token_limit'); + // ========== Get Config Template Tests - Combined ========== const templateCombined = await api('get-config-template', { provider: 'shadow', model: 'shadow-model', serviceTier: 'fast', - reasoningEffort: 'high' + reasoningEffort: 'high', + modelContextWindow: 190000, + modelAutoCompactTokenLimit: 185000 }); assert(typeof templateCombined.template === 'string', 'get-config-template(combined) missing template'); assert(/^\s*service_tier\s*=\s*"fast"\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing service_tier'); assert(/^\s*model_reasoning_effort\s*=\s*"high"\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_reasoning_effort'); + assert(/^\s*model_context_window\s*=\s*190000\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_context_window'); + assert(/^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_auto_compact_token_limit'); const templateCombinedXhigh = await api('get-config-template', { provider: 'shadow', model: 'shadow-model', serviceTier: 'fast', - reasoningEffort: 'xhigh' + reasoningEffort: 'xhigh', + modelContextWindow: 210000, + modelAutoCompactTokenLimit: 200000 }); assert(typeof templateCombinedXhigh.template === 'string', 'get-config-template(combined xhigh) missing template'); assert(/^\s*service_tier\s*=\s*"fast"\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing service_tier'); assert(/^\s*model_reasoning_effort\s*=\s*"xhigh"\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_reasoning_effort'); + assert(/^\s*model_context_window\s*=\s*210000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_context_window'); + assert(/^\s*model_auto_compact_token_limit\s*=\s*200000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_auto_compact_token_limit'); // ========== Export Config Tests ========== const exportResult = await api('export-config', { includeKeys: true }); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index aba2360..8b7bb6c 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -29,6 +29,16 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /onConfigTabPointerDown\('codex', \$event\)/); assert.match(html, /onMainTabClick\('sessions', \$event\)/); assert.match(html, /onConfigTabClick\('codex', \$event\)/); + assert.match(html, /上下文压缩阈值<\/span>/); + assert.match(html, /v-model="modelContextWindowInput"/); + assert.match(html, /v-model="modelAutoCompactTokenLimitInput"/); + assert.match(html, /@blur="onModelContextWindowBlur"/); + assert.match(html, /@blur="onModelAutoCompactTokenLimitBlur"/); + assert.match(html, /@keydown\.enter\.prevent="onModelContextWindowBlur"/); + assert.match(html, /@keydown\.enter\.prevent="onModelAutoCompactTokenLimitBlur"/); + assert.match(html, /@click="resetCodexContextBudgetDefaults"/); + assert.match(html, />\s*重置默认值\s*<\/button>/); + assert.match(html, /class="codex-config-grid"/); assert.match(html, /onSettingsTabClick\('backup'\)/); assert.match(html, /onSettingsTabClick\('trash'\)/); assert.match(html, /settingsTab === 'backup'/); @@ -87,6 +97,10 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /id="settings-panel-trash"/); assert.match(html, //); assert.match(html, //); + assert.match(html, /class="settings-tab-actions trash-header-actions" style="display:flex; flex-wrap:nowrap; align-items:stretch; justify-content:flex-end; gap:8px; margin-left:auto;"/); + assert.match(html, / + + +
+
+ + +
控制上下文窗口上限,默认 190000。
+
+
+ + +
控制自动压缩触发阈值,默认 185000。
+
+
+
+ 使用自定义数字输入框;失焦或回车后会按当前 Codex 配置规范写入模板。 +
+ +
AGENTS.md @@ -1121,14 +1168,11 @@

aria-labelledby="settings-tab-trash">
-
- 会话回收站 -
-
- -
diff --git a/web-ui/styles.css b/web-ui/styles.css index 3413fee..38d668f 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -1505,7 +1505,8 @@ body::after { } .settings-tab-header { - align-items: flex-start; + justify-content: flex-end; + align-items: center; } .settings-tab-actions { @@ -1520,6 +1521,40 @@ body::after { width: auto; } +.trash-header-actions { + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 96px; + align-items: stretch; + justify-content: end; + width: auto; + margin-left: auto; +} + +.selector-header .trash-header-actions > .btn-tool, +.selector-header .trash-header-actions > .btn-tool-compact { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + margin: 0; + width: 96px; + min-width: 96px; + height: 32px; + min-height: 32px; + padding: 0 10px; + line-height: 1; + vertical-align: top; + position: relative; + top: 0; + white-space: nowrap; +} + +.selector-header .trash-header-actions > .btn-tool:hover, +.selector-header .trash-header-actions > .btn-tool-compact:hover { + transform: none; +} + .selector-title { font-size: var(--font-size-caption); font-weight: var(--font-weight-secondary); @@ -1671,6 +1706,18 @@ body::after { line-height: 1.4; } +.codex-config-grid { + display: grid; + gap: var(--spacing-sm); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + align-items: start; +} + +.codex-config-field { + min-width: 0; + margin-bottom: 0; +} + .btn-template-editor { width: 100%; margin-top: 2px; @@ -2095,13 +2142,21 @@ body::after { } .trash-item-actions { - display: flex; + display: grid; + grid-template-columns: repeat(2, minmax(108px, 108px)); align-self: flex-end; justify-content: flex-end; - flex-wrap: wrap; gap: 8px; } +.trash-item-actions .btn-mini { + width: 100%; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; +} + .trash-item-time { width: 100%; text-align: right; @@ -4377,21 +4432,6 @@ textarea:focus-visible { } @media (max-width: 540px) { - .session-item-copy.session-item-pin { - width: 44px; - height: 44px; - min-width: 44px; - min-height: 44px; - padding: 10px; - transform: none; - } - - .session-item-copy.session-item-pin svg, - .session-item-copy.session-item-pin .pin-icon { - width: 16px; - height: 16px; - } - body { padding: var(--spacing-md) var(--spacing-sm); } From 0adf35d9dfb6c426f77cb966c0fda11e56826083 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:19:46 +0800 Subject: [PATCH 2/9] fix: address coderabbit context budget review --- cli.js | 17 ++- tests/e2e/test-config.js | 31 +++++ tests/unit/config-tabs-ui.test.mjs | 10 +- tests/unit/provider-share-command.test.mjs | 145 +++++++++++++++++++++ web-ui/app.js | 14 +- web-ui/index.html | 6 +- web-ui/styles.css | 6 + 7 files changed, 221 insertions(+), 8 deletions(-) diff --git a/cli.js b/cli.js index ede816a..a9afad1 100644 --- a/cli.js +++ b/cli.js @@ -3433,6 +3433,20 @@ function applyConfigTemplate(params = {}) { return { error: `模板 TOML 解析失败: ${e.message}` }; } + if ( + Object.prototype.hasOwnProperty.call(parsed, 'model_context_window') + && normalizePositiveIntegerParam(parsed.model_context_window) === null + ) { + return { error: '模板中的 model_context_window 必须是正整数' }; + } + + if ( + Object.prototype.hasOwnProperty.call(parsed, 'model_auto_compact_token_limit') + && normalizePositiveIntegerParam(parsed.model_auto_compact_token_limit) === null + ) { + return { error: '模板中的 model_auto_compact_token_limit 必须是正整数' }; + } + if (!parsed.model_provider || typeof parsed.model_provider !== 'string') { return { error: '模板缺少 model_provider' }; } @@ -10039,7 +10053,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser let result; switch (action) { - case 'status': + case 'status': { const statusConfigResult = readConfigOrVirtualDefault(); const config = statusConfigResult.config; const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : ''; @@ -10059,6 +10073,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser initNotice: consumeInitNotice() }; break; + } case 'install-status': result = buildInstallStatusReport(); break; diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index ea17d4a..01a2175 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -127,6 +127,37 @@ module.exports = async function testConfig(ctx) { assert(/^\s*model_context_window\s*=\s*210000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_context_window'); assert(/^\s*model_auto_compact_token_limit\s*=\s*200000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_auto_compact_token_limit'); + // ========== Apply Config Template Validation Tests ========== + const invalidContextBudgetApply = await api('apply-config-template', { + template: `model_provider = "shadow" +model = "shadow-model" +model_context_window = 0 + +[model_providers.shadow] +base_url = "https://example.test/v1" +preferred_auth_method = "shadow-key" +` + }); + assert( + invalidContextBudgetApply.error === '模板中的 model_context_window 必须是正整数', + 'apply-config-template should reject invalid model_context_window' + ); + + const invalidAutoCompactApply = await api('apply-config-template', { + template: `model_provider = "shadow" +model = "shadow-model" +model_auto_compact_token_limit = "abc" + +[model_providers.shadow] +base_url = "https://example.test/v1" +preferred_auth_method = "shadow-key" +` + }); + assert( + invalidAutoCompactApply.error === '模板中的 model_auto_compact_token_limit 必须是正整数', + 'apply-config-template should reject invalid model_auto_compact_token_limit' + ); + // ========== Export Config Tests ========== const exportResult = await api('export-config', { includeKeys: true }); assert(exportResult.data, 'export-config missing data'); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 8b7bb6c..44f1588 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -97,9 +97,9 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /id="settings-panel-trash"/); assert.match(html, //); assert.match(html, //); - assert.match(html, /class="settings-tab-actions trash-header-actions" style="display:flex; flex-wrap:nowrap; align-items:stretch; justify-content:flex-end; gap:8px; margin-left:auto;"/); - assert.match(html, / -
diff --git a/web-ui/styles.css b/web-ui/styles.css index 38d668f..1419dc9 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -4432,6 +4432,12 @@ textarea:focus-visible { } @media (max-width: 540px) { + .selector-header .trash-header-actions > .btn-tool, + .selector-header .trash-header-actions > .btn-tool-compact { + height: 44px; + min-height: 44px; + } + body { padding: var(--spacing-md) var(--spacing-sm); } From 34ab224eedfe1c9636e27231bbd48a559e7126a9 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:30:43 +0800 Subject: [PATCH 3/9] fix: restore legacy context budget defaults --- cli.js | 18 +++++ tests/e2e/test-config.js | 15 ++++ tests/unit/provider-share-command.test.mjs | 79 ++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/cli.js b/cli.js index a9afad1..c7f3a46 100644 --- a/cli.js +++ b/cli.js @@ -3392,6 +3392,20 @@ function getConfigTemplate(params = {}) { if (typeof params.reasoningEffort === 'string') { template = applyReasoningEffortToTemplate(template, params.reasoningEffort); } + if (!/^\s*model_auto_compact_token_limit\s*=.*$/m.test(template)) { + template = applyPositiveIntegerConfigToTemplate( + template, + 'model_auto_compact_token_limit', + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT + ); + } + if (!/^\s*model_context_window\s*=.*$/m.test(template)) { + template = applyPositiveIntegerConfigToTemplate( + template, + 'model_context_window', + DEFAULT_MODEL_CONTEXT_WINDOW + ); + } if (params.modelAutoCompactTokenLimit !== undefined) { template = applyPositiveIntegerConfigToTemplate( template, @@ -3416,6 +3430,10 @@ function readPositiveIntegerConfigValue(config, key) { return ''; } const raw = config[key]; + if (raw === undefined) { + if (key === 'model_context_window') return DEFAULT_MODEL_CONTEXT_WINDOW; + if (key === 'model_auto_compact_token_limit') return DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT; + } const normalized = normalizePositiveIntegerParam(raw); return normalized === null ? '' : normalized; } diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index 01a2175..bc31ec1 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -391,6 +391,21 @@ preferred_auth_method = "shadow-key" await waitForServer(legacyPort); const legacyApi = (action, params) => postJson(legacyPort, { action, params }, 2000); + const legacyStatus = await legacyApi('status'); + assert(legacyStatus.modelContextWindow === 190000, 'legacy status should default modelContextWindow'); + assert( + legacyStatus.modelAutoCompactTokenLimit === 185000, + 'legacy status should default modelAutoCompactTokenLimit' + ); + const legacyTemplateDefaults = await legacyApi('get-config-template', {}); + assert( + /^\s*model_context_window\s*=\s*190000\s*$/m.test(legacyTemplateDefaults.template), + 'legacy get-config-template should restore default model_context_window' + ); + assert( + /^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m.test(legacyTemplateDefaults.template), + 'legacy get-config-template should restore default model_auto_compact_token_limit' + ); const legacyAddDup = await legacyApi('add-provider', { name: 'foo.bar', url: 'https://dup.example.com/v1', diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 8e40acb..ce8ad13 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -199,6 +199,85 @@ preferred_auth_method = "sk-alpha" assert.strictEqual(writeConfigCalls, 0); }); +test('getConfigTemplate restores missing context budget defaults for upgraded configs', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const applyPositiveIntegerConfigToTemplateSource = extractBlockBySignature( + cliSource, + 'function applyPositiveIntegerConfigToTemplate(template, key, value) {' + ); + const applyPositiveIntegerConfigToTemplate = instantiateFunction( + applyPositiveIntegerConfigToTemplateSource, + 'applyPositiveIntegerConfigToTemplate', + { normalizePositiveIntegerParam } + ); + const getConfigTemplateSource = extractBlockBySignature(cliSource, 'function getConfigTemplate(params = {}) {'); + const getConfigTemplate = instantiateFunction(getConfigTemplateSource, 'getConfigTemplate', { + fs: { + existsSync() { + return true; + }, + readFileSync() { + return `model_provider = "alpha"\nmodel = "alpha-model"\n`; + } + }, + CONFIG_FILE: '/tmp/config.toml', + EMPTY_CONFIG_FALLBACK_TEMPLATE: '', + normalizeTopLevelConfigWithTemplate(content) { + return content; + }, + applyServiceTierToTemplate(template) { + return template; + }, + applyReasoningEffortToTemplate(template) { + return template; + }, + applyPositiveIntegerConfigToTemplate, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + }); + + const result = getConfigTemplate({}); + + assert.match(result.template, /^\s*model_context_window\s*=\s*190000\s*$/m); + assert.match(result.template, /^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m); +}); + +test('readPositiveIntegerConfigValue falls back to defaults only when budget keys are missing', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const readPositiveIntegerConfigValueSource = extractBlockBySignature( + cliSource, + 'function readPositiveIntegerConfigValue(config, key) {' + ); + const readPositiveIntegerConfigValue = instantiateFunction( + readPositiveIntegerConfigValueSource, + 'readPositiveIntegerConfigValue', + { + normalizePositiveIntegerParam, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + } + ); + + assert.strictEqual(readPositiveIntegerConfigValue({}, 'model_context_window'), 190000); + assert.strictEqual(readPositiveIntegerConfigValue({}, 'model_auto_compact_token_limit'), 185000); + assert.strictEqual(readPositiveIntegerConfigValue({}, 'other_key'), ''); + assert.strictEqual(readPositiveIntegerConfigValue({ model_context_window: 0 }, 'model_context_window'), ''); +}); + test('status api case keeps lexical declarations scoped to the switch branch', () => { assert.match(cliSource, /case 'status': \{/); }); From 45ccfba464a5e337c778cbb767f81a6b711cbecb Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:47:57 +0800 Subject: [PATCH 4/9] fix: harden context budget template generation --- cli.js | 23 ++++- tests/unit/provider-share-command.test.mjs | 107 +++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/cli.js b/cli.js index c7f3a46..735e4bf 100644 --- a/cli.js +++ b/cli.js @@ -3366,11 +3366,16 @@ function applyPositiveIntegerConfigToTemplate(template, key, value) { return content; } + const hasBom = content.charCodeAt(0) === 0xFEFF; + const lineEnding = content.includes('\r\n') ? '\r\n' : '\n'; + if (hasBom) { + content = content.slice(1); + } const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`^\\s*${escapedKey}\\s*=\\s*[^\\n]*\\n?`, 'gmi'); content = content.replace(pattern, ''); - content = content.replace(/^\s*\n*/, ''); - return `${key} = ${normalized}\n${content}`; + content = content.replace(new RegExp(`^(?:[\\t ]*${lineEnding})+`), ''); + return `${hasBom ? '\uFEFF' : ''}${key} = ${normalized}${lineEnding}${content}`; } function getConfigTemplate(params = {}) { @@ -3383,6 +3388,20 @@ function getConfigTemplate(params = {}) { } } catch (e) {} } + if ( + params.modelAutoCompactTokenLimit !== undefined + && params.modelAutoCompactTokenLimit !== null + && normalizePositiveIntegerParam(params.modelAutoCompactTokenLimit) === null + ) { + return { error: 'modelAutoCompactTokenLimit must be a positive integer' }; + } + if ( + params.modelContextWindow !== undefined + && params.modelContextWindow !== null + && normalizePositiveIntegerParam(params.modelContextWindow) === null + ) { + return { error: 'modelContextWindow must be a positive integer' }; + } const selectedProvider = typeof params.provider === 'string' ? params.provider.trim() : ''; const selectedModel = typeof params.model === 'string' ? params.model.trim() : ''; let template = normalizeTopLevelConfigWithTemplate(content, selectedProvider, selectedModel); diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index ce8ad13..97defa5 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -249,6 +249,113 @@ test('getConfigTemplate restores missing context budget defaults for upgraded co assert.match(result.template, /^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m); }); +test('getConfigTemplate rejects explicit invalid context budget params', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const applyPositiveIntegerConfigToTemplateSource = extractBlockBySignature( + cliSource, + 'function applyPositiveIntegerConfigToTemplate(template, key, value) {' + ); + const applyPositiveIntegerConfigToTemplate = instantiateFunction( + applyPositiveIntegerConfigToTemplateSource, + 'applyPositiveIntegerConfigToTemplate', + { normalizePositiveIntegerParam } + ); + const getConfigTemplateSource = extractBlockBySignature(cliSource, 'function getConfigTemplate(params = {}) {'); + const getConfigTemplate = instantiateFunction(getConfigTemplateSource, 'getConfigTemplate', { + fs: { + existsSync() { + return false; + }, + readFileSync() { + return ''; + } + }, + CONFIG_FILE: '/tmp/config.toml', + EMPTY_CONFIG_FALLBACK_TEMPLATE: 'model_provider = "alpha"\nmodel = "alpha-model"\n', + normalizeTopLevelConfigWithTemplate(content) { + return content; + }, + applyServiceTierToTemplate(template) { + return template; + }, + applyReasoningEffortToTemplate(template) { + return template; + }, + applyPositiveIntegerConfigToTemplate, + normalizePositiveIntegerParam, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + }); + + assert.deepStrictEqual( + getConfigTemplate({ modelContextWindow: 0 }), + { error: 'modelContextWindow must be a positive integer' } + ); + assert.deepStrictEqual( + getConfigTemplate({ modelAutoCompactTokenLimit: 'abc' }), + { error: 'modelAutoCompactTokenLimit must be a positive integer' } + ); +}); + +test('getConfigTemplate preserves BOM and CRLF when restoring missing context budget defaults', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const applyPositiveIntegerConfigToTemplateSource = extractBlockBySignature( + cliSource, + 'function applyPositiveIntegerConfigToTemplate(template, key, value) {' + ); + const applyPositiveIntegerConfigToTemplate = instantiateFunction( + applyPositiveIntegerConfigToTemplateSource, + 'applyPositiveIntegerConfigToTemplate', + { normalizePositiveIntegerParam } + ); + const getConfigTemplateSource = extractBlockBySignature(cliSource, 'function getConfigTemplate(params = {}) {'); + const getConfigTemplate = instantiateFunction(getConfigTemplateSource, 'getConfigTemplate', { + fs: { + existsSync() { + return true; + }, + readFileSync() { + return '\uFEFFmodel_provider = "alpha"\r\nmodel = "alpha-model"\r\n'; + } + }, + CONFIG_FILE: '/tmp/config.toml', + EMPTY_CONFIG_FALLBACK_TEMPLATE: '', + normalizeTopLevelConfigWithTemplate(content) { + return content; + }, + applyServiceTierToTemplate(template) { + return template; + }, + applyReasoningEffortToTemplate(template) { + return template; + }, + applyPositiveIntegerConfigToTemplate, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + }); + + const result = getConfigTemplate({}); + + assert.strictEqual(result.template.charCodeAt(0), 0xFEFF); + assert(result.template.includes('model_auto_compact_token_limit = 185000\r\n')); + assert(result.template.includes('model_context_window = 190000\r\n')); + assert(!/(? { const normalizePositiveIntegerParamSource = extractBlockBySignature( cliSource, From c49982c8cfb657a2e173102ab440ebef30a6a5c5 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:03:58 +0800 Subject: [PATCH 5/9] fix: address remaining coderabbit review items --- tests/unit/config-tabs-ui.test.mjs | 7 ++++++- tests/unit/provider-share-command.test.mjs | 2 +- web-ui/styles.css | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 44f1588..e1a9a04 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -301,9 +301,12 @@ test('trash item styles stay aligned with session card layout and keep mobile us assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item-actions \.btn-mini\s*\{[\s\S]*min-height:\s*44px;/); assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item \.session-count-badge\s*\{[\s\S]*align-self:\s*flex-start;/); assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item-title\s*\{[\s\S]*-webkit-line-clamp:\s*3;/); + assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-header-actions\s*\{[\s\S]*display:\s*grid;/); + assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-header-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/); + assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-header-actions\s*\{[\s\S]*width:\s*100%;/); assert.match( styles, - /@media \(max-width: 540px\)\s*\{[\s\S]*\.selector-header \.trash-header-actions > \.btn-tool,\s*[\s\S]*min-height:\s*44px;/ + /@media \(max-width: 540px\)\s*\{[\s\S]*\.selector-header \.trash-header-actions > \.btn-tool,\s*[\s\S]*width:\s*100%;[\s\S]*min-width:\s*0;[\s\S]*min-height:\s*44px;/ ); assert.doesNotMatch(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.session-item-copy\.session-item-pin\s*\{[\s\S]*width:\s*44px;/); assert.doesNotMatch( @@ -311,7 +314,9 @@ test('trash item styles stay aligned with session card layout and keep mobile us /@media \(max-width: 540px\)\s*\{[\s\S]*\.session-item-copy\.session-item-pin svg,\s*[\s\S]*width:\s*16px;/ ); assert.match(styles, /\.codex-config-grid\s*\{/); + assert.match(styles, /\.codex-config-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(min\(240px,\s*100%\),\s*1fr\)\);/); assert.match(styles, /\.codex-config-field\s*\{/); + assert.match(styles, /\.codex-config-field\s*\{[\s\S]*min-width:\s*0;/); }); test('settings tab header actions keep compact tool buttons inline on wider screens', () => { diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 97defa5..5162155 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -386,7 +386,7 @@ test('readPositiveIntegerConfigValue falls back to defaults only when budget key }); test('status api case keeps lexical declarations scoped to the switch branch', () => { - assert.match(cliSource, /case 'status': \{/); + assert.match(cliSource, /^\s*case\s+['"]status['"]:\s*\{/m); }); test('applyCodexConfigDirect queues the latest pending budget update while an apply is in flight', async () => { diff --git a/web-ui/styles.css b/web-ui/styles.css index 1419dc9..e42baec 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -1709,7 +1709,7 @@ body::after { .codex-config-grid { display: grid; gap: var(--spacing-sm); - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); align-items: start; } @@ -4432,8 +4432,16 @@ textarea:focus-visible { } @media (max-width: 540px) { + .trash-header-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + .selector-header .trash-header-actions > .btn-tool, .selector-header .trash-header-actions > .btn-tool-compact { + width: 100%; + min-width: 0; height: 44px; min-height: 44px; } From fcf26b5df0310b7e0e9ffda120aa676f895e3627 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:10:59 +0800 Subject: [PATCH 6/9] fix: relax trash header action sizing --- tests/unit/config-tabs-ui.test.mjs | 8 +++++--- web-ui/styles.css | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index e1a9a04..756d9f4 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -331,14 +331,16 @@ test('settings tab header actions keep compact tool buttons inline on wider scre ); assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*display:\s*inline-grid;/); assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*grid-auto-flow:\s*column;/); - assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*grid-auto-columns:\s*96px;/); + assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*grid-auto-columns:\s*minmax\(0,\s*max-content\);/); assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*align-items:\s*stretch;/); assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*justify-content:\s*end;/); + assert.match(styles, /\.trash-header-actions\s*\{[\s\S]*max-width:\s*100%;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*display:\s*flex;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*align-self:\s*stretch;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*margin:\s*0;/); - assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*width:\s*96px;/); - assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*min-width:\s*96px;/); + assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*width:\s*auto;/); + assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*min-width:\s*0;/); + assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*max-width:\s*100%;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*height:\s*32px;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*min-height:\s*32px;/); assert.match(styles, /\.selector-header \.trash-header-actions > \.btn-tool,\s*\.selector-header \.trash-header-actions > \.btn-tool-compact\s*\{[\s\S]*line-height:\s*1;/); diff --git a/web-ui/styles.css b/web-ui/styles.css index e42baec..0ce7dd8 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -1524,10 +1524,11 @@ body::after { .trash-header-actions { display: inline-grid; grid-auto-flow: column; - grid-auto-columns: 96px; + grid-auto-columns: minmax(0, max-content); align-items: stretch; justify-content: end; width: auto; + max-width: 100%; margin-left: auto; } @@ -1538,8 +1539,9 @@ body::after { justify-content: center; align-self: stretch; margin: 0; - width: 96px; - min-width: 96px; + width: auto; + min-width: 0; + max-width: 100%; height: 32px; min-height: 32px; padding: 0 10px; From ee5f09a3a6bce34a674025a161814d53cf9f16cc Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:23:48 +0800 Subject: [PATCH 7/9] fix: tighten codex budget review follow-ups --- cli.js | 34 +++- tests/e2e/test-config.js | 2 + tests/unit/config-tabs-ui.test.mjs | 9 +- tests/unit/provider-share-command.test.mjs | 179 +++++++++++++++++++++ web-ui/app.js | 35 +++- web-ui/index.html | 2 + 6 files changed, 246 insertions(+), 15 deletions(-) diff --git a/cli.js b/cli.js index 735e4bf..3bd11a2 100644 --- a/cli.js +++ b/cli.js @@ -3445,11 +3445,13 @@ function getConfigTemplate(params = {}) { } function readPositiveIntegerConfigValue(config, key) { + const options = arguments[2] && typeof arguments[2] === 'object' ? arguments[2] : {}; + const useDefaultsWhenMissing = options.useDefaultsWhenMissing !== false; if (!config || typeof config !== 'object' || !key) { return ''; } const raw = config[key]; - if (raw === undefined) { + if (raw === undefined && useDefaultsWhenMissing) { if (key === 'model_context_window') return DEFAULT_MODEL_CONTEXT_WINDOW; if (key === 'model_auto_compact_token_limit') return DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT; } @@ -10095,8 +10097,19 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser const config = statusConfigResult.config; const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : ''; const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : ''; - const modelContextWindow = readPositiveIntegerConfigValue(config, 'model_context_window'); - const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(config, 'model_auto_compact_token_limit'); + const budgetReadOptions = { + useDefaultsWhenMissing: !hasConfigLoadError(statusConfigResult) + }; + const modelContextWindow = readPositiveIntegerConfigValue( + config, + 'model_context_window', + budgetReadOptions + ); + const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue( + config, + 'model_auto_compact_token_limit', + budgetReadOptions + ); result = { provider: config.model_provider || '未设置', model: config.model || '未设置', @@ -11583,8 +11596,19 @@ function buildMcpStatusPayload() { const config = statusConfigResult.config; const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : ''; const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : ''; - const modelContextWindow = readPositiveIntegerConfigValue(config, 'model_context_window'); - const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(config, 'model_auto_compact_token_limit'); + const budgetReadOptions = { + useDefaultsWhenMissing: !hasConfigLoadError(statusConfigResult) + }; + const modelContextWindow = readPositiveIntegerConfigValue( + config, + 'model_context_window', + budgetReadOptions + ); + const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue( + config, + 'model_auto_compact_token_limit', + budgetReadOptions + ); return { provider: config.model_provider || '未设置', model: config.model || '未设置', diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index bc31ec1..7d399d9 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -95,6 +95,8 @@ module.exports = async function testConfig(ctx) { modelAutoCompactTokenLimit: 195000 }); assert(typeof templateContextBudget.template === 'string', 'get-config-template(context budget) missing template'); + assert(templateContextBudget.template.includes('model_provider = "shadow"'), 'get-config-template(context budget) missing provider override'); + assert(templateContextBudget.template.includes('model = "shadow-model"'), 'get-config-template(context budget) missing model override'); assert(/^\s*model_context_window\s*=\s*200000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_context_window'); assert(/^\s*model_auto_compact_token_limit\s*=\s*195000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_auto_compact_token_limit'); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 756d9f4..335addd 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -32,12 +32,16 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /上下文压缩阈值<\/span>/); assert.match(html, /v-model="modelContextWindowInput"/); assert.match(html, /v-model="modelAutoCompactTokenLimitInput"/); + assert.match(html, /@focus="editingCodexBudgetField = 'modelContextWindowInput'"/); + assert.match(html, /@focus="editingCodexBudgetField = 'modelAutoCompactTokenLimitInput'"/); assert.match(html, /@blur="onModelContextWindowBlur"/); assert.match(html, /@blur="onModelAutoCompactTokenLimitBlur"/); assert.match(html, /@keydown\.enter\.prevent="onModelContextWindowBlur"/); assert.match(html, /@keydown\.enter\.prevent="onModelAutoCompactTokenLimitBlur"/); - assert.match(html, /@click="resetCodexContextBudgetDefaults"/); - assert.match(html, />\s*重置默认值\s*<\/button>/); + assert.match( + html, + /]*@click="resetCodexContextBudgetDefaults"[^>]*>[\s\S]*?重置默认值[\s\S]*?<\/button>/ + ); assert.match(html, /class="codex-config-grid"/); assert.match(html, /onSettingsTabClick\('backup'\)/); assert.match(html, /onSettingsTabClick\('trash'\)/); @@ -200,6 +204,7 @@ test('web ui script defines provider mode metadata for codex only', () => { assert.match(appScript, /pendingProviderSwitch:\s*''/); assert.match(appScript, /modelContextWindowInput:\s*'190000'/); assert.match(appScript, /modelAutoCompactTokenLimitInput:\s*'185000'/); + assert.match(appScript, /editingCodexBudgetField:\s*''/); assert.match(appScript, /statusRes\.modelContextWindow/); assert.match(appScript, /statusRes\.modelAutoCompactTokenLimit/); assert.match(appScript, /onModelContextWindowBlur\(\)/); diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 5162155..6ec974d 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -385,6 +385,64 @@ test('readPositiveIntegerConfigValue falls back to defaults only when budget key assert.strictEqual(readPositiveIntegerConfigValue({ model_context_window: 0 }, 'model_context_window'), ''); }); +test('buildMcpStatusPayload does not synthesize budget defaults after config load errors', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const readPositiveIntegerConfigValueSource = extractBlockBySignature( + cliSource, + 'function readPositiveIntegerConfigValue(config, key) {' + ); + const readPositiveIntegerConfigValue = instantiateFunction( + readPositiveIntegerConfigValueSource, + 'readPositiveIntegerConfigValue', + { + normalizePositiveIntegerParam, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + } + ); + const buildMcpStatusPayloadSource = extractBlockBySignature( + cliSource, + 'function buildMcpStatusPayload() {' + ); + const hasConfigLoadErrorSource = extractBlockBySignature( + cliSource, + 'function hasConfigLoadError(result) {' + ); + const hasConfigLoadError = instantiateFunction( + hasConfigLoadErrorSource, + 'hasConfigLoadError' + ); + const buildMcpStatusPayload = instantiateFunction( + buildMcpStatusPayloadSource, + 'buildMcpStatusPayload', + { + readConfigOrVirtualDefault: () => ({ + config: {}, + isVirtual: true, + errorType: 'parse', + reason: 'config.toml 解析失败' + }), + hasConfigLoadError, + readPositiveIntegerConfigValue, + consumeInitNotice: () => '' + } + ); + + const result = buildMcpStatusPayload(); + + assert.strictEqual(result.modelContextWindow, ''); + assert.strictEqual(result.modelAutoCompactTokenLimit, ''); + assert.strictEqual(result.configErrorType, 'parse'); + assert.strictEqual(result.configNotice, 'config.toml 解析失败'); +}); + test('status api case keeps lexical declarations scoped to the switch branch', () => { assert.match(cliSource, /^\s*case\s+['"]status['"]:\s*\{/m); }); @@ -471,8 +529,129 @@ test('applyCodexConfigDirect queues the latest pending budget update while an ap assert.strictEqual(templateRequests[1].modelContextWindow, 190000); assert.strictEqual(templateRequests[1].modelAutoCompactTokenLimit, 175000); assert.strictEqual(appliedTemplates.length, 2); + assert.strictEqual(appliedTemplates[0].template, 'template-1'); + assert.strictEqual(appliedTemplates[1].template, 'template-2'); assert.strictEqual(loadAllCalls, 2); assert.strictEqual(context._pendingCodexApplyOptions, null); assert.strictEqual(context.codexApplying, false); assert.deepStrictEqual(messages, []); }); + +test('loadAll preserves an unsaved codex budget draft while refreshing the sibling value', async () => { + const loadAllSource = extractBlockBySignature( + appSource, + 'async loadAll() {' + ).replace(/^async loadAll/, 'async function loadAll'); + const loadAll = instantiateFunction(loadAllSource, 'loadAll', { + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000, + api: async (action) => { + if (action === 'status') { + return { + provider: 'alpha', + model: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindow: 200000, + modelAutoCompactTokenLimit: 185000, + configReady: true, + initNotice: '' + }; + } + if (action === 'list') { + return { + providers: [{ name: 'alpha', url: 'https://api.example.com/v1', hasKey: true }] + }; + } + throw new Error(`Unexpected api action: ${action}`); + } + }); + + const context = { + loading: false, + initError: '', + currentProvider: 'alpha', + currentModel: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindowInput: '190000', + modelAutoCompactTokenLimitInput: '180000', + editingCodexBudgetField: 'modelAutoCompactTokenLimitInput', + providersList: [], + normalizePositiveIntegerInput(value, label, fallback = '') { + const raw = value === undefined || value === null || value === '' + ? String(fallback || '') + : String(value); + const text = raw.trim(); + const numeric = Number.parseInt(text, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { ok: false, error: `${label} invalid` }; + } + return { ok: true, value: numeric, text: String(numeric) }; + }, + showMessage() {}, + maybeShowStarPrompt() {}, + async loadModelsForProvider() {}, + async loadCodexAuthProfiles() {} + }; + + await loadAll.call(context); + + assert.strictEqual(context.modelContextWindowInput, '200000'); + assert.strictEqual(context.modelAutoCompactTokenLimitInput, '180000'); +}); + +test('applyCodexConfigDirect surfaces backend validation details from direct apply failures', async () => { + const applyCodexConfigDirectSource = extractBlockBySignature( + appSource, + 'async applyCodexConfigDirect(options = {}) {' + ).replace(/^async applyCodexConfigDirect/, 'async function applyCodexConfigDirect'); + const messages = []; + const applyCodexConfigDirect = instantiateFunction(applyCodexConfigDirectSource, 'applyCodexConfigDirect', { + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000, + api: async (action) => { + if (action === 'get-config-template') { + return { error: '模板中的 model_context_window 必须是正整数' }; + } + throw new Error(`Unexpected api action: ${action}`); + } + }); + + const context = { + codexApplying: false, + _pendingCodexApplyOptions: null, + currentProvider: 'alpha', + currentModel: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindowInput: '190000', + modelAutoCompactTokenLimitInput: '185000', + normalizePositiveIntegerInput(value, label, fallback = '') { + const raw = value === undefined || value === null || value === '' + ? String(fallback || '') + : String(value); + const text = raw.trim(); + const numeric = Number.parseInt(text, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { ok: false, error: `${label} invalid` }; + } + return { ok: true, value: numeric, text: String(numeric) }; + }, + showMessage(message, type) { + messages.push({ message, type }); + }, + async loadAll() { + throw new Error('loadAll should not be called when template generation fails'); + } + }; + + await applyCodexConfigDirect.call(context, { silent: true }); + + assert.deepStrictEqual(messages, [{ + message: '模板中的 model_context_window 必须是正整数', + type: 'error' + }]); + assert.strictEqual(context.codexApplying, false); + assert.strictEqual(context._pendingCodexApplyOptions, null); +}); diff --git a/web-ui/app.js b/web-ui/app.js index 89cc3f4..257fde3 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -118,6 +118,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; modelReasoningEffort: 'high', modelContextWindowInput: '190000', modelAutoCompactTokenLimitInput: '185000', + editingCodexBudgetField: '', providersList: [], models: [], codexModelsLoading: false, @@ -714,9 +715,11 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; 'model_context_window', DEFAULT_MODEL_CONTEXT_WINDOW ); - this.modelContextWindowInput = contextWindow.ok && contextWindow.text - ? contextWindow.text - : '190000'; + if (this.editingCodexBudgetField !== 'modelContextWindowInput') { + this.modelContextWindowInput = contextWindow.ok && contextWindow.text + ? contextWindow.text + : '190000'; + } } { const autoCompactTokenLimit = this.normalizePositiveIntegerInput( @@ -724,9 +727,11 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; 'model_auto_compact_token_limit', DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT ); - this.modelAutoCompactTokenLimitInput = autoCompactTokenLimit.ok && autoCompactTokenLimit.text - ? autoCompactTokenLimit.text - : '185000'; + if (this.editingCodexBudgetField !== 'modelAutoCompactTokenLimitInput') { + this.modelAutoCompactTokenLimitInput = autoCompactTokenLimit.ok && autoCompactTokenLimit.text + ? autoCompactTokenLimit.text + : '185000'; + } } this.providersList = listRes.providers; if (statusRes.configReady === false) { @@ -3244,6 +3249,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; }, async onModelContextWindowBlur() { + this.editingCodexBudgetField = ''; const normalized = this.normalizePositiveIntegerInput( this.modelContextWindowInput, 'model_context_window', @@ -3261,6 +3267,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; }, async onModelAutoCompactTokenLimitBlur() { + this.editingCodexBudgetField = ''; const normalized = this.normalizePositiveIntegerInput( this.modelAutoCompactTokenLimitInput, 'model_auto_compact_token_limit', @@ -3452,7 +3459,13 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; modelAutoCompactTokenLimit: modelAutoCompactTokenLimit.value }); if (tplRes.error) { - this.showMessage('获取模板失败', 'error'); + this.showMessage( + (typeof tplRes.error === 'string' && tplRes.error.trim()) + || (typeof tplRes.message === 'string' && tplRes.message.trim()) + || (typeof tplRes.detail === 'string' && tplRes.detail.trim()) + || '获取模板失败', + 'error' + ); return; } @@ -3460,7 +3473,13 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; template: tplRes.template }); if (applyRes.error) { - this.showMessage('应用模板失败', 'error'); + this.showMessage( + (typeof applyRes.error === 'string' && applyRes.error.trim()) + || (typeof applyRes.message === 'string' && applyRes.message.trim()) + || (typeof applyRes.detail === 'string' && applyRes.detail.trim()) + || '应用模板失败', + 'error' + ); return; } diff --git a/web-ui/index.html b/web-ui/index.html index c11e1ac..8fc07b0 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -490,6 +490,7 @@

inputmode="numeric" autocomplete="off" placeholder="例如: 190000" + @focus="editingCodexBudgetField = 'modelContextWindowInput'" @input="sanitizePositiveIntegerDraft('modelContextWindowInput')" @blur="onModelContextWindowBlur" @keydown.enter.prevent="onModelContextWindowBlur"> @@ -504,6 +505,7 @@

inputmode="numeric" autocomplete="off" placeholder="例如: 185000" + @focus="editingCodexBudgetField = 'modelAutoCompactTokenLimitInput'" @input="sanitizePositiveIntegerDraft('modelAutoCompactTokenLimitInput')" @blur="onModelAutoCompactTokenLimitBlur" @keydown.enter.prevent="onModelAutoCompactTokenLimitBlur"> From f42b75cb1d126d7914fb426fa2d8014c0b8cbaad Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:33:06 +0800 Subject: [PATCH 8/9] test: add codex budget regression coverage --- tests/unit/provider-share-command.test.mjs | 168 +++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 6ec974d..b779777 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -443,6 +443,70 @@ test('buildMcpStatusPayload does not synthesize budget defaults after config loa assert.strictEqual(result.configNotice, 'config.toml 解析失败'); }); +test('status api case does not synthesize budget defaults after config load errors', () => { + const normalizePositiveIntegerParamSource = extractBlockBySignature( + cliSource, + 'function normalizePositiveIntegerParam(value) {' + ); + const normalizePositiveIntegerParam = instantiateFunction( + normalizePositiveIntegerParamSource, + 'normalizePositiveIntegerParam' + ); + const readPositiveIntegerConfigValueSource = extractBlockBySignature( + cliSource, + 'function readPositiveIntegerConfigValue(config, key) {' + ); + const readPositiveIntegerConfigValue = instantiateFunction( + readPositiveIntegerConfigValueSource, + 'readPositiveIntegerConfigValue', + { + normalizePositiveIntegerParam, + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000 + } + ); + const hasConfigLoadErrorSource = extractBlockBySignature( + cliSource, + 'function hasConfigLoadError(result) {' + ); + const hasConfigLoadError = instantiateFunction( + hasConfigLoadErrorSource, + 'hasConfigLoadError' + ); + const statusCaseBlock = extractBlockBySignature( + cliSource, + "case 'status': {" + ) + .replace(/^case\s+['"]status['"]:\s*/, '') + .replace(/\bbreak;\s*(?=\}\s*$)/, ''); + const runStatusCase = instantiateFunction( + `function runStatusCase() { + let result; + ${statusCaseBlock} + return result; + }`, + 'runStatusCase', + { + readConfigOrVirtualDefault: () => ({ + config: {}, + isVirtual: true, + errorType: 'parse', + reason: 'config.toml 解析失败' + }), + hasConfigLoadError, + readPositiveIntegerConfigValue, + consumeInitNotice: () => '' + } + ); + + const result = runStatusCase(); + + assert.strictEqual(result.modelContextWindow, ''); + assert.strictEqual(result.modelAutoCompactTokenLimit, ''); + assert.strictEqual(result.configErrorType, 'parse'); + assert.strictEqual(result.configNotice, 'config.toml 解析失败'); +}); + test('status api case keeps lexical declarations scoped to the switch branch', () => { assert.match(cliSource, /^\s*case\s+['"]status['"]:\s*\{/m); }); @@ -601,6 +665,110 @@ test('loadAll preserves an unsaved codex budget draft while refreshing the sibli assert.strictEqual(context.modelAutoCompactTokenLimitInput, '180000'); }); +test('applyCodexConfigDirect preserves a focused sibling budget draft across the loadAll refresh', async () => { + const loadAllSource = extractBlockBySignature( + appSource, + 'async loadAll() {' + ).replace(/^async loadAll/, 'async function loadAll'); + const loadAll = instantiateFunction(loadAllSource, 'loadAll', { + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000, + api: async (action) => { + if (action === 'status') { + return { + provider: 'alpha', + model: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindow: 200000, + modelAutoCompactTokenLimit: 185000, + configReady: true, + initNotice: '' + }; + } + if (action === 'list') { + return { + providers: [{ name: 'alpha', url: 'https://api.example.com/v1', hasKey: true }] + }; + } + throw new Error(`Unexpected loadAll api action: ${action}`); + } + }); + const applyCodexConfigDirectSource = extractBlockBySignature( + appSource, + 'async applyCodexConfigDirect(options = {}) {' + ).replace(/^async applyCodexConfigDirect/, 'async function applyCodexConfigDirect'); + let firstTemplateResolve = null; + const applyCodexConfigDirect = instantiateFunction( + applyCodexConfigDirectSource, + 'applyCodexConfigDirect', + { + DEFAULT_MODEL_CONTEXT_WINDOW: 190000, + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT: 185000, + api: async (action, params) => { + if (action === 'get-config-template') { + return await new Promise((resolve) => { + firstTemplateResolve = resolve; + }); + } + if (action === 'apply-config-template') { + assert.deepStrictEqual(params, { template: 'template-1' }); + return { success: true }; + } + throw new Error(`Unexpected apply api action: ${action}`); + } + } + ); + + const context = { + loading: false, + initError: '', + codexApplying: false, + _pendingCodexApplyOptions: null, + currentProvider: 'alpha', + currentModel: 'alpha-model', + serviceTier: 'fast', + modelReasoningEffort: 'high', + modelContextWindowInput: '190000', + modelAutoCompactTokenLimitInput: '185000', + editingCodexBudgetField: '', + providersList: [], + normalizePositiveIntegerInput(value, label, fallback = '') { + const raw = value === undefined || value === null || value === '' + ? String(fallback || '') + : String(value); + const text = raw.trim(); + const numeric = Number.parseInt(text, 10); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { ok: false, error: `${label} invalid` }; + } + return { ok: true, value: numeric, text: String(numeric) }; + }, + showMessage() {}, + maybeShowStarPrompt() {}, + async loadModelsForProvider() {}, + async loadCodexAuthProfiles() {} + }; + context.loadAll = loadAll; + context.applyCodexConfigDirect = applyCodexConfigDirect; + + const firstApply = applyCodexConfigDirect.call(context, { + silent: true, + modelContextWindow: 200000 + }); + await Promise.resolve(); + + context.editingCodexBudgetField = 'modelAutoCompactTokenLimitInput'; + context.modelAutoCompactTokenLimitInput = '180000'; + + firstTemplateResolve({ template: 'template-1' }); + await firstApply; + + assert.strictEqual(context.modelContextWindowInput, '200000'); + assert.strictEqual(context.modelAutoCompactTokenLimitInput, '180000'); + assert.strictEqual(context.editingCodexBudgetField, 'modelAutoCompactTokenLimitInput'); +}); + test('applyCodexConfigDirect surfaces backend validation details from direct apply failures', async () => { const applyCodexConfigDirectSource = extractBlockBySignature( appSource, From c287ceba8f718b2978b1353aeed8b79f84a4ace4 Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:37:38 +0800 Subject: [PATCH 9/9] test: tighten config template validation guards --- tests/unit/provider-share-command.test.mjs | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index b779777..edd5c3d 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -157,22 +157,34 @@ test('applyConfigTemplate rejects invalid positive integer context budget values ); const applyConfigTemplateSource = extractBlockBySignature(cliSource, 'function applyConfigTemplate(params = {}) {'); let writeConfigCalls = 0; + let updateAuthJsonCalls = 0; + let writeModelsCalls = 0; + let writeCurrentModelsCalls = 0; + let recordRecentConfigCalls = 0; const applyConfigTemplate = instantiateFunction(applyConfigTemplateSource, 'applyConfigTemplate', { toml: require('@iarna/toml'), normalizePositiveIntegerParam, writeConfig() { writeConfigCalls += 1; }, - updateAuthJson() {}, + updateAuthJson() { + updateAuthJsonCalls += 1; + }, readModels() { return []; }, - writeModels() {}, + writeModels() { + writeModelsCalls += 1; + }, readCurrentModels() { return {}; }, - writeCurrentModels() {}, - recordRecentConfig() {} + writeCurrentModels() { + writeCurrentModelsCalls += 1; + }, + recordRecentConfig() { + recordRecentConfigCalls += 1; + } }); const invalidContextResult = applyConfigTemplate({ @@ -197,6 +209,10 @@ preferred_auth_method = "sk-alpha" assert.deepStrictEqual(invalidContextResult, { error: '模板中的 model_context_window 必须是正整数' }); assert.deepStrictEqual(invalidAutoCompactResult, { error: '模板中的 model_auto_compact_token_limit 必须是正整数' }); assert.strictEqual(writeConfigCalls, 0); + assert.strictEqual(updateAuthJsonCalls, 0); + assert.strictEqual(writeModelsCalls, 0); + assert.strictEqual(writeCurrentModelsCalls, 0); + assert.strictEqual(recordRecentConfigCalls, 0); }); test('getConfigTemplate restores missing context budget defaults for upgraded configs', () => {