fix(telegram): keep compact command replies visible

This commit is contained in:
Ayaan Zaidi
2026-06-08 23:19:05 +05:30
parent fff5261ade
commit aa935ddeb2
11 changed files with 214 additions and 16 deletions
+2
View File
@@ -8,7 +8,9 @@ export { runEmbeddedAgent } from "./embedded-agent-runner/run.js";
export {
abortAndDrainEmbeddedAgentRun,
abortEmbeddedAgentRun,
isEmbeddedAgentRunAbortableForCompaction,
isEmbeddedAgentRunActive,
isEmbeddedAgentRunHandleActive,
isEmbeddedAgentRunStreaming,
queueEmbeddedAgentMessage,
queueEmbeddedAgentMessageWithOutcome,
@@ -24,6 +24,7 @@ import {
clearEmbeddedRunAbandonment,
consumeEmbeddedRunModelSwitch,
getActiveEmbeddedRunSnapshot,
isEmbeddedAgentRunAbortableForCompaction,
isEmbeddedAgentRunHandleActive,
isEmbeddedRunAbandoned,
formatEmbeddedAgentQueueFailureSummary,
@@ -91,6 +92,20 @@ describe("embedded-agent runner run registry", () => {
expect(abortNormal).not.toHaveBeenCalled();
});
it("keeps queued reply operations out of compact abort checks", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-reply-run",
resetTriggered: false,
});
expect(isEmbeddedAgentRunAbortableForCompaction("session-reply-run")).toBe(false);
operation.setPhase("running");
expect(isEmbeddedAgentRunAbortableForCompaction("session-reply-run")).toBe(true);
});
it("aborts every active run in all mode", () => {
const abortA = vi.fn();
const abortB = vi.fn();
+9
View File
@@ -7,6 +7,7 @@ import {
abortReplyRunBySessionId,
forceClearReplyRunBySessionId,
isReplyRunActiveForSessionId,
isReplyRunAbortableForCompaction,
isReplyRunStreamingForSessionId,
queueReplyRunMessage,
resolveActiveReplyRunSessionId,
@@ -520,6 +521,14 @@ export function isEmbeddedAgentRunHandleActive(sessionId: string): boolean {
return active;
}
export function isEmbeddedAgentRunAbortableForCompaction(sessionId: string): boolean {
const active = ACTIVE_EMBEDDED_RUNS.has(sessionId) || isReplyRunAbortableForCompaction(sessionId);
if (active) {
diag.debug(`run compact abort check: sessionId=${sessionId} active=true`);
}
return active;
}
export function isEmbeddedAgentRunStreaming(sessionId: string): boolean {
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
if (!handle) {
+2
View File
@@ -10,7 +10,9 @@ export {
abortAndDrainEmbeddedAgentRun,
abortEmbeddedAgentRun,
compactEmbeddedAgentSession,
isEmbeddedAgentRunAbortableForCompaction,
isEmbeddedAgentRunActive,
isEmbeddedAgentRunHandleActive,
isEmbeddedAgentRunStreaming,
queueEmbeddedAgentMessage,
queueEmbeddedAgentMessageWithOutcome,
@@ -2,7 +2,7 @@
export {
abortEmbeddedAgentRun,
compactEmbeddedAgentSession,
isEmbeddedAgentRunActive,
isEmbeddedAgentRunAbortableForCompaction,
waitForEmbeddedAgentRunEnd,
} from "../../agents/embedded-agent.js";
export {
+60 -1
View File
@@ -14,7 +14,7 @@ vi.mock("./commands-compact.runtime.js", () => ({
formatContextUsageShort: vi.fn(() => "Context 12.1k"),
formatTokenCount: vi.fn((value: number) => `${value}`),
incrementCompactionCount: vi.fn(),
isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false),
isEmbeddedAgentRunAbortableForCompaction: vi.fn().mockReturnValue(false),
resolveFreshSessionTotalTokens: vi.fn(() => 12_345),
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
resolveSessionFilePathOptions: vi.fn(() => ({})),
@@ -22,10 +22,13 @@ vi.mock("./commands-compact.runtime.js", () => ({
}));
const {
abortEmbeddedAgentRun,
compactEmbeddedAgentSession,
formatContextUsageShort,
incrementCompactionCount,
isEmbeddedAgentRunAbortableForCompaction,
resolveSessionFilePathOptions,
waitForEmbeddedAgentRunEnd,
} = await import("./commands-compact.runtime.js");
const { handleCompactCommand } = await import("./commands-compact.js");
@@ -191,6 +194,62 @@ describe("handleCompactCommand", () => {
expect(call.senderE164).toBe("+15551234567");
expect(call.agentDir).toBe("/tmp/openclaw-agent-compact");
expect(call.authProfileId).toBe("github-copilot:work");
expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled();
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled();
});
it("does not abort the command reply run before compacting", async () => {
vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(false);
vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({
ok: true,
compacted: false,
});
const result = await handleCompactCommand(
{
...buildCompactParams("/compact", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
sessionEntry: {
sessionId: "session-1",
updatedAt: Date.now(),
},
} as HandleCommandsParams,
true,
);
expect(result?.shouldContinue).toBe(false);
expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).toHaveBeenCalledWith("session-1");
expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled();
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled();
expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce();
});
it("aborts an active embedded run before compacting", async () => {
vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true);
vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({
ok: true,
compacted: false,
});
await handleCompactCommand(
{
...buildCompactParams("/compact", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
sessionEntry: {
sessionId: "session-1",
updatedAt: Date.now(),
},
} as HandleCommandsParams,
true,
);
expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1");
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000);
expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce();
});
it("treats already-under-target manual compaction as skipped", async () => {
+1 -1
View File
@@ -217,7 +217,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
}
const runtime = await loadCompactRuntime();
const sessionId = targetSessionEntry.sessionId;
if (runtime.isEmbeddedAgentRunActive(sessionId)) {
if (runtime.isEmbeddedAgentRunAbortableForCompaction(sessionId)) {
runtime.abortEmbeddedAgentRun(sessionId);
await runtime.waitForEmbeddedAgentRunEnd(sessionId, 15_000);
}
@@ -11,6 +11,7 @@ import {
createReplyOperation,
forceClearReplyRunBySessionId,
isReplyRunActiveForSessionId,
isReplyRunAbortableForCompaction,
queueReplyRunMessage,
replyRunRegistry,
resolveActiveReplyRunSessionId,
@@ -58,6 +59,21 @@ describe("reply run registry", () => {
}
});
it("treats queued reply operations as non-abortable for compaction", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-compact",
resetTriggered: false,
});
expect(isReplyRunActiveForSessionId("session-compact")).toBe(true);
expect(isReplyRunAbortableForCompaction("session-compact")).toBe(false);
operation.setPhase("running");
expect(isReplyRunAbortableForCompaction("session-compact")).toBe(true);
});
it("mirrors active reply operations into diagnostic work state", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:telegram:direct:chat-1",
@@ -518,6 +518,11 @@ export function isReplyRunActiveForSessionId(sessionId: string): boolean {
return resolveReplyRunForCurrentSessionId(sessionId) !== undefined;
}
export function isReplyRunAbortableForCompaction(sessionId: string): boolean {
const operation = resolveReplyRunForCurrentSessionId(sessionId);
return Boolean(operation && operation.phase !== "queued");
}
export function isReplyRunStreamingForSessionId(sessionId: string): boolean {
const operation = resolveReplyRunForCurrentSessionId(sessionId);
if (!operation || operation.phase !== "running") {
@@ -57,6 +57,7 @@ const mockState = vi.hoisted(() => ({
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
isStatusNotice?: boolean;
isError?: boolean;
};
}>,
@@ -1490,6 +1491,38 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
]);
});
it("broadcasts agent-run status notices without source reply mirrors", async () => {
createTranscriptFixture("openclaw-chat-send-agent-status-notice-");
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
text: "⚙️ Codex compaction started • Context 2k/200k",
isStatusNotice: true,
},
},
];
const respond = vi.fn();
const context = createChatContext();
const broadcast = await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-agent-status-notice",
message: "/compact",
});
expect(broadcast).toMatchObject({
runId: "idem-agent-status-notice",
sessionKey: "main",
state: "final",
});
expect(extractFirstTextBlock(broadcast)).toBe("⚙️ Codex compaction started • Context 2k/200k");
const assistantEntries = await readActiveAssistantTranscriptMessages();
expect(assistantEntries).toStrictEqual([]);
});
it("does not duplicate media-bearing internal-ui source replies in the transcript", async () => {
await withTranscriptFixtureState(
"openclaw-chat-send-agent-source-reply-media-",
@@ -2175,6 +2208,48 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("broadcasts returned agent errors after status notices", async () => {
createTranscriptFixture("openclaw-chat-send-agent-status-notice-error-");
const errorMessage = "LLM idle timeout (120s): no response from model";
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
text: "⚙️ Codex compaction started • Context 2k/200k",
isStatusNotice: true,
},
},
{
kind: "final",
payload: {
text: errorMessage,
isError: true,
},
},
];
const respond = vi.fn();
const context = createChatContext();
const broadcast = await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-agent-status-notice-error",
message: "/compact",
});
expect(broadcast).toMatchObject({
runId: "idem-agent-status-notice-error",
sessionKey: "main",
state: "error",
errorMessage,
});
const finalBroadcasts = (
context.broadcast as unknown as ReturnType<typeof vi.fn>
).mock.calls.filter(([, payload]) => (payload as { state?: unknown })?.state === "final");
expect(finalBroadcasts).toStrictEqual([]);
});
it("broadcasts returned agent-run error payloads after an agent starts", async () => {
createTranscriptFixture("openclaw-chat-send-agent-returned-error-");
const errorMessage = "LLM idle timeout (120s): no response from model";
+28 -13
View File
@@ -46,7 +46,11 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { modelCatalogBrowseRequiresFullDiscovery } from "../../agents/model-catalog-browse.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js";
import {
getReplyPayloadMetadata,
isReplyPayloadStatusNotice,
type ReplyPayload,
} from "../../auto-reply/reply-payload.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { stageSandboxMedia } from "../../auto-reply/reply/stage-sandbox-media.js";
import type { MsgContext, TemplateContext } from "../../auto-reply/templating.js";
@@ -4043,17 +4047,25 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
} else {
const sourceReplyPayloads = deliveredReplies
const hasReturnedAgentErrorPayloads = returnedAgentErrorPayloads.length > 0;
const agentRunReplyPayloads = deliveredReplies
.filter((entryEntry) => entryEntry.kind === "final")
.map((entryResult) => entryResult.payload)
.filter(isSourceReplyTranscriptMirrorPayload);
if (sourceReplyPayloads.length > 0) {
.filter(
(payload) =>
isSourceReplyTranscriptMirrorPayload(payload) ||
(!hasReturnedAgentErrorPayloads && isReplyPayloadStatusNotice(payload)),
);
if (agentRunReplyPayloads.length > 0) {
const hasSourceReplyTranscriptMirror = agentRunReplyPayloads.some(
isSourceReplyTranscriptMirrorPayload,
);
const finalPayloads = await normalizeWebchatReplyMediaPathsForDisplay({
cfg,
sessionKey,
agentId,
accountId,
payloads: sourceReplyPayloads,
payloads: agentRunReplyPayloads,
});
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
sessionKey,
@@ -4100,11 +4112,11 @@ export const chatHandlers: GatewayRequestHandlers = {
},
});
const combinedAssistantContent =
sourceReplyPayloads.length === 1
agentRunReplyPayloads.length === 1
? await buildReplyAssistantContent(finalPayloads)
: undefined;
const combinedMediaMessage =
sourceReplyPayloads.length === 1
agentRunReplyPayloads.length === 1
? await buildReplyMediaMessage(finalPayloads)
: undefined;
type SourceReplyContentState = {
@@ -4115,17 +4127,17 @@ export const chatHandlers: GatewayRequestHandlers = {
};
const sourceReplyContentStates: SourceReplyContentState[] = [];
const sourceReplyBroadcastContent: AssistantDisplayContentBlock[] = [];
for (const [replyIndex] of sourceReplyPayloads.entries()) {
for (const [replyIndex] of agentRunReplyPayloads.entries()) {
const finalPayload = finalPayloads[replyIndex];
if (!finalPayload) {
continue;
}
const replyAssistantContent =
sourceReplyPayloads.length === 1
agentRunReplyPayloads.length === 1
? combinedAssistantContent
: await buildReplyAssistantContent([finalPayload]);
const replyMediaMessage =
sourceReplyPayloads.length === 1
agentRunReplyPayloads.length === 1
? combinedMediaMessage
: await buildReplyMediaMessage([finalPayload]);
const replyBroadcastContent = hasAssistantDisplayMediaContent(
@@ -4163,7 +4175,10 @@ export const chatHandlers: GatewayRequestHandlers = {
>["sourceReplyTranscriptMirror"];
state: SourceReplyContentState;
}> = [];
for (const [replyIndex, sourceReplyPayload] of sourceReplyPayloads.entries()) {
for (const [
replyIndex,
sourceReplyPayload,
] of agentRunReplyPayloads.entries()) {
const state = sourceReplyContentStates[replyIndex];
if (!state || !hasAssistantDisplayMediaContent(state.persistedContent)) {
continue;
@@ -4210,7 +4225,7 @@ export const chatHandlers: GatewayRequestHandlers = {
for (const [
replyIndex,
sourceReplyPayload,
] of sourceReplyPayloads.entries()) {
] of agentRunReplyPayloads.entries()) {
if (!sourceReplyContentStates[replyIndex]) {
continue;
}
@@ -4352,7 +4367,7 @@ export const chatHandlers: GatewayRequestHandlers = {
agentId,
message,
});
broadcastedSourceReplyFinal = true;
broadcastedSourceReplyFinal = hasSourceReplyTranscriptMirror;
}
}
}