feat: show status reaction during context compaction

When context auto-compaction runs, the bot appears frozen for 10-30s
with no feedback. This adds a ✍ (writing) status reaction during
compaction so users know the bot is still working.

- Add `compacting` state to StatusReactionController with ✍ emoji
- Add `onCompactionStart`/`onCompactionEnd` callbacks to GetReplyOptions
- Wire existing compaction events to the status reaction system
- Apply to both Telegram and Discord channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cypherm
2026-03-12 16:33:09 +08:00
committed by Josh Lehman
parent 0c8ea8d987
commit 96303d2d19
6 changed files with 39 additions and 1 deletions

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

@@ -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,7 @@ export type StatusReactionController = {
setQueued: () => Promise<void> | void;
setThinking: () => Promise<void> | void;
setTool: (toolName?: string) => Promise<void> | void;
setCompacting: () => Promise<void> | void;
setDone: () => Promise<void>;
setError: () => Promise<void>;
clear: () => Promise<void>;
@@ -58,6 +60,7 @@ export const DEFAULT_EMOJIS: Required<StatusReactionEmojis> = {
error: "😱",
stallSoft: "🥱",
stallHard: "😨",
compacting: "✍",
};
export const DEFAULT_TIMING: Required<StatusReactionTiming> = {
@@ -162,6 +165,7 @@ export function createStatusReactionController(params: {
emojis.error,
emojis.stallSoft,
emojis.stallHard,
emojis.compacting,
]);
/**
@@ -306,6 +310,10 @@ export function createStatusReactionController(params: {
scheduleEmoji(emoji);
}
function setCompacting(): void {
scheduleEmoji(emojis.compacting);
}
function finishWithEmoji(emoji: string): Promise<void> {
if (!enabled) {
return Promise.resolve();
@@ -375,6 +383,7 @@ export function createStatusReactionController(params: {
setQueued,
setThinking,
setTool,
setCompacting,
setDone,
setError,
clear,

View File

@@ -769,6 +769,18 @@ 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;
}
await statusReactions.setThinking();
},
},
});
if (isProcessAborted(abortSignal)) {

View File

@@ -713,6 +713,12 @@ export const dispatchTelegramMessage = async ({
await statusReactionController.setTool(payload.name);
}
: undefined,
onCompactionStart: statusReactionController
? () => statusReactionController.setCompacting()
: undefined,
onCompactionEnd: statusReactionController
? () => 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,
};
}