diff --git a/docs/compaction.md b/docs/compaction.md index 2112875eb8d..4526fc87eba 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -238,6 +238,25 @@ Prompt selection: - short UI summary: `compaction-short-summary.md` - handoff document: `handoff-document.md` (used by `generateHandoff(...)`, not serialized compaction) +LLM summary model selection: + +1. `currentModel.compactionModel`, if the active model metadata defines one. +2. `modelRoles.compaction`, if configured. +3. A provider-local fast compaction model such as `gpt-5.3-codex-spark`, when `compaction.preferFastModel !== false`, the prepared compaction input fits the fast model's context and modalities, and rerouting cannot regress prompt-cache economics (see below). +4. The active session model. +5. Built-in fallback roles (`default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `advisor`). +6. The remaining available model with the largest context window. + +Candidates without usable credentials are skipped. The inferred fast compaction candidate is skipped before any request when the prepared input is too large for its usable context window, so the chain falls through to the active model or later fallback. `/compact remote` keeps the same order but filters out provider-native non-remote-capable candidates when no `compaction.remoteEndpoint` is configured. Snapcompact does not use this chain because it archives history locally and requires the active model to support image input. + +The inferred candidate is additionally gated on prompt-cache economics. Provider-native remote compaction (V1 `/responses/compact` and V2 streaming) replays the session's exact native history — the prefix the provider has already cached for the live conversation — so on the session model most of that input bills at the roughly 10x cheaper `cacheRead` rate. Prefix caches do not transfer across models, so rerouting the replay to a fast model whose list price matches the session model's would silently turn a warm cached read into a full-price cold prefill. The inferred fast candidate is therefore only used when at least one of these holds: + +- the compaction wire is `openai-codex-responses` (the flat-rate ChatGPT subscription backend, where tokens are not metered), +- the session model would not use provider-native replay anyway (remote compaction disabled or unsupported; the local summary prompt never shared a prefix with the session cache), or +- the fast model's catalog input price does not exceed the session model's `cacheRead` price (no billing regression; also covers zero-cost catalogs). + +Explicitly configured targets (`compactionModel`, `modelRoles.compaction`) bypass this gate — a user who routes compaction to a specific model accepts the trade-off. + Remote summarization modes: - If `compaction.remoteEndpoint` is set and remote compaction is enabled, local summary generation POSTs: @@ -411,6 +430,7 @@ From `settings-schema.ts`: - `compaction.keepRecentTokens` = `20000` - `compaction.autoContinue` = `true` - `compaction.midTurnEnabled` = `true` +- `compaction.preferFastModel` = `true` - `compaction.remoteEnabled` = `true` - `compaction.remoteEndpoint` = `undefined` - `compaction.thresholdPercent` = `-1` and `compaction.thresholdTokens` = `-1`; when no positive override is set, the threshold is `contextWindow - max(15% of contextWindow, reserveTokens)` diff --git a/docs/models.md b/docs/models.md index d1144a222df..f14d9c0a3a6 100644 --- a/docs/models.md +++ b/docs/models.md @@ -444,7 +444,7 @@ Resolution precedence for exact selectors: Supported model roles: -- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `advisor` +- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `compaction`, `advisor` The `tiny` role overrides the online model used for lightweight background tasks (session titles, memory, `auto`-thinking difficulty classification, unexpected-stop detection); when unset, these fall back to `pi/smol`. Pick one in `/models`. diff --git a/docs/settings.md b/docs/settings.md index 7961c7a1b99..94046bb1cce 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -308,7 +308,7 @@ enabledModels: | Key | Type | Default | Notes | |---|---|---|---| -| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `advisor`. The `tiny` role overrides the online model for lightweight background tasks (titles, memory, auto-thinking, unexpected-stop), else `pi/smol`. Per-role env/flags exist only for `--model`/`--smol`/`--slow`/`--plan`; configure the advisor with `modelRoles.advisor`. | +| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `compaction`, `advisor`. The `tiny` role overrides the online model for lightweight background tasks (titles, memory, auto-thinking, unexpected-stop), else `pi/smol`; the `compaction` role overrides the model used for LLM summary compaction when the active model does not define `compactionModel`. Per-role env/flags exist only for `--model`/`--smol`/`--slow`/`--plan`; configure the advisor with `modelRoles.advisor`. | | `modelTags` | record | `{}` | Custom role/tag metadata; can introduce additional roles. | | `modelProviderOrder` | array | `[]` | Preferred provider order when a model id is ambiguous. | | `cycleOrder` | array | `["smol","default","slow"]` | Roles cycled by the model switcher. | @@ -508,6 +508,7 @@ compaction: enabled: true strategy: snapcompact # context-full, handoff, shake, snapcompact, off midTurnEnabled: true # check thresholds between tool-loop provider requests + preferFastModel: true # try provider-local fast summary model when it fits thresholdPercent: -1 # -1 = default reserve-based behavior thresholdTokens: -1 # fixed token limit when > 0 remoteEnabled: true @@ -521,6 +522,7 @@ memory: | `contextPromotion.enabled` | boolean | `true` | Promote relevant earlier context. | | `compaction.enabled` | boolean | `true` | Automatic conversation compaction. | | `compaction.midTurnEnabled` | boolean | `true` | Check thresholds at safe mid-turn tool-loop boundaries before the next provider request. | +| `compaction.preferFastModel` | boolean | `true` | Try a provider-local fast model such as `gpt-5.3-codex-spark` for LLM summary compaction when no explicit compaction model is configured; skip it when the prepared input does not fit or when rerouting would forfeit cached input pricing on a metered provider (see [compaction](compaction.md)). | | `compaction.strategy` | enum | `snapcompact` | `context-full`, `handoff`, `shake`, `snapcompact`, `off`. | | `compaction.thresholdPercent` | number | `-1` | Percent-of-context trigger; `-1` = reserve-based default. | | `compaction.thresholdTokens` | number | `-1` | Fixed token trigger when `> 0`. | diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 3c7e6ecbd2a..c989cd0c813 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -171,6 +171,7 @@ export interface CompactionSettings { */ reserveTokens?: number; keepRecentTokens: number; + preferFastModel?: boolean; autoContinue?: boolean; remoteEnabled?: boolean; remoteEndpoint?: string; @@ -191,6 +192,7 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { thresholdTokens: -1, midTurnEnabled: true, keepRecentTokens: 20000, + preferFastModel: true, autoContinue: true, remoteEnabled: true, remoteStreamingV2Enabled: true, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c2395e3df3..48c1d5de6a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added a `modelRoles.compaction` role for routing LLM summary compaction to a dedicated model when the active model does not define `compactionModel`. +- Added `compaction.preferFastModel` to try a provider-local fast compaction model by default and fall back when the prepared input does not fit. The inferred fast candidate is only used when it cannot regress prompt-cache economics: on the flat-rate `openai-codex-responses` wire, when the session model would not use provider-native history replay anyway, or when the fast model's input price does not exceed the session model's cached-input price. Explicit `compactionModel`/`modelRoles.compaction` targets bypass the gate. + ## [16.3.6] - 2026-07-04 ### Changed diff --git a/packages/coding-agent/src/config/model-roles.ts b/packages/coding-agent/src/config/model-roles.ts index 3ab5e948935..46ce3dc9b90 100644 --- a/packages/coding-agent/src/config/model-roles.ts +++ b/packages/coding-agent/src/config/model-roles.ts @@ -15,6 +15,7 @@ export type ModelRole = | "commit" | "tiny" | "task" + | "compaction" | "advisor"; export interface ModelRoleInfo { @@ -35,6 +36,7 @@ export const MODEL_ROLES: Record = { commit: { tag: "COMMIT", name: "Commit", color: "dim" }, tiny: { tag: "TINY", name: "Tiny", color: "dim" }, task: { tag: "TASK", name: "Subtask", color: "muted" }, + compaction: { tag: "COMPACT", name: "Compaction", color: "muted" }, advisor: { tag: "ADVISOR", name: "Advisor", color: "accent" }, }; @@ -48,6 +50,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [ "commit", "tiny", "task", + "compaction", "advisor", ]; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 2316034da80..d4a0d30a6a4 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1894,6 +1894,18 @@ export const SETTINGS_SCHEMA = { }, }, + "compaction.preferFastModel": { + type: "boolean", + default: true, + ui: { + tab: "context", + group: "Compaction", + label: "Prefer Fast Compaction Model", + description: + "Use a provider-local fast model for LLM summary compaction when no explicit compaction model is configured; falls back when the prepared compaction input does not fit or when rerouting would forfeit cached input pricing on a metered provider", + }, + }, + "compaction.strategy": { type: "enum", values: ["context-full", "handoff", "shake", "snapcompact", "off"] as const, @@ -5056,6 +5068,7 @@ export interface CompactionSettings { reserveTokens: number | undefined; keepRecentTokens: number; midTurnEnabled: boolean; + preferFastModel: boolean; handoffSaveToDisk: boolean; autoContinue: boolean; remoteEnabled: boolean; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 38edd6812e3..8bc791f60b0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -69,6 +69,7 @@ import { type ShakeRegion, type SummaryOptions, shouldCompact, + shouldUseCompactionV2Streaming, shouldUseOpenAiRemoteCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { @@ -119,6 +120,7 @@ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop"; import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard"; import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; +import { parseKnownModel } from "@oh-my-pi/pi-catalog/identity/classify"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; @@ -620,6 +622,8 @@ const RETRY_BACKOFF_JITTER_RATIO = 0.25; * most-recent kept turn already exceeds the threshold (the snapcompact thrash). */ const COMPACTION_RECOVERY_BAND = 0.8; +const DEFAULT_FAST_COMPACTION_MODEL_ID = "gpt-5.3-codex-spark"; +const DEFAULT_FAST_COMPACTION_INPUT_SAFETY_TOKENS = 8_192; function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number { const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS); @@ -9480,12 +9484,13 @@ export class AgentSession { compactionCandidates = this.#getCompactionModelCandidates(availableModels); } const pathEntries = this.sessionManager.getBranch(); - const preparation = prepareCompaction( + const preparedCompaction = await this.#prepareCompactionWithCandidateFilter( pathEntries, effectiveSettings, - await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId), + compactionCandidates, + availableModels, ); - if (!preparation) { + if (!preparedCompaction) { // Check why we can't compact const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { @@ -9493,6 +9498,8 @@ export class AgentSession { } throw new Error("Nothing to compact (session too small)"); } + const { preparation } = preparedCompaction; + compactionCandidates = preparedCompaction.candidates; let hookCompaction: CompactionResult | undefined; let fromExtension = false; @@ -11633,6 +11640,222 @@ export class AgentSession { return this.#resolveConfiguredModelTarget(currentModel.compactionModel, currentModel, availableModels); } + #resolveCompactionRoleTarget(currentModel: Model | undefined, availableModels: Model[]): Model | undefined { + return this.#resolveRoleModelFull("compaction", availableModels, currentModel).model; + } + + #resolveDefaultFastCompactionTarget(currentModel: Model | undefined, availableModels: Model[]): Model | undefined { + if (!currentModel || this.settings.get("compaction.preferFastModel") === false) return undefined; + const currentParsed = parseKnownModel(currentModel.id); + if (currentParsed.family !== "openai" || currentParsed.variant === "codex-spark") return undefined; + + const candidates = availableModels.filter(model => { + if (model.provider !== currentModel.provider || model.api !== currentModel.api) return false; + if (this.#getModelKey(model) === this.#getModelKey(currentModel)) return false; + if (!model.input.includes("text")) return false; + const parsed = parseKnownModel(model.id); + if (parsed.family !== "openai" || parsed.variant !== "codex-spark") return false; + return this.#inferredFastCompactionIsCacheSafe(currentModel, model); + }); + candidates.sort((a, b) => { + const rankDiff = this.#defaultFastCompactionRank(a) - this.#defaultFastCompactionRank(b); + if (rankDiff !== 0) return rankDiff; + const priorityDiff = (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER); + if (priorityDiff !== 0) return priorityDiff; + const contextDiff = (b.contextWindow ?? 0) - (a.contextWindow ?? 0); + if (contextDiff !== 0) return contextDiff; + return this.#getModelKey(a).localeCompare(this.#getModelKey(b)); + }); + return candidates[0]; + } + + /** + * Whether inferring `candidate` as the default fast compaction model cannot + * regress prompt-cache economics for `currentModel`'s sessions. Provider + * prefix caches don't transfer across models, and provider-native remote + * compaction replays the session's exact native history — input the session + * model would serve mostly at the ~10x cheaper cacheRead rate. Rerouting + * that replay is only safe when tokens aren't metered (the ChatGPT + * subscription codex wire), when no cache-friendly replay would happen + * anyway (the local summary prompt never shared a prefix with the session + * cache), or when the candidate's list price doesn't exceed the session + * model's cached-input price. Explicit compactionModel/modelRoles.compaction + * targets never pass through this gate. + */ + #inferredFastCompactionIsCacheSafe(currentModel: Model, candidate: Model): boolean { + const compactionApi = currentModel.remoteCompaction?.api ?? currentModel.api; + if (compactionApi === "openai-codex-responses") return true; + if (!this.#sessionCompactionUsesNativeReplay(currentModel)) return true; + if (candidate.cost.input <= currentModel.cost.cacheRead) return true; + logger.debug("Skipping default fast compaction model to keep the session model's cached input pricing", { + model: this.#getModelKey(candidate), + candidateInputCost: candidate.cost.input, + currentCacheReadCost: currentModel.cost.cacheRead, + }); + return false; + } + + /** Mirrors the V2-then-V1 remote gating in `compact()` for the session model. */ + #sessionCompactionUsesNativeReplay(currentModel: Model): boolean { + if (this.settings.get("compaction.remoteEnabled") === false) return false; + if ( + this.settings.get("compaction.remoteStreamingV2Enabled") !== false && + shouldUseCompactionV2Streaming(currentModel) + ) { + return true; + } + return shouldUseOpenAiRemoteCompaction(currentModel); + } + + #defaultFastCompactionRank(model: Model): number { + if (model.id === DEFAULT_FAST_COMPACTION_MODEL_ID || model.id.endsWith(`/${DEFAULT_FAST_COMPACTION_MODEL_ID}`)) { + return 0; + } + return model.id.includes("preview") ? 2 : 1; + } + + #isExplicitCompactionTarget(candidate: Model, currentModel: Model | undefined, availableModels: Model[]): boolean { + if (!currentModel) return false; + const candidateKey = this.#getModelKey(candidate); + const configured = this.#resolveCompactionConfiguredTarget(currentModel, availableModels); + if (configured && this.#getModelKey(configured) === candidateKey) return true; + const roleTarget = this.#resolveCompactionRoleTarget(currentModel, availableModels); + return !!roleTarget && this.#getModelKey(roleTarget) === candidateKey; + } + + #isInferredDefaultFastCompactionCandidate( + candidate: Model, + currentModel: Model | undefined, + availableModels: Model[], + ): boolean { + const inferred = this.#resolveDefaultFastCompactionTarget(currentModel, availableModels); + if (!inferred || this.#getModelKey(inferred) !== this.#getModelKey(candidate)) return false; + return !this.#isExplicitCompactionTarget(candidate, currentModel, availableModels); + } + + #filterCompactionCandidatesForPreparation( + candidates: readonly Model[], + preparation: CompactionPreparation, + availableModels: Model[], + ): Model[] { + return candidates.filter(candidate => { + if (!this.#isInferredDefaultFastCompactionCandidate(candidate, this.model, availableModels)) return true; + return this.#compactionPreparationFitsCandidate(preparation, candidate); + }); + } + + async #prepareCompactionWithCandidateFilter( + pathEntries: SessionEntry[], + settings: CompactionSettings, + candidates: readonly Model[], + availableModels: Model[], + ): Promise<{ preparation: CompactionPreparation; candidates: Model[] } | undefined> { + const initialRunnableCandidates = await this.#runnableCompactionCandidates(candidates, this.sessionId); + const initialPreparation = prepareCompaction(pathEntries, settings, initialRunnableCandidates); + if (!initialPreparation) return undefined; + + const filteredCandidates = this.#filterCompactionCandidatesForPreparation( + candidates, + initialPreparation, + availableModels, + ); + const filteredRunnableCandidates = await this.#runnableCompactionCandidates(filteredCandidates, this.sessionId); + if (this.#sameCompactionCandidateKeys(initialRunnableCandidates, filteredRunnableCandidates)) { + return { preparation: initialPreparation, candidates: filteredCandidates }; + } + + const preparation = prepareCompaction(pathEntries, settings, filteredRunnableCandidates); + if (!preparation) return undefined; + return { + preparation, + candidates: this.#filterCompactionCandidatesForPreparation(filteredCandidates, preparation, availableModels), + }; + } + + #sameCompactionCandidateKeys(left: readonly Model[], right: readonly Model[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (this.#getModelKey(left[i]) !== this.#getModelKey(right[i])) return false; + } + return true; + } + + #compactionPreparationFitsCandidate(preparation: CompactionPreparation, candidate: Model): boolean { + if (this.#compactionPreparationNeedsImageInput(preparation) && !candidate.input.includes("image")) { + logger.debug("Skipping default fast compaction model because the prepared input contains images", { + model: this.#getModelKey(candidate), + }); + return false; + } + + const contextWindow = candidate.contextWindow ?? 0; + if (contextWindow <= 0) return true; + const usableInputBudget = Math.max( + 1, + contextWindow - effectiveReserveTokens(contextWindow, preparation.settings), + ); + const estimatedInputTokens = this.#estimateCompactionRequestTokens(preparation, candidate); + if (estimatedInputTokens + DEFAULT_FAST_COMPACTION_INPUT_SAFETY_TOKENS <= usableInputBudget) return true; + + logger.debug("Skipping default fast compaction model because the prepared input exceeds its usable context", { + model: this.#getModelKey(candidate), + estimatedInputTokens, + usableInputBudget, + contextWindow, + }); + return false; + } + + #estimateCompactionRequestTokens(preparation: CompactionPreparation, candidate: Model): number { + const basePromptTokens = countTokens(this.#baseSystemPrompt); + if (preparation.settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(candidate)) { + return ( + basePromptTokens + + this.#estimateMessagesTokens([ + ...preparation.messagesToSummarize, + ...preparation.turnPrefixMessages, + ...preparation.recentMessages, + ]) + ); + } + + const priorSummaryTokens = preparation.previousSummary ? countTokens(preparation.previousSummary) : 0; + const historySummaryTokens = + this.#estimateMessagesTokens(preparation.messagesToSummarize) + priorSummaryTokens + basePromptTokens; + const turnPrefixTokens = this.#estimateMessagesTokens(preparation.turnPrefixMessages) + basePromptTokens; + const shortSummaryTokens = this.#estimateMessagesTokens(preparation.recentMessages) + basePromptTokens; + return Math.max(historySummaryTokens, turnPrefixTokens, shortSummaryTokens); + } + + #estimateMessagesTokens(messages: readonly AgentMessage[]): number { + let total = 0; + for (const message of messages) { + total += estimateTokens(message); + } + return total; + } + + #compactionPreparationNeedsImageInput(preparation: CompactionPreparation): boolean { + return [ + ...preparation.messagesToSummarize, + ...preparation.turnPrefixMessages, + ...preparation.recentMessages, + ].some(message => this.#messageHasImageContent(message)); + } + + #messageHasImageContent(message: AgentMessage): boolean { + const content = (message as { content?: unknown }).content; + if (Array.isArray(content) && content.some(block => this.#isImageContentBlock(block))) return true; + const blocks = (message as { blocks?: unknown }).blocks; + if (Array.isArray(blocks) && blocks.some(block => this.#isImageContentBlock(block))) return true; + const images = (message as { images?: unknown }).images; + return Array.isArray(images) && images.length > 0; + } + + #isImageContentBlock(block: unknown): boolean { + return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "image"; + } + #resolveRoleModelFull( role: string, availableModels: Model[], @@ -11692,8 +11915,11 @@ export class AgentSession { if (preferredModel) { addCandidate(this.#resolveCompactionConfiguredTarget(preferredModel, availableModels)); } + addCandidate(this.#resolveCompactionRoleTarget(preferredModel ?? undefined, availableModels)); + addCandidate(this.#resolveDefaultFastCompactionTarget(preferredModel ?? undefined, availableModels)); addCandidate(preferredModel ?? undefined); for (const role of MODEL_ROLE_IDS) { + if (role === "compaction") continue; addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model); } @@ -11740,8 +11966,12 @@ export class AgentSession { options?: SummaryOptions, precomputedCandidates?: Model[], ): Promise { - const candidates = - precomputedCandidates ?? this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable()); + const availableModels = this.#modelRegistry.getAvailable(); + const candidates = this.#filterCompactionCandidatesForPreparation( + precomputedCandidates ?? this.#getCompactionModelCandidates(availableModels), + preparation, + availableModels, + ); const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId); for (const candidate of candidates) { @@ -12253,12 +12483,13 @@ export class AgentSession { const pathEntries = this.sessionManager.getBranch(); - const autoCompactionCandidates = await this.#runnableCompactionCandidates( + const preparedCompaction = await this.#prepareCompactionWithCandidateFilter( + pathEntries, + compactionSettings, this.#getCompactionModelCandidates(availableModels), - this.sessionId, + availableModels, ); - const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates); - if (!preparation) { + if (!preparedCompaction) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, @@ -12287,6 +12518,7 @@ export class AgentSession { if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; } + const { preparation } = preparedCompaction; let hookCompaction: CompactionResult | undefined; let fromExtension = false; @@ -12424,7 +12656,7 @@ export class AgentSession { details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { - const candidates = this.#getCompactionModelCandidates(availableModels); + const candidates = preparedCompaction.candidates; const retrySettings = this.settings.getGroup("retry"); const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId); let compactResult: CompactionResult | undefined; diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index 7a30df5aac0..3123206d474 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import type { Model } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -63,18 +64,20 @@ describe("AgentSession mid-run threshold compaction", () => { async function createHarness( settingsOverride: Record = {}, - options: { extensionRunner?: ExtensionRunner } = {}, + options: { extensionRunner?: ExtensionRunner; model?: Model; authenticatedModels?: readonly Model[] } = {}, ): Promise<{ session: AgentSession; observedContexts: string[][]; sessionManager: SessionManager; }> { const observedContexts: string[][] = []; - const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + const model = options.model ?? getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); - authStorage.setRuntimeApiKey("anthropic", "test-key"); + for (const authenticatedModel of options.authenticatedModels ?? [model]) { + authStorage.setRuntimeApiKey(authenticatedModel.provider, "test-key"); + } const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); const settings = Settings.isolated({ "compaction.enabled": true, @@ -114,9 +117,9 @@ describe("AgentSession mid-run threshold compaction", () => { content: [ { type: "toolCall" as const, id: `tc-${index}`, name: "bash", arguments: { cmd: "pwd" } }, ], - api: "anthropic-messages" as const, - provider: "anthropic" as const, - model: "claude-sonnet-4-5", + api: model.api, + provider: model.provider, + model: model.id, usage: highUsage(50_000), stopReason: "toolUse" as const, timestamp: Date.now(), @@ -124,9 +127,9 @@ describe("AgentSession mid-run threshold compaction", () => { : { role: "assistant" as const, content: [{ type: "text" as const, text: "All done." }], - api: "anthropic-messages" as const, - provider: "anthropic" as const, - model: "claude-sonnet-4-5", + api: model.api, + provider: model.provider, + model: model.id, usage: highUsage(200), stopReason: "stop" as const, timestamp: Date.now(), @@ -165,6 +168,10 @@ describe("AgentSession mid-run threshold compaction", () => { })); } + function modelKey(model: { provider: string; id: string }): string { + return `${model.provider}/${model.id}`; + } + it("compacts in place between tool-call turns outside goal mode", async () => { const { session, observedContexts } = await createHarness(); const compactSpy = mockCompaction("MID-RUN-COMPACTED"); @@ -188,6 +195,44 @@ describe("AgentSession mid-run threshold compaction", () => { expect(observedContexts[1].join("\n")).toContain("ACTIVE-GOAL-MID-RUN-COMPACTED"); }); + it("uses the provider-local fast compaction model for auto mid-run compaction when it fits", async () => { + const currentModel = getBundledModel("openai-codex", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai-codex", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + const { session } = await createHarness( + {}, + { model: currentModel, authenticatedModels: [currentModel, fastModel] }, + ); + const compactSpy = mockCompaction("CODEX-FAST-MID-RUN-COMPACTED"); + + await session.prompt("work on the release"); + + expect(compactSpy).toHaveBeenCalledTimes(1); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(fastModel)); + }); + + it("keeps the metered session model for auto mid-run compaction to preserve cached input pricing", async () => { + const currentModel = getBundledModel("openai", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + const { session } = await createHarness( + {}, + { model: currentModel, authenticatedModels: [currentModel, fastModel] }, + ); + const compactSpy = mockCompaction("OPENAI-METERED-MID-RUN-COMPACTED"); + + await session.prompt("work on the release"); + + expect(compactSpy).toHaveBeenCalledTimes(1); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + }); + it("falls back to in-place compaction for mid-run handoff strategy", async () => { const { session, observedContexts } = await createHarness({ "compaction.strategy": "handoff" }); const handoffSpy = vi.spyOn(session, "handoff").mockImplementation(async () => { diff --git a/packages/coding-agent/test/compaction-prefer-current-model.test.ts b/packages/coding-agent/test/compaction-prefer-current-model.test.ts index 0bceded5f60..9f8b15dc570 100644 --- a/packages/coding-agent/test/compaction-prefer-current-model.test.ts +++ b/packages/coding-agent/test/compaction-prefer-current-model.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -38,16 +39,34 @@ describe("compaction prefers the current session model over modelRoles.default", tempDir.removeSync(); }); - it("uses the active Anthropic chat model when modelRoles.default points at an OpenAI model", async () => { - const currentModel = getBundledModel("anthropic", "claude-sonnet-4-5"); - const defaultRoleModel = getBundledModel("openai", "gpt-5"); - if (!currentModel || !defaultRoleModel) { - throw new Error("Expected bundled test models to exist"); - } + function modelKey(model: { provider: string; id: string }): string { + return `${model.provider}/${model.id}`; + } - const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); - settings.setModelRole("default", `${defaultRoleModel.provider}/${defaultRoleModel.id}`); + function activeModelKey(): string { + if (!session.model) throw new Error("Expected session model to remain selected"); + return modelKey(session.model); + } + function appendCompactableHistory(targetSession: AgentSession): void { + for (const [userText, assistantText] of [ + ["first question", "first answer"], + ["second question", "second answer"], + ] as const) { + const user = userMsg(userText); + const assistant = assistantMsg(assistantText); + targetSession.agent.appendMessage(user); + targetSession.sessionManager.appendMessage(user); + targetSession.agent.appendMessage(assistant); + targetSession.sessionManager.appendMessage(assistant); + } + } + + async function createCompactionSession( + currentModel: Model, + settings: Settings, + authenticatedModels: readonly Model[], + ): Promise { const agent = new Agent({ initialState: { model: currentModel, @@ -58,10 +77,9 @@ describe("compaction prefers the current session model over modelRoles.default", }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - // Both providers have credentials so an "auth failure" wouldn't be the - // reason a candidate is skipped — order alone must drive the choice. - authStorage.setRuntimeApiKey(currentModel.provider, "anthropic-token"); - authStorage.setRuntimeApiKey(defaultRoleModel.provider, "openai-token"); + for (const model of authenticatedModels) { + authStorage.setRuntimeApiKey(model.provider, `${model.provider}-token`); + } modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); session = new AgentSession({ @@ -71,96 +89,265 @@ describe("compaction prefers the current session model over modelRoles.default", modelRegistry, }); session.subscribe(() => {}); + appendCompactableHistory(session); + } - for (const [userText, assistantText] of [ - ["first question", "first answer"], - ["second question", "second answer"], - ] as const) { - const user = userMsg(userText); - const assistant = assistantMsg(assistantText); - session.agent.appendMessage(user); - session.sessionManager.appendMessage(user); - session.agent.appendMessage(assistant); - session.sessionManager.appendMessage(assistant); - } - - const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => ({ + function mockCompaction() { + return vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => ({ summary: "ok", shortSummary: "ok short", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: 1, details: { provider: model.provider }, })); + } + + it("uses the active Anthropic chat model when modelRoles.default points at an OpenAI model", async () => { + const currentModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const defaultRoleModel = getBundledModel("openai", "gpt-5"); + if (!currentModel || !defaultRoleModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("default", modelKey(defaultRoleModel)); + await createCompactionSession(currentModel, settings, [currentModel, defaultRoleModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + }); + + it("uses modelRoles.compaction before the default fast model and leaves the active model unchanged", async () => { + const currentModel = getBundledModel("openai", "gpt-5.3-codex"); + const compactionRoleModel = getBundledModel("openai", "gpt-5"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!currentModel || !compactionRoleModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("compaction", modelKey(compactionRoleModel)); + await createCompactionSession(currentModel, settings, [currentModel, fastModel, compactionRoleModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(compactionRoleModel)); + expect(activeModelKey()).toBe(modelKey(currentModel)); + }); + + it("uses the provider-local fast compaction model before the active Codex subscription model by default", async () => { + const currentModel = getBundledModel("openai-codex", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai-codex", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); await session.compact(); expect(compactSpy).toHaveBeenCalled(); const [, firstCandidate] = compactSpy.mock.calls[0]!; - expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(`${currentModel.provider}/${currentModel.id}`); + expect(modelKey(firstCandidate)).toBe(modelKey(fastModel)); + expect(activeModelKey()).toBe(modelKey(currentModel)); }); - it("uses compactionModel only for the summary call and leaves the active model unchanged", async () => { - const baseCurrentModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + it("keeps the active metered OpenAI model when the inferred fast model would forfeit cached input pricing", async () => { + // API-key OpenAI compaction replays the session's native history, which + // bills mostly at the 10x-cheaper cacheRead rate on the session model. + // Spark shares codex's list price, so rerouting the replay to it would be + // a pure cost regression — the inferred default must not do that silently. + const currentModel = getBundledModel("openai", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + expect(activeModelKey()).toBe(modelKey(currentModel)); + }); + + it("uses the inferred fast model on a metered provider when remote compaction is disabled", async () => { + // With provider-native replay off, the summary request serializes the + // conversation under a dedicated system prompt and never shared a prefix + // with the session cache — rerouting it costs nothing extra. + const currentModel = getBundledModel("openai", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ + "compaction.keepRecentTokens": 1, + "compaction.remoteEnabled": false, + "compaction.strategy": "context-full", + }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(fastModel)); + }); + + it("uses the inferred fast model on a zero-cost catalog", async () => { + const currentModel = getBundledModel("cursor", "gpt-5.3-codex"); + const fastModel = getBundledModel("cursor", "gpt-5.3-codex-spark-preview"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(fastModel)); + }); + + it("honors an explicit modelRoles.compaction fast model on a metered provider", async () => { + // The cache-economics gate only guards the *inferred* default; a user who + // explicitly routes compaction to spark accepts the cache trade-off. + const currentModel = getBundledModel("openai", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("compaction", modelKey(fastModel)); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(fastModel)); + }); + + it("uses the active Codex model when the fast compaction default is disabled", async () => { + const currentModel = getBundledModel("openai-codex", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai-codex", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ + "compaction.keepRecentTokens": 1, + "compaction.preferFastModel": false, + "compaction.strategy": "context-full", + }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + }); + + it("skips the default fast compaction model when the prepared input exceeds its context", async () => { + const currentModel = getBundledModel("openai-codex", "gpt-5.3-codex"); + const fastModel = getBundledModel("openai-codex", "gpt-5.3-codex-spark"); + if (!currentModel || !fastModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + await createCompactionSession(currentModel, settings, [currentModel, fastModel]); + const hugeUser = userMsg(`oversized compact input ${"token ".repeat(140_000)}`); + const hugeAssistant = assistantMsg("oversized response"); + session.agent.appendMessage(hugeUser); + session.sessionManager.appendMessage(hugeUser); + session.agent.appendMessage(hugeAssistant); + session.sessionManager.appendMessage(hugeAssistant); + const recentUser = userMsg("recent question"); + const recentAssistant = assistantMsg("recent answer"); + session.agent.appendMessage(recentUser); + session.sessionManager.appendMessage(recentUser); + session.agent.appendMessage(recentAssistant); + session.sessionManager.appendMessage(recentAssistant); + const compactSpy = mockCompaction(); + + await session.compact(); + + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + }); + + it("uses compactionModel before modelRoles.compaction and leaves the active model unchanged", async () => { + const baseCurrentModel = getBundledModel("openai", "gpt-5.3-codex"); const compactionModel = getBundledModel("openai", "gpt-5"); - if (!baseCurrentModel || !compactionModel) { + const compactionRoleModel = getBundledModel("openai-codex", "gpt-5.4-mini"); + const fastModel = getBundledModel("openai", "gpt-5.3-codex-spark"); + if (!baseCurrentModel || !compactionModel || !compactionRoleModel || !fastModel) { throw new Error("Expected bundled test models to exist"); } const currentModel = buildModel({ ...baseCurrentModel, - compactionModel: `${compactionModel.provider}/${compactionModel.id}`, + compactionModel: modelKey(compactionModel), compat: baseCurrentModel.compatConfig, }); + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("compaction", modelKey(compactionRoleModel)); + await createCompactionSession(currentModel, settings, [ + currentModel, + fastModel, + compactionModel, + compactionRoleModel, + ]); + const compactSpy = mockCompaction(); - const agent = new Agent({ - initialState: { - model: currentModel, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, - }); - - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey(currentModel.provider, "anthropic-token"); - authStorage.setRuntimeApiKey(compactionModel.provider, "openai-token"); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + await session.compact(); - session = new AgentSession({ - agent, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }), - modelRegistry, - }); - session.subscribe(() => {}); + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(compactionModel)); + expect(activeModelKey()).toBe(modelKey(currentModel)); + }); - for (const [userText, assistantText] of [ - ["first question", "first answer"], - ["second question", "second answer"], - ] as const) { - const user = userMsg(userText); - const assistant = assistantMsg(assistantText); - session.agent.appendMessage(user); - session.sessionManager.appendMessage(user); - session.agent.appendMessage(assistant); - session.sessionManager.appendMessage(assistant); + it("skips an unauthenticated modelRoles.compaction candidate and uses the active model", async () => { + const currentModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const compactionRoleModel = getBundledModel("openai", "gpt-5"); + if (!currentModel || !compactionRoleModel) { + throw new Error("Expected bundled test models to exist"); } - const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => ({ - summary: "ok", - shortSummary: "ok short", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: 1, - details: { provider: model.provider }, - })); + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("compaction", modelKey(compactionRoleModel)); + await createCompactionSession(currentModel, settings, [currentModel]); + const compactSpy = mockCompaction(); await session.compact(); - expect(compactSpy).toHaveBeenCalled(); + expect(compactSpy).toHaveBeenCalledTimes(1); const [, firstCandidate] = compactSpy.mock.calls[0]!; - expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe( - `${compactionModel.provider}/${compactionModel.id}`, - ); - expect(`${session.model?.provider}/${session.model?.id}`).toBe(`${currentModel.provider}/${currentModel.id}`); + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); }); it("/compact remote skips a non-remote-capable compactionModel and uses the active remote-capable model", async () => { @@ -178,56 +365,40 @@ describe("compaction prefers the current session model over modelRoles.default", } const currentModel = buildModel({ ...baseCurrentModel, - compactionModel: `${nonRemoteCompactionModel.provider}/${nonRemoteCompactionModel.id}`, + compactionModel: modelKey(nonRemoteCompactionModel), compat: baseCurrentModel.compatConfig, }); - const agent = new Agent({ - initialState: { - model: currentModel, - systemPrompt: ["Test"], - tools: [], - messages: [], - }, - }); + await createCompactionSession( + currentModel, + Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.preferFastModel": false }), + [currentModel, nonRemoteCompactionModel], + ); + const compactSpy = mockCompaction(); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey(currentModel.provider, "openai-token"); - authStorage.setRuntimeApiKey(nonRemoteCompactionModel.provider, "anthropic-token"); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + await session.compact(undefined, { mode: "remote" }); - session = new AgentSession({ - agent, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "compaction.keepRecentTokens": 1 }), - modelRegistry, - }); - session.subscribe(() => {}); + expect(compactSpy).toHaveBeenCalled(); + const [, firstCandidate] = compactSpy.mock.calls[0]!; + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); + }); - for (const [userText, assistantText] of [ - ["first question", "first answer"], - ["second question", "second answer"], - ] as const) { - const user = userMsg(userText); - const assistant = assistantMsg(assistantText); - session.agent.appendMessage(user); - session.sessionManager.appendMessage(user); - session.agent.appendMessage(assistant); - session.sessionManager.appendMessage(assistant); + it("/compact remote skips a non-remote-capable modelRoles.compaction candidate", async () => { + const currentModel = getBundledModel("openai", "gpt-5"); + const nonRemoteCompactionModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!currentModel || !nonRemoteCompactionModel) { + throw new Error("Expected bundled test models to exist"); } - const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => ({ - summary: "ok", - shortSummary: "ok short", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: 1, - details: { provider: model.provider }, - })); + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.preferFastModel": false }); + settings.setModelRole("compaction", modelKey(nonRemoteCompactionModel)); + await createCompactionSession(currentModel, settings, [currentModel, nonRemoteCompactionModel]); + const compactSpy = mockCompaction(); await session.compact(undefined, { mode: "remote" }); expect(compactSpy).toHaveBeenCalled(); const [, firstCandidate] = compactSpy.mock.calls[0]!; - expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(`${currentModel.provider}/${currentModel.id}`); + expect(modelKey(firstCandidate)).toBe(modelKey(currentModel)); }); });