diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index a07d9bb6af..e17e3856fb 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -41,7 +41,7 @@ import { getDeferredToolsDeltaAttachment, getMcpInstructionsDeltaAttachment, } from '../../utils/attachments.js' -import { getMemoryPath } from '../../utils/config.js' +import { getGlobalConfig, getMemoryPath } from '../../utils/config.js' import { COMPACT_MAX_OUTPUT_TOKENS } from '../../utils/context.js' import { analyzeContext, @@ -83,6 +83,7 @@ import { } from '../../utils/sessionStorage.js' import { sleep } from '../../utils/sleep.js' import { jsonStringify } from '../../utils/slowOperations.js' +import { parseUserSpecifiedModel } from '../../utils/model/model.js' /* eslint-enable @typescript-eslint/no-require-imports */ import { asSystemPrompt } from '../../utils/systemPromptType.js' import { getTaskOutputPath } from '../../utils/task/diskOutput.js' @@ -1154,10 +1155,15 @@ async function streamCompactSummary({ // main conversation's cached prefix (system prompt, tools, context messages). // Falls back to regular streaming path on failure. // 3P default: true — see comment at the other tengu_compact_cache_prefix read above. - const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_compact_cache_prefix', - true, - ) + // Disabled when compactModel overrides mainLoopModel: different model = different + // cache key, so the forked-agent cache-sharing path would be a guaranteed miss. + const rawCompactModel = getGlobalConfig().compactModel + const compactModel = rawCompactModel + ? parseUserSpecifiedModel(rawCompactModel) + : context.options.mainLoopModel + const promptCacheSharingEnabled = + compactModel === context.options.mainLoopModel && + getFeatureValue_CACHED_MAY_BE_STALE('tengu_compact_cache_prefix', true) // Send keep-alive signals during compaction to prevent remote session // WebSocket idle timeouts from dropping bridge connections. Compaction // API calls can take 5-10+ seconds, during which no other messages @@ -1262,10 +1268,10 @@ async function streamCompactSummary({ let response: AssistantMessage | undefined context.setResponseLength?.(() => 0) - // Check if tool search is enabled using the main loop's tools list. + // Check if tool search is enabled using the model that will actually compact. // context.options.tools includes MCP tools merged via useMergedTools. const useToolSearch = await isToolSearchEnabled( - context.options.mainLoopModel, + compactModel, context.options.tools, async () => appState.toolPermissionContext, context.options.agentDefinitions.activeAgents, @@ -1312,13 +1318,13 @@ async function streamCompactSummary({ const appState = context.getAppState() return appState.toolPermissionContext }, - model: context.options.mainLoopModel, + model: compactModel, toolChoice: undefined, isNonInteractiveSession: context.options.isNonInteractiveSession, hasAppendSystemPrompt: !!context.options.appendSystemPrompt, maxOutputTokensOverride: Math.min( COMPACT_MAX_OUTPUT_TOKENS, - getMaxOutputTokensForModel(context.options.mainLoopModel), + getMaxOutputTokensForModel(compactModel), ), querySource: 'compact', agents: context.options.agentDefinitions.activeAgents, diff --git a/src/tools/ConfigTool/ConfigTool.test.ts b/src/tools/ConfigTool/ConfigTool.test.ts new file mode 100644 index 0000000000..eb12311c07 --- /dev/null +++ b/src/tools/ConfigTool/ConfigTool.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test' +import { ConfigTool } from './ConfigTool.js' + +// --------------------------------------------------------------------------- +// mapToolResultToToolResultBlockParam — rendering of set/clear results +// --------------------------------------------------------------------------- + +describe('ConfigTool.mapToolResultToToolResultBlockParam', () => { + const toolUseID = 'test-id' + + test('renders set result using newValue', () => { + const result = ConfigTool.mapToolResultToToolResultBlockParam( + { + success: true, + operation: 'set', + setting: 'compactModel', + newValue: 'claude-sonnet-4-6', + }, + toolUseID, + ) + expect(result.content).toBe('Set compactModel to "claude-sonnet-4-6"') + }) + + test('clearable "default" result renders Set…to null, not undefined', () => { + // Regression: clearable branch previously returned value:null without newValue, + // causing mapToolResultToToolResultBlockParam to render "Set compactModel to undefined". + const result = ConfigTool.mapToolResultToToolResultBlockParam( + { + success: true, + operation: 'set', + setting: 'compactModel', + newValue: null, + }, + toolUseID, + ) + expect(result.content).toBe('Set compactModel to null') + expect(result.content).not.toContain('undefined') + }) + + test('renders get result using value', () => { + const result = ConfigTool.mapToolResultToToolResultBlockParam( + { + success: true, + operation: 'get', + setting: 'compactModel', + value: 'claude-opus-4-8', + }, + toolUseID, + ) + expect(result.content).toBe('compactModel = "claude-opus-4-8"') + }) + + test('renders error result', () => { + const result = ConfigTool.mapToolResultToToolResultBlockParam( + { success: false, error: 'Unknown setting: "foo"' }, + toolUseID, + ) + expect(result.content).toBe('Error: Unknown setting: "foo"') + expect(result.is_error).toBe(true) + }) +}) diff --git a/src/tools/ConfigTool/ConfigTool.ts b/src/tools/ConfigTool/ConfigTool.ts index ad8f0f990b..b4609fba51 100644 --- a/src/tools/ConfigTool/ConfigTool.ts +++ b/src/tools/ConfigTool/ConfigTool.ts @@ -179,6 +179,39 @@ export const ConfigTool = buildTool({ } } + // Handle "default" for clearable settings — delete the key to restore the built-in default. + if ( + config.clearable && + typeof value === 'string' && + value.toLowerCase().trim() === 'default' + ) { + const key = path[0] + if (!key) { + return { + data: { + success: false, + operation: 'set', + setting, + error: 'Invalid setting path', + }, + } + } + saveGlobalConfig(prev => { + if (!(key in prev)) return prev + const next = { ...prev } + delete (next as Record)[key] + return next + }) + return { + data: { + success: true, + operation: 'set', + setting, + newValue: null, + }, + } + } + let finalValue: unknown = value // Coerce and validate boolean values diff --git a/src/tools/ConfigTool/supportedSettings.ts b/src/tools/ConfigTool/supportedSettings.ts index 0ce91721fe..eaaa525312 100644 --- a/src/tools/ConfigTool/supportedSettings.ts +++ b/src/tools/ConfigTool/supportedSettings.ts @@ -27,6 +27,8 @@ type SettingConfig = { validateOnWrite?: (v: unknown) => Promise<{ valid: boolean; error?: string }> /** Format value when reading/getting for display */ formatOnRead?: (v: unknown) => unknown + /** Whether setting value "default" should delete the key and restore the default */ + clearable?: boolean } export const SUPPORTED_SETTINGS: Record = { @@ -59,6 +61,13 @@ export const SUPPORTED_SETTINGS: Record = { type: 'boolean', description: 'Auto-compact when context is full', }, + compactModel: { + source: 'global', + type: 'string', + description: 'Model used for compaction (defaults to main model); set "default" to clear', + validateOnWrite: async (v: unknown) => validateModel(v as string), + clearable: true, + }, autoMemoryEnabled: { source: 'settings', type: 'boolean', diff --git a/src/utils/config.ts b/src/utils/config.ts index 7818abfc1c..a53ccfa9b7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -254,6 +254,7 @@ export type GlobalConfig = { bypassPermissionsModeAccepted?: boolean hasUsedBackslashReturn?: boolean autoCompactEnabled: boolean // Controls whether auto-compact is enabled + compactModel?: string // Override model used for compaction (defaults to mainLoopModel) toolHistoryCompressionEnabled: boolean // Compress old tool_result content for small-context providers showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s") // Controls whether to show per-query cache hit/miss stats at the end of each turn. @@ -704,6 +705,7 @@ export const GLOBAL_CONFIG_KEYS = [ 'editorMode', 'hasUsedBackslashReturn', 'autoCompactEnabled', + 'compactModel', 'toolHistoryCompressionEnabled', 'showTurnDuration', 'showCacheStats',