diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5efacdc6..2f541235 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1736,8 +1736,20 @@ export class AgentSession { // This ensures sessions that hit persistent API errors (e.g. 529) can still compact. let contextTokens: number; if (assistantMessage.stopReason === "error") { - const estimate = estimateContextTokens(this.agent.state.messages); + const messages = this.agent.state.messages; + const estimate = estimateContextTokens(messages); if (estimate.lastUsageIndex === null) return; // No usage data at all + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return; + } contextTokens = estimate.tokens; } else { contextTokens = calculateContextTokens(assistantMessage.usage); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 48381cc8..ab8c9f00 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -351,4 +351,86 @@ describe("AgentSession auto-compaction queue resume", () => { expect(runAutoCompactionSpy).not.toHaveBeenCalled(); }); + + it("should not trigger threshold compaction for error messages when only kept pre-compaction usage exists", async () => { + const model = session.model!; + const preCompactionTimestamp = Date.now() - 10_000; + + // A "kept" assistant message from before compaction with high usage + const keptAssistant: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "kept response from before compaction" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 180_000, + output: 10_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 190_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: preCompactionTimestamp, + }; + + // Record the kept assistant in the session and create a compaction after it + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "before compaction" }], + timestamp: preCompactionTimestamp - 1000, + }); + sessionManager.appendMessage(keptAssistant); + const firstKeptEntryId = sessionManager.getEntries()[0]!.id; + sessionManager.appendCompaction("summary", firstKeptEntryId, keptAssistant.usage.totalTokens, undefined, false); + + // Post-compaction error message + const errorAssistant: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: "529 overloaded", + timestamp: Date.now(), + }; + + // Agent state has the kept assistant (pre-compaction) and the error (post-compaction) + session.agent.replaceMessages([ + { role: "user", content: [{ type: "text", text: "kept user msg" }], timestamp: preCompactionTimestamp - 1000 }, + keptAssistant, + { role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 }, + errorAssistant, + ]); + + const runAutoCompactionSpy = vi + .spyOn( + session as unknown as { + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; + }, + "_runAutoCompaction", + ) + .mockResolvedValue(); + + const checkCompaction = ( + session as unknown as { + _checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise; + } + )._checkCompaction.bind(session); + + await checkCompaction(errorAssistant); + + // Should NOT compact because the only usage data is from a kept pre-compaction message + expect(runAutoCompactionSpy).not.toHaveBeenCalled(); + }); });