fix(session): dropped recovered overflow error turns

Removed recoverable context-overflow and incomplete-response assistant errors from both active context and persisted session history before compaction/promotion schedules the retry.

Fixes #3747
This commit is contained in:
roboomp
2026-06-28 19:34:33 +00:00
parent 7bbaebda24
commit dd57ee85bf
3 changed files with 42 additions and 10 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed recoverable context-overflow compaction keeping the failed assistant error turn in visible session history after scheduling the retry. ([#3747](https://github.com/can1357/oh-my-pi/issues/3747))
## [16.2.4] - 2026-06-28
### Fixed
@@ -9482,12 +9482,7 @@ export class AgentSession {
const errorIsFromBeforeCompaction =
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
// Remove the error message from agent state (it IS saved to session for history,
// but we don't want it in context for the retry)
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
await this.#discardRecoverableAssistantTurn(assistantMessage);
// Try context promotion first - switch to a larger model and retry without compacting
const promoted = await this.#tryContextPromotion(assistantMessage);
@@ -9512,10 +9507,7 @@ export class AgentSession {
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
// allow the handoff strategy to actually run.
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.replaceMessages(messages.slice(0, -1));
}
await this.#discardRecoverableAssistantTurn(assistantMessage);
const promoted = await this.#tryContextPromotion(assistantMessage);
if (promoted) {
@@ -9797,6 +9789,11 @@ export class AgentSession {
}
}
async #discardRecoverableAssistantTurn(assistantMessage: AssistantMessage): Promise<void> {
await this.#waitForSessionMessagePersistence(assistantMessage);
this.#discardAssistantTurn(assistantMessage);
}
/**
* Drop an assistant turn from BOTH the live agent context and the persisted
* session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -382,6 +382,37 @@ describe("AgentSession auto-compaction progress guard", () => {
expect(noProgress.length).toBe(0);
});
it("removes the visible overflow error before retrying after compaction", async () => {
seedPriorTurns();
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
vi.spyOn(session, "getContextUsage").mockReturnValue({ tokens: 150000, contextWindow: 200000, percent: 75 });
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_compaction_end") onCompactionDone();
});
const assistantMsg = overflowAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",
message: expect.objectContaining({
role: "assistant",
stopReason: "error",
errorMessage: assistantMsg.errorMessage,
}),
}),
);
});
it("retries a small-window overflow when the default reserve exceeds the model window", async () => {
// Bundled 4k/8k models can be smaller than the default absolute reserve
// (16,384). Retry fit must clamp that reserve; otherwise the budget goes