mirror of https://github.com/openclaw/openclaw.git
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:
parent
4e872521f0
commit
61d219cb39
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = {
|
|||
error?: string;
|
||||
stallSoft?: string;
|
||||
stallHard?: string;
|
||||
compacting?: string;
|
||||
};
|
||||
|
||||
export type StatusReactionsTimingConfig = {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue