diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 42453cb..95786cf 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3752,6 +3752,42 @@ export function mergeHermesDisplayConfig(config = {}, form = {}) { return next } +export function buildHermesKanbanConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const kanban = root.kanban && typeof root.kanban === 'object' && !Array.isArray(root.kanban) + ? root.kanban + : {} + return { + dispatchStaleTimeoutSeconds: parseHermesInteger( + kanban.dispatch_stale_timeout_seconds, + 'kanban.dispatch_stale_timeout_seconds', + 14400, + 0, + 604800, + false, + ), + } +} + +export function mergeHermesKanbanConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesKanbanConfigValues(next) + const kanban = next.kanban && typeof next.kanban === 'object' && !Array.isArray(next.kanban) + ? mergeConfigsPreservingFields(next.kanban, {}) + : {} + + kanban.dispatch_stale_timeout_seconds = parseHermesInteger( + Object.hasOwn(form, 'dispatchStaleTimeoutSeconds') ? form.dispatchStaleTimeoutSeconds : currentValues.dispatchStaleTimeoutSeconds, + 'kanban.dispatch_stale_timeout_seconds', + 14400, + 0, + 604800, + true, + ) + next.kanban = kanban + return next +} + function putHermesChannelDisplayFields(form, config, platform) { const { display, platformDisplay } = hermesDisplayConfigParts(config, platform) const legacyToolProgress = display.tool_progress_overrides && typeof display.tool_progress_overrides === 'object' && !Array.isArray(display.tool_progress_overrides) @@ -12028,6 +12064,27 @@ const handlers = { } }, + hermes_kanban_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesKanbanConfigValues(config), + } + }, + + hermes_kanban_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesKanbanConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesKanbanConfigValues(next), + } + }, + hermes_streaming_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 4649c4c..99b662e 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -6539,6 +6539,40 @@ fn build_hermes_human_delay_config_values(config: &serde_yaml::Value) -> Value { }) } +fn build_hermes_kanban_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let kanban = root.and_then(|map| yaml_get_mapping(map, "kanban")); + serde_json::json!({ + "dispatchStaleTimeoutSeconds": kanban + .map(|map| bounded_hermes_i64( + yaml_i64_field(map, "dispatch_stale_timeout_seconds"), + 14400, + 0, + 604800, + )) + .unwrap_or(14400), + }) +} + +fn merge_hermes_kanban_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_kanban_config_values(config); + let stale_timeout = validate_hermes_i64( + form_i64(form, "dispatchStaleTimeoutSeconds") + .or_else(|| current["dispatchStaleTimeoutSeconds"].as_i64()), + "kanban.dispatch_stale_timeout_seconds", + 14400, + 0, + 604800, + )?; + + let kanban = yaml_child_object(ensure_yaml_object(config)?, "kanban")?; + kanban.insert( + yaml_key("dispatch_stale_timeout_seconds"), + serde_yaml::Value::Number(serde_yaml::Number::from(stale_timeout)), + ); + Ok(()) +} + fn merge_hermes_human_delay_config( config: &mut serde_yaml::Value, form: &Value, @@ -9575,6 +9609,29 @@ pub fn hermes_display_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_kanban_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_kanban_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_kanban_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_kanban_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_kanban_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_human_delay_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -19263,6 +19320,84 @@ memory: } } +#[cfg(test)] +mod hermes_kanban_config_tests { + use super::{build_hermes_kanban_config_values, merge_hermes_kanban_config}; + use serde_json::json; + + #[test] + fn kanban_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_kanban_config_values(&config); + assert_eq!(values["dispatchStaleTimeoutSeconds"], 14400); + } + + #[test] + fn kanban_values_normalize_existing_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +kanban: + dispatch_stale_timeout_seconds: "7200" +"#, + ) + .unwrap(); + let values = build_hermes_kanban_config_values(&config); + assert_eq!(values["dispatchStaleTimeoutSeconds"], 7200); + } + + #[test] + fn merge_kanban_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +kanban: + dispatch_interval_seconds: 30 + custom_flag: keep-me +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_kanban_config( + &mut config, + &json!({ + "dispatchStaleTimeoutSeconds": 0, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!( + config["kanban"]["dispatch_interval_seconds"].as_i64(), + Some(30) + ); + assert_eq!(config["kanban"]["custom_flag"].as_str(), Some("keep-me")); + assert_eq!( + config["kanban"]["dispatch_stale_timeout_seconds"].as_i64(), + Some(0) + ); + } + + #[test] + fn merge_kanban_config_rejects_invalid_timeout() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_kanban_config(&mut config, &json!({ "dispatchStaleTimeoutSeconds": -1 })) + .unwrap_err(); + assert!(err.contains("kanban.dispatch_stale_timeout_seconds")); + + let err = merge_hermes_kanban_config( + &mut config, + &json!({ "dispatchStaleTimeoutSeconds": 604801 }), + ) + .unwrap_err(); + assert!(err.contains("kanban.dispatch_stale_timeout_seconds")); + } +} + #[cfg(test)] mod hermes_security_config_tests { use super::{build_hermes_security_config_values, merge_hermes_security_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 552956b..ddef617 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -299,6 +299,8 @@ pub fn run() { hermes::hermes_security_config_save, hermes::hermes_display_config_read, hermes::hermes_display_config_save, + hermes::hermes_kanban_config_read, + hermes::hermes_kanban_config_save, hermes::hermes_human_delay_config_read, hermes::hermes_human_delay_config_save, hermes::hermes_streaming_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 0746462..0e64b0b 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -173,6 +173,10 @@ const HUMAN_DELAY_DEFAULTS = { humanDelayMaxMs: 2500, } +const KANBAN_DEFAULTS = { + dispatchStaleTimeoutSeconds: 14400, +} + const STREAMING_DEFAULTS = { enabled: false, transport: 'edit', @@ -333,6 +337,7 @@ export function render() { let securityValues = { ...SECURITY_DEFAULTS } let displayValues = { ...DISPLAY_DEFAULTS } let humanDelayValues = { ...HUMAN_DELAY_DEFAULTS } + let kanbanValues = { ...KANBAN_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let ioSafetyValues = { ...IO_SAFETY_DEFAULTS } @@ -367,6 +372,7 @@ export function render() { let securityLoading = true let displayLoading = true let humanDelayLoading = true + let kanbanLoading = true let streamingLoading = true let executionLimitsLoading = true let ioSafetyLoading = true @@ -401,6 +407,7 @@ export function render() { let securitySaving = false let displaySaving = false let humanDelaySaving = false + let kanbanSaving = false let streamingSaving = false let executionLimitsSaving = false let ioSafetySaving = false @@ -435,6 +442,7 @@ export function render() { let securityError = null let displayError = null let humanDelayError = null + let kanbanError = null let streamingError = null let executionLimitsError = null let ioSafetyError = null @@ -456,7 +464,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -1461,6 +1469,34 @@ export function render() { ` } + function renderKanbanConfigPanel() { + const disabled = loading || saving || kanbanLoading || kanbanSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesKanbanConfigTitle')}
+
${t('engine.hermesKanbanConfigDesc')}
+
+
+ ${kanbanSaving ? t('engine.hermesConfigStatusSaving') : kanbanLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesKanbanConfigStatusReady')} + +
+
+
+ ${renderError(kanbanError)} +
+ +
+
${t('engine.hermesKanbanConfigFootnote')}
+
+
+ ` + } + function renderStreamingPanel() { const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return ` @@ -2110,6 +2146,7 @@ export function render() { ${renderSecurityConfigPanel()} ${renderDisplayConfigPanel()} ${renderHumanDelayConfigPanel()} + ${renderKanbanConfigPanel()}
@@ -2151,6 +2188,7 @@ export function render() { el.querySelector('#hm-security-save')?.addEventListener('click', saveSecurityConfig) el.querySelector('#hm-display-save')?.addEventListener('click', saveDisplayConfig) el.querySelector('#hm-human-delay-save')?.addEventListener('click', saveHumanDelayConfig) + el.querySelector('#hm-kanban-config-save')?.addEventListener('click', saveKanbanConfig) el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) el.querySelector('#hm-io-safety-save')?.addEventListener('click', saveIoSafety) @@ -2279,6 +2317,11 @@ export function render() { humanDelayValues = { ...HUMAN_DELAY_DEFAULTS, ...(data?.values || {}) } } + async function loadKanbanConfig() { + const data = await api.hermesKanbanConfigRead() + kanbanValues = { ...KANBAN_DEFAULTS, ...(data?.values || {}) } + } + async function loadStreaming() { const data = await api.hermesStreamingConfigRead() streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } @@ -2358,6 +2401,7 @@ export function render() { securityLoading = true displayLoading = true humanDelayLoading = true + kanbanLoading = true streamingLoading = true executionLimitsLoading = true ioSafetyLoading = true @@ -2675,6 +2719,14 @@ export function render() { humanDelayLoading = false draw() } + try { + await loadKanbanConfig() + } catch (err) { + kanbanError = humanizeError(err, t('engine.hermesKanbanConfigLoadFailed') || 'Load Kanban config failed') + } finally { + kanbanLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -2759,6 +2811,9 @@ export function render() { try { await loadHumanDelayConfig() } catch {} + try { + await loadKanbanConfig() + } catch {} try { await loadStreaming() } catch {} @@ -3427,6 +3482,31 @@ export function render() { } } + async function saveKanbanConfig() { + const form = { + dispatchStaleTimeoutSeconds: el.querySelector('#hm-kanban-dispatch-stale-timeout-seconds')?.value || '14400', + } + kanbanSaving = true + kanbanError = null + draw() + try { + const result = await api.hermesKanbanConfigSave(form) + kanbanValues = { ...KANBAN_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesKanbanConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + kanbanError = humanizeError(err, t('engine.hermesKanbanConfigSaveFailed') || 'Save Kanban config failed') + toast(kanbanError, 'error') + } finally { + kanbanSaving = false + draw() + } + } + async function saveStreaming() { const form = { enabled: !!el.querySelector('#hm-streaming-enabled')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 15e86a3..45416cb 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -551,6 +551,8 @@ export const api = { hermesSecurityConfigSave: (form) => invoke('hermes_security_config_save', { form }), hermesDisplayConfigRead: () => invoke('hermes_display_config_read'), hermesDisplayConfigSave: (form) => invoke('hermes_display_config_save', { form }), + hermesKanbanConfigRead: () => invoke('hermes_kanban_config_read'), + hermesKanbanConfigSave: (form) => invoke('hermes_kanban_config_save', { form }), hermesHumanDelayConfigRead: () => invoke('hermes_human_delay_config_read'), hermesHumanDelayConfigSave: (form) => invoke('hermes_human_delay_config_save', { form }), hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 282d820..e14c4a7 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -1028,6 +1028,15 @@ export default { hermesHumanDelayConfigMinMs: _('最小延迟 ms', 'Minimum delay ms', '最小延遲 ms'), hermesHumanDelayConfigMaxMs: _('最大延迟 ms', 'Maximum delay ms', '最大延遲 ms'), hermesHumanDelayConfigFootnote: _('natural 使用 800-2500ms;custom 使用下方范围。Signal 等平台可能忽略或仅部分支持该设置。', 'natural uses 800-2500ms; custom uses the range below. Platforms such as Signal may ignore or only partially support this setting.', 'natural 使用 800-2500ms;custom 使用下方範圍。Signal 等平台可能忽略或僅部分支援此設定。'), + hermesKanbanConfigTitle: _('Kanban 调度稳定性', 'Kanban dispatch reliability', 'Kanban 調度穩定性'), + hermesKanbanConfigDesc: _('控制 Kanban Worker 多久没有心跳后被自动回收,避免长任务卡在运行中。', 'Control how long a Kanban worker may go without a heartbeat before it is reclaimed, preventing long tasks from staying stuck as running.', '控制 Kanban Worker 多久沒有心跳後被自動回收,避免長任務卡在執行中。'), + hermesKanbanConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesKanbanConfigSave: _('保存 Kanban 设置', 'Save Kanban settings', '儲存 Kanban 設定'), + hermesKanbanConfigSaveSuccess: _('Kanban 调度配置已保存,建议重启 Hermes Gateway 生效', 'Kanban dispatch settings saved. Restart Hermes Gateway to take effect.', 'Kanban 調度設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesKanbanConfigLoadFailed: _('加载 Kanban 调度配置失败', 'Load Kanban dispatch settings failed', '載入 Kanban 調度設定失敗'), + hermesKanbanConfigSaveFailed: _('保存 Kanban 调度配置失败', 'Save Kanban dispatch settings failed', '儲存 Kanban 調度設定失敗'), + hermesKanbanConfigDispatchStaleTimeoutSeconds: _('无心跳回收时间(秒)', 'Heartbeat reclaim timeout (s)', '無心跳回收時間(秒)'), + hermesKanbanConfigFootnote: _('写入 kanban.dispatch_stale_timeout_seconds。默认 14400 秒;设为 0 会关闭无心跳自动回收。建议只在确认 Worker 会长时间离线且由外部系统接管时关闭。', 'Writes kanban.dispatch_stale_timeout_seconds. Default is 14400 seconds; set 0 to disable heartbeat-based reclaim. Disable it only when workers may stay offline for long periods and an external supervisor handles recovery.', '寫入 kanban.dispatch_stale_timeout_seconds。預設 14400 秒;設為 0 會關閉無心跳自動回收。建議只在確認 Worker 會長時間離線且由外部系統接管時關閉。'), hermesSecurityConfigTitle: _('Tirith 安全扫描', 'Tirith security scanning', 'Tirith 安全掃描'), hermesSecurityConfigDesc: _('控制终端命令执行前的 Tirith 内容扫描,拦截明显的 URL 伪装、管道执行和注入风险。', 'Control Tirith content scanning before terminal commands run to catch obvious URL spoofing, pipe-to-shell, and injection risks.', '控制終端命令執行前的 Tirith 內容掃描,攔截明顯的 URL 偽裝、管道執行和注入風險。'), hermesSecurityConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index ede6f06..ef2d43c 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -425,6 +425,15 @@ test('Hermes 配置页会暴露语音转写结构化配置字段', () => { } }) +test('Hermes 配置页会暴露 Kanban 调度稳定性结构化配置字段', () => { + for (const id of [ + 'hm-kanban-config-save', + 'hm-kanban-dispatch-stale-timeout-seconds', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页数值输入会保留 0 值显示', () => { assert.doesNotMatch(source, /String\(value \|\| ''\)/, 'esc(value) 不能把合法 0 渲染为空字符串') }) @@ -455,6 +464,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('BrowserConfig') || key.includes('TerminalConfig') || key.includes('SttConfig') || + key.includes('KanbanConfig') || key.includes('CheckpointsConfig') || key.includes('ApprovalsConfig') || key.includes('CronConfig') || diff --git a/tests/hermes-kanban-config.test.js b/tests/hermes-kanban-config.test.js new file mode 100644 index 0000000..7a0b694 --- /dev/null +++ b/tests/hermes-kanban-config.test.js @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesKanbanConfigValues, + mergeHermesKanbanConfig, +} from '../scripts/dev-api.js' + +test('Hermes Kanban 配置读取会提供上游默认值', () => { + const values = buildHermesKanbanConfigValues({}) + + assert.deepEqual(values, { + dispatchStaleTimeoutSeconds: 14400, + }) +}) + +test('Hermes Kanban 配置读取会规范化已有字段', () => { + const values = buildHermesKanbanConfigValues({ + kanban: { + dispatch_stale_timeout_seconds: '7200', + }, + }) + + assert.equal(values.dispatchStaleTimeoutSeconds, 7200) +}) + +test('Hermes Kanban 配置保存会保留未知 YAML 并写入 kanban', () => { + const next = mergeHermesKanbanConfig({ + model: { provider: 'anthropic' }, + kanban: { + dispatch_interval_seconds: 30, + custom_flag: 'keep-me', + }, + memory: { memory_enabled: true }, + }, { + dispatchStaleTimeoutSeconds: '0', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.equal(next.kanban.dispatch_interval_seconds, 30) + assert.equal(next.kanban.custom_flag, 'keep-me') + assert.equal(next.kanban.dispatch_stale_timeout_seconds, 0) +}) + +test('Hermes Kanban 配置保存会拒绝非法超时', () => { + assert.throws( + () => mergeHermesKanbanConfig({}, { dispatchStaleTimeoutSeconds: '-1' }), + /kanban\.dispatch_stale_timeout_seconds/, + ) + assert.throws( + () => mergeHermesKanbanConfig({}, { dispatchStaleTimeoutSeconds: '604801' }), + /kanban\.dispatch_stale_timeout_seconds/, + ) + assert.throws( + () => mergeHermesKanbanConfig({}, { dispatchStaleTimeoutSeconds: '12.5' }), + /kanban\.dispatch_stale_timeout_seconds/, + ) +})