diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 6df61b15c..aa2580f8f 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -980,12 +980,14 @@ export class EventController { } this.ctx.showWarning(event.errorMessage); } else if (!event.skipped) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); this.ctx.showStatus("Auto-shake completed"); } } else if (event.result) { + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); @@ -993,6 +995,7 @@ export class EventController { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { this.ctx.chatContainer.clear(); + this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2a2c35fcd..776b45d1f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1860,6 +1860,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#planModePreviousTools = previousTools; this.planModePlanFilePath = planFilePath; this.planModeEnabled = true; + // Suppress cache-miss marker on the next turn: plan mode changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; await this.session.setActiveToolsByName(uniquePlanTools); this.session.setPlanModeState({ @@ -1977,6 +1980,9 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setStandingResolveHandler?.(null); this.session.setPlanModeState(undefined); this.planModeEnabled = false; + // Suppress cache-miss marker on the next turn: plan exit changes the system + // prompt, which predictably invalidates the cache. + this.lastAssistantUsage = undefined; this.planModePaused = options?.paused ?? false; this.planModePlanFilePath = undefined; this.#planModePreviousTools = undefined; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 6cf8ead56..f9ecd4e4e 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -403,7 +403,10 @@ export class UiHelpers { // updateResult armed. previous.seal(); }; - for (const message of sessionContext.messages) { + const messages = sessionContext.messages; + const count = messages.length; + for (let i = 0; i < count; i++) { + const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls if (message.role === "assistant") { @@ -412,7 +415,8 @@ export class UiHelpers { const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined; if (assistantComponent) { const usage = message.usage; - if (this.ctx.settings.get("display.cacheMissMarker")) { + const explained = sessionContext.cacheMissExplainedAt?.[i] ?? false; + if (this.ctx.settings.get("display.cacheMissMarker") && !explained) { const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage); if (invalidation) assistantComponent.setCacheInvalidation(invalidation); } diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index c113c6bb4..08d4339e7 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -20,6 +20,13 @@ export interface SessionContext { mode: string; /** Mode-specific data from the last mode_change entry */ modeData?: Record; + /** + * Array parallel to messages, indicating which assistant turns should + * have their prompt-cache misses suppressed/explained (because a model, + * compaction, or plan-mode transition directly preceded them). + * Only populated in transcript mode. + */ + cacheMissExplainedAt?: boolean[]; } /** Lists session model strings to try when restoring, in fallback order. */ @@ -191,12 +198,45 @@ export function buildSessionContext( // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AgentMessage[] = []; + const cacheMissExplainedAt: boolean[] = []; + let pendingReset = false; + let currentMode = "none"; + let lastAssistantModel: string | undefined; + + const handleEntryResetTracking = (entry: SessionEntry) => { + if (entry.type === "compaction") { + pendingReset = true; + } else if (entry.type === "model_change") { + pendingReset = true; + } else if (entry.type === "mode_change") { + const isPlanTransition = (entry.mode === "plan") !== (currentMode === "plan"); + if (isPlanTransition) { + pendingReset = true; + } + currentMode = entry.mode; + } + }; + + const pushMessage = (msg: AgentMessage) => { + messages.push(msg); + if (!options?.transcript) return; + if (msg.role === "assistant") { + const currentModel = `${msg.provider}/${msg.model}`; + const modelChanged = lastAssistantModel !== undefined && lastAssistantModel !== currentModel; + lastAssistantModel = currentModel; + cacheMissExplainedAt.push(pendingReset || modelChanged); + pendingReset = false; + } else { + cacheMissExplainedAt.push(false); + } + }; const appendMessage = (entry: SessionEntry) => { + handleEntryResetTracking(entry); if (entry.type === "message") { - messages.push(entry.message); + pushMessage(entry.message); } else if (entry.type === "custom_message") { - messages.push( + pushMessage( createCustomMessage( entry.customType, entry.content, @@ -207,7 +247,7 @@ export function buildSessionContext( ), ); } else if (entry.type === "branch_summary" && entry.summary) { - messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + pushMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); } }; @@ -217,9 +257,10 @@ export function buildSessionContext( // TUI) at the point it fired, with any snapcompact frames re-attached so // the component can report them. for (const entry of path) { + handleEntryResetTracking(entry); if (entry.type === "compaction") { const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( entry.summary, entry.tokensBefore, @@ -252,7 +293,7 @@ export function buildSessionContext( // Emit summary first; re-attach any archived snapcompact frames so the // model can keep reading the archived history after every context rebuild. const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData); - messages.push( + pushMessage( createCompactionSummaryMessage( compaction.summary, compaction.tokensBefore, @@ -335,6 +376,9 @@ export function buildSessionContext( ); if (normalized.length === 0) { messages.splice(i, 1); + if (options?.transcript) { + cacheMissExplainedAt.splice(i, 1); + } } else { messages[i] = { ...message, content: normalized }; } @@ -342,6 +386,7 @@ export function buildSessionContext( return { messages, + cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined, thinkingLevel, serviceTier, models, diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index 87d8c011d..eb11c8ea8 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -111,7 +111,6 @@ describe("formatSessionDumpText tool parameters", () => { expect(out).not.toContain("## Available Tools"); }); - it("does not falsely omit the Available Tools section even if systemPrompt contains tool headings", () => { const out = formatSessionDumpText({ messages: [],