From b9beb6869e1003b61dcd99737d8822f918c65819 Mon Sep 17 00:00:00 2001 From: zidongdesign Date: Sun, 8 Mar 2026 10:42:45 +0800 Subject: [PATCH] fix: route compaction completion notice through block reply pipeline Previously the completion notice bypassed the block-reply pipeline by calling opts.onBlockReply directly after the pipeline had already been flushed and stopped. This meant timeout/abort handling and serial delivery guarantees did not apply to the notice, risking stalls or out-of-order delivery in streaming/routed runs. Fix: enqueue the completion notice into blockReplyPipeline *before* flush so it is delivered through the same path as every other block reply. The non-streaming fallback (verboseNotices) is preserved for runs where no pipeline exists. Also removes the now-unnecessary direct opts.onBlockReply call and cleans up the redundant suffix in the pre-flush path (count suffix is still included in the verboseNotices fallback path where count is available). Addresses P1 review comment on PR #38805. --- src/auto-reply/reply/agent-runner.ts | 42 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 8b65b6d00d9..e6acb1d9af5 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -414,6 +414,27 @@ export async function runReplyAgent(params: { const payloadArray = runResult.payloads ?? []; + // If compaction completed, enqueue the completion notice into the pipeline + // *before* flushing so it benefits from the same timeout/abort/serial- + // delivery guarantees as every other block reply. The count update and + // post-compaction context injection still happen later (after flush) because + // they don't affect the user-visible notice text at this point — we use a + // placeholder suffix here and the full count is logged separately. + if (autoCompactionCompleted && blockReplyPipeline) { + const verboseEnabled = resolvedVerboseLevel !== "off"; + const completionText = verboseEnabled + ? `🧹 Auto-compaction complete.` + : `✅ Context compacted.`; + const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; + blockReplyPipeline.enqueue( + applyReplyToMode({ + text: completionText, + replyToId: currentMessageId, + replyToCurrent: true, + }), + ); + } + if (blockReplyPipeline) { await blockReplyPipeline.flush({ force: true }); blockReplyPipeline.stop(); @@ -706,21 +727,12 @@ export async function runReplyAgent(params: { ? `🧹 Auto-compaction complete${suffix}.` : `✅ Context compacted${suffix}.`; - // In block-streaming mode, onBlockReply bypasses buildReplyPayloads, so - // we must deliver the completion notice the same way the start notice was - // sent (via onBlockReply directly). Otherwise the user sees the "🧹 - // Compacting context..." start notice but never receives the completion. - // Apply replyToMode so the notice is threaded consistently with normal - // replies when replyToMode=all|first is configured. - if (opts?.onBlockReply) { - const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; - const noticePayload = applyReplyToMode({ - text: completionText, - replyToId: currentMessageId, - replyToCurrent: true, - }); - await opts.onBlockReply(noticePayload); - } else { + // In block-streaming mode the completion notice was already enqueued into + // blockReplyPipeline before flush (see above), so it travels through the + // normal timeout/abort/serial-delivery path without bypassing the pipeline. + // Here we only handle the non-streaming fallback: push into verboseNotices + // so it appears as a final payload alongside other verbose output. + if (!blockReplyPipeline) { verboseNotices.push({ text: completionText }); } }