Skip to content
Closed
24 changes: 15 additions & 9 deletions src/services/compact/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions src/tools/ConfigTool/ConfigTool.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 33 additions & 0 deletions src/tools/ConfigTool/ConfigTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[key]
return next
})
return {
data: {
success: true,
operation: 'set',
setting,
newValue: null,
},
}
}

let finalValue: unknown = value

// Coerce and validate boolean values
Expand Down
9 changes: 9 additions & 0 deletions src/tools/ConfigTool/supportedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SettingConfig> = {
Expand Down Expand Up @@ -59,6 +61,13 @@ export const SUPPORTED_SETTINGS: Record<string, SettingConfig> = {
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',
Expand Down
2 changes: 2 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -704,6 +705,7 @@ export const GLOBAL_CONFIG_KEYS = [
'editorMode',
'hasUsedBackslashReturn',
'autoCompactEnabled',
'compactModel',
'toolHistoryCompressionEnabled',
'showTurnDuration',
'showCacheStats',
Expand Down