diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fafa1031e..efd21e71c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1075,17 +1075,11 @@ export class AgentSession { throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); } - // Check if we need to compact before sending (catches aborted responses) + // Check if we need to compact before sending (catches aborted responses). + // The user's new prompt is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); - if (lastAssistant && (await this._checkCompaction(lastAssistant, false))) { - try { - await this.agent.continue(); - while (await this._handlePostAgentRun()) { - await this.agent.continue(); - } - } finally { - this._flushPendingBashMessages(); - } + if (lastAssistant) { + await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) diff --git a/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts new file mode 100644 index 000000000..3f53aba01 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/pre-prompt-compaction-no-continue.test.ts @@ -0,0 +1,75 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, getUserTexts, type Harness } from "../harness.ts"; + +function createUsage(totalTokens: number) { + return { + input: totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +describe("pre-prompt compaction regression", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("compacts length-stop overflow before a new prompt without continuing from an assistant message", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }], + settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "pre-prompt summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + + const now = Date.now(); + const model = harness.getModel(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "previous prompt" }], + timestamp: now - 1000, + }); + const lengthStopAssistant: AssistantMessage = { + ...fauxAssistantMessage("length-stop assistant response", { stopReason: "length", timestamp: now - 500 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: createUsage(100), + }; + harness.sessionManager.appendMessage(lengthStopAssistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + harness.setResponses([fauxAssistantMessage("answered next prompt")]); + const continueSpy = vi.spyOn(harness.session.agent, "continue"); + + await expect(harness.session.prompt("next prompt")).resolves.toBeUndefined(); + + expect(continueSpy).not.toHaveBeenCalled(); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "overflow", + aborted: false, + willRetry: true, + }); + expect(getUserTexts(harness)).toContain("next prompt"); + expect(harness.faux.state.callCount).toBe(1); + }); +});