feat: show status reaction during context compaction (#35474)

Merged via squash.

Prepared head SHA: 145a7b7c4e
Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Cypherm 2026-03-13 12:06:15 +08:00 committed by GitHub
parent 4e872521f0
commit 61d219cb39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 169 additions and 10 deletions

View File

@ -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

View File

@ -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?.();
}
}
},

View File

@ -54,6 +54,10 @@ export type GetReplyOptions = {
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
/** Called when a tool phase starts/updates, before summary payloads are emitted. */
onToolStart?: (payload: { name?: string; phase?: string }) => Promise<void> | void;
/** Called when context auto-compaction starts (allows UX feedback during the pause). */
onCompactionStart?: () => Promise<void> | void;
/** Called when context auto-compaction completes. */
onCompactionEnd?: () => Promise<void> | 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;

View File

@ -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",

View File

@ -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> | void;
setThinking: () => Promise<void> | void;
setTool: (toolName?: string) => Promise<void> | void;
setCompacting: () => Promise<void> | void;
/** Cancel any pending debounced emoji (useful before forcing a state transition). */
cancelPending: () => void;
setDone: () => Promise<void>;
setError: () => Promise<void>;
clear: () => Promise<void>;
@ -58,6 +62,7 @@ export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
error: "😱",
stallSoft: "🥱",
stallHard: "😨",
compacting: "✍",
};
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
@ -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<void> {
if (!enabled) {
return Promise.resolve();
@ -375,6 +390,8 @@ export function createStatusReactionController(params: {
setQueued,
setThinking,
setTool,
setCompacting,
cancelPending,
setDone,
setError,
clear,

View File

@ -1481,7 +1481,7 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = {
error?: string;
stallSoft?: string;
stallHard?: string;
compacting?: string;
};
export type StatusReactionsTimingConfig = {

View File

@ -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(),

View File

@ -47,15 +47,19 @@ type DispatchInboundParams = {
onReasoningStream?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onToolStart?: (payload: { name?: string }) => Promise<void> | void;
onCompactionStart?: () => Promise<void> | void;
onCompactionEnd?: () => Promise<void> | void;
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | 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 () => {

View File

@ -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)) {

View File

@ -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();

View File

@ -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],
);
});
});

View File

@ -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,
},
}));

View File

@ -90,6 +90,7 @@ export const TELEGRAM_STATUS_REACTION_VARIANTS: Record<StatusReactionEmojiKey, s
error: ["😱", "😨", "🤯"],
stallSoft: ["🥱", "😴", "🤔"],
stallHard: ["😨", "😱", "⚡"],
compacting: ["✍", "🤔", "🤯"],
};
const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
@ -102,6 +103,7 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
"error",
"stallSoft",
"stallHard",
"compacting",
];
function normalizeEmoji(value: string | undefined): string | undefined {
@ -129,6 +131,7 @@ export function resolveTelegramStatusReactionEmojis(params: {
error: normalizeEmoji(overrides?.error) ?? DEFAULT_EMOJIS.error,
stallSoft: normalizeEmoji(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
stallHard: normalizeEmoji(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
compacting: normalizeEmoji(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
};
}

View File

@ -115,9 +115,14 @@ describe("agentLogoUrl", () => {
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");
});