From 61d219cb39e3dc69e35a765d5d6b4c98691e20dd Mon Sep 17 00:00:00 2001 From: Cypherm Date: Fri, 13 Mar 2026 12:06:15 +0800 Subject: [PATCH] feat: show status reaction during context compaction (#35474) Merged via squash. Prepared head SHA: 145a7b7c4e1939718c41a300899ae813bd9c511b Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../reply/agent-runner-execution.ts | 6 ++- src/auto-reply/types.ts | 4 ++ src/channels/status-reactions.test.ts | 23 +++++++++ src/channels/status-reactions.ts | 17 +++++++ src/config/schema.help.ts | 2 +- src/config/types.messages.ts | 1 + src/config/zod-schema.session.ts | 1 + .../monitor/message-handler.process.test.ts | 47 +++++++++++++++++-- .../monitor/message-handler.process.ts | 13 +++++ src/infra/net/proxy-fetch.test.ts | 4 ++ src/telegram/bot-message-dispatch.test.ts | 37 +++++++++++++++ src/telegram/bot-message-dispatch.ts | 9 ++++ src/telegram/status-reaction-variants.ts | 3 ++ ui/src/ui/views/agents-utils.test.ts | 11 +++-- 15 files changed, 169 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e51b3c5d51..f6b385e8133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777. - Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. +- Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. ## 2026.3.11 diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index bdbd68ac2e4..ff3838a1936 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -393,11 +393,15 @@ export async function runAgentTurnWithFallback(params: { await params.opts?.onToolStart?.({ name, phase }); } } - // Track auto-compaction completion + // Track auto-compaction completion and notify UI layer if (evt.stream === "compaction") { const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; + if (phase === "start") { + await params.opts?.onCompactionStart?.(); + } if (phase === "end") { autoCompactionCompleted = true; + await params.opts?.onCompactionEnd?.(); } } }, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 4692d442ea5..be32e3635e1 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -54,6 +54,10 @@ export type GetReplyOptions = { onToolResult?: (payload: ReplyPayload) => Promise | void; /** Called when a tool phase starts/updates, before summary payloads are emitted. */ onToolStart?: (payload: { name?: string; phase?: string }) => Promise | void; + /** Called when context auto-compaction starts (allows UX feedback during the pause). */ + onCompactionStart?: () => Promise | void; + /** Called when context auto-compaction completes. */ + onCompactionEnd?: () => Promise | void; /** Called when the actual model is selected (including after fallback). * Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */ onModelSelected?: (ctx: ModelSelectedContext) => void; diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 9b61946d64e..41611c22b1a 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -148,6 +148,15 @@ describe("createStatusReactionController", () => { expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); }); + it("should debounce setCompacting and eventually call adapter", async () => { + const { calls, controller } = createEnabledController(); + + void controller.setCompacting(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.compacting }); + }); + it("should classify tool name and debounce", async () => { const { calls, controller } = createEnabledController(); @@ -245,6 +254,19 @@ describe("createStatusReactionController", () => { expect(calls.length).toBe(callsAfterFirst); }); + it("should cancel a pending compacting emoji before resuming thinking", async () => { + const { calls, controller } = createEnabledController(); + + void controller.setCompacting(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs - 1); + controller.cancelPending(); + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji); + expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]); + }); + it("should call removeReaction when adapter supports it and emoji changes", async () => { const { calls, controller } = createEnabledController(); @@ -446,6 +468,7 @@ describe("constants", () => { const emojiKeys = [ "queued", "thinking", + "compacting", "tool", "coding", "web", diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index 4b0651232c8..060555a997c 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -24,6 +24,7 @@ export type StatusReactionEmojis = { error?: string; // Default: "❌" stallSoft?: string; // Default: "⏳" stallHard?: string; // Default: "⚠️" + compacting?: string; // Default: "✍" }; export type StatusReactionTiming = { @@ -38,6 +39,9 @@ export type StatusReactionController = { setQueued: () => Promise | void; setThinking: () => Promise | void; setTool: (toolName?: string) => Promise | void; + setCompacting: () => Promise | void; + /** Cancel any pending debounced emoji (useful before forcing a state transition). */ + cancelPending: () => void; setDone: () => Promise; setError: () => Promise; clear: () => Promise; @@ -58,6 +62,7 @@ export const DEFAULT_EMOJIS: Required = { error: "😱", stallSoft: "🥱", stallHard: "😨", + compacting: "✍", }; export const DEFAULT_TIMING: Required = { @@ -162,6 +167,7 @@ export function createStatusReactionController(params: { emojis.error, emojis.stallSoft, emojis.stallHard, + emojis.compacting, ]); /** @@ -306,6 +312,15 @@ export function createStatusReactionController(params: { scheduleEmoji(emoji); } + function setCompacting(): void { + scheduleEmoji(emojis.compacting); + } + + function cancelPending(): void { + clearDebounceTimer(); + pendingEmoji = ""; + } + function finishWithEmoji(emoji: string): Promise { if (!enabled) { return Promise.resolve(); @@ -375,6 +390,8 @@ export function createStatusReactionController(params: { setQueued, setThinking, setTool, + setCompacting, + cancelPending, setDone, setError, clear, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 9c45125754c..20e764cbb25 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1481,7 +1481,7 @@ export const FIELD_HELP: Record = { "messages.statusReactions.enabled": "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", "messages.statusReactions.emojis": - "Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", + "Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", "messages.statusReactions.timing": "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "messages.inbound.debounceMs": diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 39a5ca7da69..002a1200b8b 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = { error?: string; stallSoft?: string; stallHard?: string; + compacting?: string; }; export type StatusReactionsTimingConfig = { diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 648caa60f5b..b8bb99b1b14 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -169,6 +169,7 @@ export const MessagesSchema = z error: z.string().optional(), stallSoft: z.string().optional(), stallHard: z.string().optional(), + compacting: z.string().optional(), }) .strict() .optional(), diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 8b059d00f39..96c9a65df9c 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -47,15 +47,19 @@ type DispatchInboundParams = { onReasoningStream?: () => Promise | void; onReasoningEnd?: () => Promise | void; onToolStart?: (payload: { name?: string }) => Promise | void; + onCompactionStart?: () => Promise | void; + onCompactionEnd?: () => Promise | void; onPartialReply?: (payload: { text?: string }) => Promise | void; onAssistantMessageStart?: () => Promise | void; }; }; -const dispatchInboundMessage = vi.fn(async (_params?: DispatchInboundParams) => ({ - queuedFinal: false, - counts: { final: 0, tool: 0, block: 0 }, -})); -const recordInboundSession = vi.fn(async () => {}); +const dispatchInboundMessage = vi.hoisted(() => + vi.fn(async (_params?: DispatchInboundParams) => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +); +const recordInboundSession = vi.hoisted(() => vi.fn(async () => {})); const configSessionsMocks = vi.hoisted(() => ({ readSessionUpdatedAt: vi.fn(() => undefined), resolveStorePath: vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json"), @@ -346,6 +350,39 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain("🏁"); }); + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { + vi.useFakeTimers(); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onCompactionStart?.(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + await params?.replyOptions?.onCompactionEnd?.(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + return createNoQueuedDispatchResult(); + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { + ackReaction: "👀", + statusReactions: { + timing: { debounceMs: 0 }, + }, + }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + const runPromise = processDiscordMessage(ctx as any); + await vi.advanceTimersByTimeAsync(2_500); + await vi.runAllTimersAsync(); + await runPromise; + + const emojis = getReactionEmojis(); + expect(emojis).toContain(DEFAULT_EMOJIS.compacting); + expect(emojis).toContain(DEFAULT_EMOJIS.thinking); + }); + it("clears status reactions when dispatch aborts and removeAckAfterReply is enabled", async () => { const abortController = new AbortController(); dispatchInboundMessage.mockImplementationOnce(async () => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index ea64b37f98e..36978628b7a 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -769,6 +769,19 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } await statusReactions.setTool(payload.name); }, + onCompactionStart: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + await statusReactions.setCompacting(); + }, + onCompactionEnd: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + statusReactions.cancelPending(); + await statusReactions.setThinking(); + }, }, }); if (isProcessAborted(abortSignal)) { diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts index 331cd1ac6ea..6fb0c01dc0d 100644 --- a/src/infra/net/proxy-fetch.test.ts +++ b/src/infra/net/proxy-fetch.test.ts @@ -75,6 +75,8 @@ describe("resolveProxyFetchFromEnv", () => { it("returns proxy fetch using EnvHttpProxyAgent when HTTPS_PROXY is set", async () => { vi.stubEnv("HTTP_PROXY", ""); vi.stubEnv("HTTPS_PROXY", "http://proxy.test:8080"); + delete process.env.https_proxy; + delete process.env.http_proxy; undiciFetch.mockResolvedValue({ ok: true }); const fetchFn = resolveProxyFetchFromEnv(); @@ -91,6 +93,8 @@ describe("resolveProxyFetchFromEnv", () => { it("returns proxy fetch when HTTP_PROXY is set", () => { vi.stubEnv("HTTPS_PROXY", ""); vi.stubEnv("HTTP_PROXY", "http://fallback.test:3128"); + delete process.env.https_proxy; + delete process.env.http_proxy; const fetchFn = resolveProxyFetchFromEnv(); expect(fetchFn).toBeDefined(); diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 17ec8ac21a9..62255706fbd 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -2182,4 +2182,41 @@ describe("dispatchTelegramMessage draft streaming", () => { ); expect(finalTextSentViaDeliverReplies).toBe(true); }); + + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { + const statusReactionController = { + setThinking: vi.fn(async () => {}), + setCompacting: vi.fn(async () => {}), + setTool: vi.fn(async () => {}), + setDone: vi.fn(async () => {}), + setError: vi.fn(async () => {}), + setQueued: vi.fn(async () => {}), + cancelPending: vi.fn(() => {}), + clear: vi.fn(async () => {}), + restoreInitial: vi.fn(async () => {}), + }; + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { + await replyOptions?.onCompactionStart?.(); + await replyOptions?.onCompactionEnd?.(); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + statusReactionController: statusReactionController as never, + }), + streamMode: "off", + }); + + expect(statusReactionController.setCompacting).toHaveBeenCalledTimes(1); + expect(statusReactionController.cancelPending).toHaveBeenCalledTimes(1); + expect(statusReactionController.setThinking).toHaveBeenCalledTimes(2); + expect(statusReactionController.setCompacting.mock.invocationCallOrder[0]).toBeLessThan( + statusReactionController.cancelPending.mock.invocationCallOrder[0], + ); + expect(statusReactionController.cancelPending.mock.invocationCallOrder[0]).toBeLessThan( + statusReactionController.setThinking.mock.invocationCallOrder[1], + ); + }); }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 4d8d2b678e8..424f98caefc 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -713,6 +713,15 @@ export const dispatchTelegramMessage = async ({ await statusReactionController.setTool(payload.name); } : undefined, + onCompactionStart: statusReactionController + ? () => statusReactionController.setCompacting() + : undefined, + onCompactionEnd: statusReactionController + ? async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + } + : undefined, onModelSelected, }, })); diff --git a/src/telegram/status-reaction-variants.ts b/src/telegram/status-reaction-variants.ts index 5f79b1cbadb..9ce3d033eb0 100644 --- a/src/telegram/status-reaction-variants.ts +++ b/src/telegram/status-reaction-variants.ts @@ -90,6 +90,7 @@ export const TELEGRAM_STATUS_REACTION_VARIANTS: Record { describe("resolveAgentAvatarUrl", () => { it("prefers a runtime avatar URL over non-URL identity avatars", () => { expect( - resolveAgentAvatarUrl({ identity: { avatar: "A", avatarUrl: "/avatar/main" } }, { - avatar: "A", - } as { avatar: string }), + resolveAgentAvatarUrl( + { identity: { avatar: "A", avatarUrl: "/avatar/main" } }, + { + agentId: "main", + avatar: "A", + name: "Main", + }, + ), ).toBe("/avatar/main"); });