mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(telegram): keep compact command replies visible
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user