Skip to content

Commit 259e3ba

Browse files
committed
fix(telegram): keep compact command replies visible
1 parent ab4757c commit 259e3ba

11 files changed

Lines changed: 214 additions & 16 deletions

File tree

src/agents/embedded-agent-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export { runEmbeddedAgent } from "./embedded-agent-runner/run.js";
88
export {
99
abortAndDrainEmbeddedAgentRun,
1010
abortEmbeddedAgentRun,
11+
isEmbeddedAgentRunAbortableForCompaction,
1112
isEmbeddedAgentRunActive,
13+
isEmbeddedAgentRunHandleActive,
1214
isEmbeddedAgentRunStreaming,
1315
queueEmbeddedAgentMessage,
1416
queueEmbeddedAgentMessageWithOutcome,

src/agents/embedded-agent-runner/runs.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
clearEmbeddedRunAbandonment,
2525
consumeEmbeddedRunModelSwitch,
2626
getActiveEmbeddedRunSnapshot,
27+
isEmbeddedAgentRunAbortableForCompaction,
2728
isEmbeddedAgentRunHandleActive,
2829
isEmbeddedRunAbandoned,
2930
formatEmbeddedAgentQueueFailureSummary,
@@ -91,6 +92,20 @@ describe("embedded-agent runner run registry", () => {
9192
expect(abortNormal).not.toHaveBeenCalled();
9293
});
9394

95+
it("keeps queued reply operations out of compact abort checks", () => {
96+
const operation = createReplyOperation({
97+
sessionKey: "agent:main:main",
98+
sessionId: "session-reply-run",
99+
resetTriggered: false,
100+
});
101+
102+
expect(isEmbeddedAgentRunAbortableForCompaction("session-reply-run")).toBe(false);
103+
104+
operation.setPhase("running");
105+
106+
expect(isEmbeddedAgentRunAbortableForCompaction("session-reply-run")).toBe(true);
107+
});
108+
94109
it("aborts every active run in all mode", () => {
95110
const abortA = vi.fn();
96111
const abortB = vi.fn();

src/agents/embedded-agent-runner/runs.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
abortReplyRunBySessionId,
88
forceClearReplyRunBySessionId,
99
isReplyRunActiveForSessionId,
10+
isReplyRunAbortableForCompaction,
1011
isReplyRunStreamingForSessionId,
1112
queueReplyRunMessage,
1213
resolveActiveReplyRunSessionId,
@@ -520,6 +521,14 @@ export function isEmbeddedAgentRunHandleActive(sessionId: string): boolean {
520521
return active;
521522
}
522523

524+
export function isEmbeddedAgentRunAbortableForCompaction(sessionId: string): boolean {
525+
const active = ACTIVE_EMBEDDED_RUNS.has(sessionId) || isReplyRunAbortableForCompaction(sessionId);
526+
if (active) {
527+
diag.debug(`run compact abort check: sessionId=${sessionId} active=true`);
528+
}
529+
return active;
530+
}
531+
523532
export function isEmbeddedAgentRunStreaming(sessionId: string): boolean {
524533
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
525534
if (!handle) {

src/agents/embedded-agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export {
1010
abortAndDrainEmbeddedAgentRun,
1111
abortEmbeddedAgentRun,
1212
compactEmbeddedAgentSession,
13+
isEmbeddedAgentRunAbortableForCompaction,
1314
isEmbeddedAgentRunActive,
15+
isEmbeddedAgentRunHandleActive,
1416
isEmbeddedAgentRunStreaming,
1517
queueEmbeddedAgentMessage,
1618
queueEmbeddedAgentMessageWithOutcome,

src/auto-reply/reply/commands-compact.runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
export {
33
abortEmbeddedAgentRun,
44
compactEmbeddedAgentSession,
5-
isEmbeddedAgentRunActive,
5+
isEmbeddedAgentRunAbortableForCompaction,
66
waitForEmbeddedAgentRunEnd,
77
} from "../../agents/embedded-agent.js";
88
export {

src/auto-reply/reply/commands-compact.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,21 @@ vi.mock("./commands-compact.runtime.js", () => ({
1414
formatContextUsageShort: vi.fn(() => "Context 12.1k"),
1515
formatTokenCount: vi.fn((value: number) => `${value}`),
1616
incrementCompactionCount: vi.fn(),
17-
isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false),
17+
isEmbeddedAgentRunAbortableForCompaction: vi.fn().mockReturnValue(false),
1818
resolveFreshSessionTotalTokens: vi.fn(() => 12_345),
1919
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
2020
resolveSessionFilePathOptions: vi.fn(() => ({})),
2121
waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(undefined),
2222
}));
2323

2424
const {
25+
abortEmbeddedAgentRun,
2526
compactEmbeddedAgentSession,
2627
formatContextUsageShort,
2728
incrementCompactionCount,
29+
isEmbeddedAgentRunAbortableForCompaction,
2830
resolveSessionFilePathOptions,
31+
waitForEmbeddedAgentRunEnd,
2932
} = await import("./commands-compact.runtime.js");
3033
const { handleCompactCommand } = await import("./commands-compact.js");
3134

@@ -191,6 +194,62 @@ describe("handleCompactCommand", () => {
191194
expect(call.senderE164).toBe("+15551234567");
192195
expect(call.agentDir).toBe("/tmp/openclaw-agent-compact");
193196
expect(call.authProfileId).toBe("github-copilot:work");
197+
expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled();
198+
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled();
199+
});
200+
201+
it("does not abort the command reply run before compacting", async () => {
202+
vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(false);
203+
vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({
204+
ok: true,
205+
compacted: false,
206+
});
207+
208+
const result = await handleCompactCommand(
209+
{
210+
...buildCompactParams("/compact", {
211+
commands: { text: true },
212+
channels: { whatsapp: { allowFrom: ["*"] } },
213+
} as OpenClawConfig),
214+
sessionEntry: {
215+
sessionId: "session-1",
216+
updatedAt: Date.now(),
217+
},
218+
} as HandleCommandsParams,
219+
true,
220+
);
221+
222+
expect(result?.shouldContinue).toBe(false);
223+
expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).toHaveBeenCalledWith("session-1");
224+
expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled();
225+
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled();
226+
expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce();
227+
});
228+
229+
it("aborts an active embedded run before compacting", async () => {
230+
vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true);
231+
vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({
232+
ok: true,
233+
compacted: false,
234+
});
235+
236+
await handleCompactCommand(
237+
{
238+
...buildCompactParams("/compact", {
239+
commands: { text: true },
240+
channels: { whatsapp: { allowFrom: ["*"] } },
241+
} as OpenClawConfig),
242+
sessionEntry: {
243+
sessionId: "session-1",
244+
updatedAt: Date.now(),
245+
},
246+
} as HandleCommandsParams,
247+
true,
248+
);
249+
250+
expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1");
251+
expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000);
252+
expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce();
194253
});
195254

196255
it("treats already-under-target manual compaction as skipped", async () => {

src/auto-reply/reply/commands-compact.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
217217
}
218218
const runtime = await loadCompactRuntime();
219219
const sessionId = targetSessionEntry.sessionId;
220-
if (runtime.isEmbeddedAgentRunActive(sessionId)) {
220+
if (runtime.isEmbeddedAgentRunAbortableForCompaction(sessionId)) {
221221
runtime.abortEmbeddedAgentRun(sessionId);
222222
await runtime.waitForEmbeddedAgentRunEnd(sessionId, 15_000);
223223
}

src/auto-reply/reply/reply-run-registry.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createReplyOperation,
1212
forceClearReplyRunBySessionId,
1313
isReplyRunActiveForSessionId,
14+
isReplyRunAbortableForCompaction,
1415
queueReplyRunMessage,
1516
replyRunRegistry,
1617
resolveActiveReplyRunSessionId,
@@ -58,6 +59,21 @@ describe("reply run registry", () => {
5859
}
5960
});
6061

62+
it("treats queued reply operations as non-abortable for compaction", () => {
63+
const operation = createReplyOperation({
64+
sessionKey: "agent:main:main",
65+
sessionId: "session-compact",
66+
resetTriggered: false,
67+
});
68+
69+
expect(isReplyRunActiveForSessionId("session-compact")).toBe(true);
70+
expect(isReplyRunAbortableForCompaction("session-compact")).toBe(false);
71+
72+
operation.setPhase("running");
73+
74+
expect(isReplyRunAbortableForCompaction("session-compact")).toBe(true);
75+
});
76+
6177
it("mirrors active reply operations into diagnostic work state", () => {
6278
const operation = createReplyOperation({
6379
sessionKey: "agent:main:telegram:direct:chat-1",

src/auto-reply/reply/reply-run-registry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@ export function isReplyRunActiveForSessionId(sessionId: string): boolean {
518518
return resolveReplyRunForCurrentSessionId(sessionId) !== undefined;
519519
}
520520

521+
export function isReplyRunAbortableForCompaction(sessionId: string): boolean {
522+
const operation = resolveReplyRunForCurrentSessionId(sessionId);
523+
return Boolean(operation && operation.phase !== "queued");
524+
}
525+
521526
export function isReplyRunStreamingForSessionId(sessionId: string): boolean {
522527
const operation = resolveReplyRunForCurrentSessionId(sessionId);
523528
if (!operation || operation.phase !== "running") {

src/gateway/server-methods/chat.directive-tags.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const mockState = vi.hoisted(() => ({
5757
replyToId?: string;
5858
replyToCurrent?: boolean;
5959
isReasoning?: boolean;
60+
isStatusNotice?: boolean;
6061
isError?: boolean;
6162
};
6263
}>,
@@ -1490,6 +1491,38 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
14901491
]);
14911492
});
14921493

1494+
it("broadcasts agent-run status notices without source reply mirrors", async () => {
1495+
createTranscriptFixture("openclaw-chat-send-agent-status-notice-");
1496+
mockState.triggerAgentRunStart = true;
1497+
mockState.dispatchedReplies = [
1498+
{
1499+
kind: "final",
1500+
payload: {
1501+
text: "⚙️ Codex compaction started • Context 2k/200k",
1502+
isStatusNotice: true,
1503+
},
1504+
},
1505+
];
1506+
const respond = vi.fn();
1507+
const context = createChatContext();
1508+
1509+
const broadcast = await runNonStreamingChatSend({
1510+
context,
1511+
respond,
1512+
idempotencyKey: "idem-agent-status-notice",
1513+
message: "/compact",
1514+
});
1515+
1516+
expect(broadcast).toMatchObject({
1517+
runId: "idem-agent-status-notice",
1518+
sessionKey: "main",
1519+
state: "final",
1520+
});
1521+
expect(extractFirstTextBlock(broadcast)).toBe("⚙️ Codex compaction started • Context 2k/200k");
1522+
const assistantEntries = await readActiveAssistantTranscriptMessages();
1523+
expect(assistantEntries).toStrictEqual([]);
1524+
});
1525+
14931526
it("does not duplicate media-bearing internal-ui source replies in the transcript", async () => {
14941527
await withTranscriptFixtureState(
14951528
"openclaw-chat-send-agent-source-reply-media-",
@@ -2175,6 +2208,48 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
21752208
});
21762209
});
21772210

2211+
it("broadcasts returned agent errors after status notices", async () => {
2212+
createTranscriptFixture("openclaw-chat-send-agent-status-notice-error-");
2213+
const errorMessage = "LLM idle timeout (120s): no response from model";
2214+
mockState.triggerAgentRunStart = true;
2215+
mockState.dispatchedReplies = [
2216+
{
2217+
kind: "final",
2218+
payload: {
2219+
text: "⚙️ Codex compaction started • Context 2k/200k",
2220+
isStatusNotice: true,
2221+
},
2222+
},
2223+
{
2224+
kind: "final",
2225+
payload: {
2226+
text: errorMessage,
2227+
isError: true,
2228+
},
2229+
},
2230+
];
2231+
const respond = vi.fn();
2232+
const context = createChatContext();
2233+
2234+
const broadcast = await runNonStreamingChatSend({
2235+
context,
2236+
respond,
2237+
idempotencyKey: "idem-agent-status-notice-error",
2238+
message: "/compact",
2239+
});
2240+
2241+
expect(broadcast).toMatchObject({
2242+
runId: "idem-agent-status-notice-error",
2243+
sessionKey: "main",
2244+
state: "error",
2245+
errorMessage,
2246+
});
2247+
const finalBroadcasts = (
2248+
context.broadcast as unknown as ReturnType<typeof vi.fn>
2249+
).mock.calls.filter(([, payload]) => (payload as { state?: unknown })?.state === "final");
2250+
expect(finalBroadcasts).toStrictEqual([]);
2251+
});
2252+
21782253
it("broadcasts returned agent-run error payloads after an agent starts", async () => {
21792254
createTranscriptFixture("openclaw-chat-send-agent-returned-error-");
21802255
const errorMessage = "LLM idle timeout (120s): no response from model";

0 commit comments

Comments
 (0)