fix(session): resumed queued messages after compaction reconnects

The #5800 drain guard suppressed the abort-finally stranded-message
drain while the session was disconnected from the agent event stream.
newSession/switchSession drop the agent queues on transition, so nothing
is lost there. compact() preserves the queues and only reconnected in
its finally — it never re-drained — so a steer/follow-up arriving during
compaction (async IRC, an xd:// mount notice, an SDK steer) stayed
stranded until the next explicit prompt.

Re-drain in compact()'s finally after #reconnectToAgent (and after the
compaction AbortController is cleared, so isCompacting is false and the
scheduled agent.continue() actually runs). Added a regression test that
queues a follow-up mid-compaction and asserts it resumes.

Fixes #5800
This commit is contained in:
roboomp
2026-07-17 08:03:03 +00:00
parent 4d685bf761
commit 67727c8de5
3 changed files with 71 additions and 3 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed `/new` starting an unsolicited old-context provider turn when a hidden steer (e.g. an `xd://` mount notice) was queued: the session transition is now an atomic boundary, so a queued steer/follow-up can no longer auto-resume against the pre-`/new` context while the session is disconnected mid-transition ([#5800](https://github.com/can1357/oh-my-pi/issues/5800)).
- Fixed `/new` starting an unsolicited old-context provider turn when a hidden steer (e.g. an `xd://` mount notice) was queued: the session transition is now an atomic boundary, so a queued steer/follow-up can no longer auto-resume against the pre-`/new` context while the session is disconnected mid-transition. `/compact` still resumes a steer/follow-up that arrives while it runs, draining the queue once it reconnects ([#5800](https://github.com/can1357/oh-my-pi/issues/5800)).
## [17.0.2] - 2026-07-17
@@ -2248,8 +2248,10 @@ export class AgentSession {
// the still-old context (the transition hasn't reached agent.reset() yet), start a
// stale provider turn that races the reset, and — once reconnected — append its
// output to the fresh session (issue #5800). A disconnected session never owns the
// queue: the transition does. Leave any queued steer/follow-up for the post-transition
// state (reset drops them; an explicit prompt flushes them).
// queue: the transition does. newSession/switchSession drop the queue (reset /
// clearAllQueues), so nothing survives; compaction preserves it and re-drains itself
// after #reconnectToAgent (see compact()'s finally); an explicit prompt flushes it
// in every case.
if (this.#unsubscribeAgent === undefined) return;
// A concern steered into a resumed streaming run after a user interrupt can
// strand at the turn tail (steered past the loop's final boundary poll). While
@@ -11023,6 +11025,13 @@ export class AgentSession {
this.#compactionAbortController = undefined;
}
this.#reconnectToAgent();
// Compaction disconnected before `await abort()`, so abort's finally drain
// (and any steer/follow-up that arrived mid-compaction — async IRC, an
// `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
// (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
// queues, so nothing else resumes them: re-drain now that the listener is back
// and `isCompacting` is false, or the queued turn hangs until the next prompt.
this.#drainStrandedQueuedMessages();
}
}
@@ -268,6 +268,65 @@ describe("AgentSession auto-compaction queue resume", () => {
expect(compactingDuringAbort).toBe(true);
});
it("resumes a message queued during manual compaction once it completes (#5800)", async () => {
// Regression for #5800 review: manual /compact disconnects the agent
// listener before `await abort()`, so the abort-finally stranded-message
// drain is suppressed while disconnected. Unlike /new (which resets the
// queue), compaction preserves the agent queues, so a steer/follow-up that
// arrives mid-compaction (async IRC, an xd:// mount notice, an SDK steer)
// would hang until the next explicit prompt unless compact() re-drains
// after reconnecting.
session.settings.set("compaction.keepRecentTokens", 1);
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "previous answer" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1_000,
output: 100,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 1_100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
});
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
// Park compaction inside its awaited hook so we can queue a follow-up while
// the session is disconnected and abort has already run its finally.
const gate = Promise.withResolvers<void>();
(globalThis as typeof globalThis & { __ompManualCompactGate?: Promise<void> }).__ompManualCompactGate =
gate.promise;
const compactPromise = session.compact();
while (!getRuntimeSignals().includes("before_compact:enter")) {
await Promise.resolve();
}
// A message arrives DURING compaction (post-abort, still disconnected).
session.agent.followUp({
role: "user",
content: "please respond after compaction",
timestamp: Date.now(),
});
expect(session.agent.hasQueuedMessages()).toBe(true);
gate.resolve();
await compactPromise;
await session.waitForIdle();
// compact()'s finally re-drained the stranded queue after reconnecting.
expect(continueSpy).toHaveBeenCalledTimes(1);
});
it("cancels an in-flight auto-compaction when manual compact startup aborts", async () => {
// Give the branch something to summarize so auto-compaction reaches the
// awaited session_before_compact hook, where the test parks it.