feat(coding-agent): added tracking for expected cache invalidations

- Added tracking for expected cache invalidations during model changes, compactions, and plan-mode transitions.
- Included `cacheMissExplainedAt` metadata in session context to prevent displaying misleading cache miss warnings in the transcript.
- Updated controller logic to reset assistant usage markers when mode-switching or performing actions that invalidate the prompt cache.
This commit is contained in:
can1357
2026-06-19 08:08:14 +02:00
parent 65f0d0a245
commit 82c4c14fd7
5 changed files with 65 additions and 8 deletions
@@ -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();
@@ -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;
@@ -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);
}
@@ -20,6 +20,13 @@ export interface SessionContext {
mode: string;
/** Mode-specific data from the last mode_change entry */
modeData?: Record<string, unknown>;
/**
* 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,
@@ -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: [],