Skip to content

Commit a40c29b

Browse files
authored
Fix cron text announce delivery for Telegram targets (#40575)
Merged via squash. Prepared head SHA: 54b1513 Co-authored-by: obviyus <[email protected]> Co-authored-by: obviyus <[email protected]> Reviewed-by: @obviyus
1 parent d4a960f commit a40c29b

9 files changed

+513
-419
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
5656
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
5757
- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
58+
- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus.
5859

5960
## 2026.3.7
6061

src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts

Lines changed: 31 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j
44
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
55
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
66
import type { CliDeps } from "../cli/deps.js";
7+
import { callGateway } from "../gateway/call.js";
78
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
89
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
910
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
@@ -137,7 +138,7 @@ describe("runCronIsolatedAgentTurn", () => {
137138
});
138139
});
139140

140-
it("handles media heartbeat delivery and announce cleanup modes", async () => {
141+
it("handles media heartbeat delivery and last-target text delivery", async () => {
141142
await withTempHome(async (home) => {
142143
const { storePath, deps } = await createTelegramDeliveryFixture(home);
143144

@@ -185,14 +186,18 @@ describe("runCronIsolatedAgentTurn", () => {
185186
});
186187

187188
expect(keepRes.status).toBe("ok");
188-
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
189-
const keepArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
190-
| { cleanup?: "keep" | "delete" }
191-
| undefined;
192-
expect(keepArgs?.cleanup).toBe("keep");
193-
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
189+
expect(keepRes.delivered).toBe(true);
190+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
191+
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
192+
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
193+
"123",
194+
"HEARTBEAT_OK 🦞",
195+
expect.objectContaining({ accountId: undefined }),
196+
);
194197

198+
vi.mocked(deps.sendMessageTelegram).mockClear();
195199
vi.mocked(runSubagentAnnounceFlow).mockClear();
200+
vi.mocked(callGateway).mockClear();
196201

197202
const deleteRes = await runCronIsolatedAgentTurn({
198203
cfg,
@@ -211,12 +216,25 @@ describe("runCronIsolatedAgentTurn", () => {
211216
});
212217

213218
expect(deleteRes.status).toBe("ok");
214-
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
215-
const deleteArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
216-
| { cleanup?: "keep" | "delete" }
217-
| undefined;
218-
expect(deleteArgs?.cleanup).toBe("delete");
219-
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
219+
expect(deleteRes.delivered).toBe(true);
220+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
221+
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
222+
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
223+
"123",
224+
"HEARTBEAT_OK 🦞",
225+
expect.objectContaining({ accountId: undefined }),
226+
);
227+
expect(callGateway).toHaveBeenCalledTimes(1);
228+
expect(callGateway).toHaveBeenCalledWith(
229+
expect.objectContaining({
230+
method: "sessions.delete",
231+
params: expect.objectContaining({
232+
key: "agent:main:cron:job-1",
233+
deleteTranscript: true,
234+
emitLifecycleHooks: false,
235+
}),
236+
}),
237+
);
220238
});
221239
});
222240

@@ -243,70 +261,4 @@ describe("runCronIsolatedAgentTurn", () => {
243261
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
244262
});
245263
});
246-
247-
it("uses a unique announce childRunId for each cron run", async () => {
248-
await withTempHome(async (home) => {
249-
const storePath = await writeSessionStore(home, {
250-
lastProvider: "telegram",
251-
lastChannel: "telegram",
252-
lastTo: "123",
253-
});
254-
const deps: CliDeps = {
255-
sendMessageSlack: vi.fn(),
256-
sendMessageWhatsApp: vi.fn(),
257-
sendMessageTelegram: vi.fn(),
258-
sendMessageDiscord: vi.fn(),
259-
sendMessageSignal: vi.fn(),
260-
sendMessageIMessage: vi.fn(),
261-
};
262-
263-
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
264-
payloads: [{ text: "final summary" }],
265-
meta: {
266-
durationMs: 5,
267-
agentMeta: { sessionId: "s", provider: "p", model: "m" },
268-
},
269-
});
270-
271-
const cfg = makeCfg(home, storePath);
272-
const job = makeJob({ kind: "agentTurn", message: "do it" });
273-
job.delivery = { mode: "announce", channel: "last" };
274-
275-
const nowSpy = vi.spyOn(Date, "now");
276-
let now = Date.now();
277-
nowSpy.mockImplementation(() => now);
278-
try {
279-
await runCronIsolatedAgentTurn({
280-
cfg,
281-
deps,
282-
job,
283-
message: "do it",
284-
sessionKey: "cron:job-1",
285-
lane: "cron",
286-
});
287-
now += 5;
288-
await runCronIsolatedAgentTurn({
289-
cfg,
290-
deps,
291-
job,
292-
message: "do it",
293-
sessionKey: "cron:job-1",
294-
lane: "cron",
295-
});
296-
} finally {
297-
nowSpy.mockRestore();
298-
}
299-
300-
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(2);
301-
const firstArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
302-
| { childRunId?: string }
303-
| undefined;
304-
const secondArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[1]?.[0] as
305-
| { childRunId?: string }
306-
| undefined;
307-
expect(firstArgs?.childRunId).toBeTruthy();
308-
expect(secondArgs?.childRunId).toBeTruthy();
309-
expect(secondArgs?.childRunId).not.toBe(firstArgs?.childRunId);
310-
});
311-
});
312264
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import "./isolated-agent.mocks.js";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
4+
import { discordOutbound } from "../channels/plugins/outbound/discord.js";
5+
import { imessageOutbound } from "../channels/plugins/outbound/imessage.js";
6+
import { signalOutbound } from "../channels/plugins/outbound/signal.js";
7+
import { slackOutbound } from "../channels/plugins/outbound/slack.js";
8+
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
9+
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
10+
import type { CliDeps } from "../cli/deps.js";
11+
import { setActivePluginRegistry } from "../plugins/runtime.js";
12+
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
13+
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
14+
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
15+
import {
16+
makeCfg,
17+
makeJob,
18+
withTempCronHome,
19+
writeSessionStore,
20+
} from "./isolated-agent.test-harness.js";
21+
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
22+
23+
type ChannelCase = {
24+
name: string;
25+
channel: "slack" | "discord" | "whatsapp" | "imessage";
26+
to: string;
27+
sendKey: keyof Pick<
28+
CliDeps,
29+
"sendMessageSlack" | "sendMessageDiscord" | "sendMessageWhatsApp" | "sendMessageIMessage"
30+
>;
31+
expectedTo: string;
32+
};
33+
34+
const CASES: ChannelCase[] = [
35+
{
36+
name: "Slack",
37+
channel: "slack",
38+
to: "channel:C12345",
39+
sendKey: "sendMessageSlack",
40+
expectedTo: "channel:C12345",
41+
},
42+
{
43+
name: "Discord",
44+
channel: "discord",
45+
to: "channel:789",
46+
sendKey: "sendMessageDiscord",
47+
expectedTo: "channel:789",
48+
},
49+
{
50+
name: "WhatsApp",
51+
channel: "whatsapp",
52+
to: "+15551234567",
53+
sendKey: "sendMessageWhatsApp",
54+
expectedTo: "+15551234567",
55+
},
56+
{
57+
name: "iMessage",
58+
channel: "imessage",
59+
60+
sendKey: "sendMessageIMessage",
61+
expectedTo: "[email protected]",
62+
},
63+
];
64+
65+
async function runExplicitAnnounceTurn(params: {
66+
home: string;
67+
storePath: string;
68+
deps: CliDeps;
69+
channel: ChannelCase["channel"];
70+
to: string;
71+
}) {
72+
return await runCronIsolatedAgentTurn({
73+
cfg: makeCfg(params.home, params.storePath),
74+
deps: params.deps,
75+
job: {
76+
...makeJob({ kind: "agentTurn", message: "do it" }),
77+
delivery: {
78+
mode: "announce",
79+
channel: params.channel,
80+
to: params.to,
81+
},
82+
},
83+
message: "do it",
84+
sessionKey: "cron:job-1",
85+
lane: "cron",
86+
});
87+
}
88+
89+
describe("runCronIsolatedAgentTurn core-channel direct delivery", () => {
90+
beforeEach(() => {
91+
setupIsolatedAgentTurnMocks();
92+
setActivePluginRegistry(
93+
createTestRegistry([
94+
{
95+
pluginId: "telegram",
96+
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
97+
source: "test",
98+
},
99+
{
100+
pluginId: "signal",
101+
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }),
102+
source: "test",
103+
},
104+
{
105+
pluginId: "slack",
106+
plugin: createOutboundTestPlugin({ id: "slack", outbound: slackOutbound }),
107+
source: "test",
108+
},
109+
{
110+
pluginId: "discord",
111+
plugin: createOutboundTestPlugin({ id: "discord", outbound: discordOutbound }),
112+
source: "test",
113+
},
114+
{
115+
pluginId: "whatsapp",
116+
plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }),
117+
source: "test",
118+
},
119+
{
120+
pluginId: "imessage",
121+
plugin: createOutboundTestPlugin({ id: "imessage", outbound: imessageOutbound }),
122+
source: "test",
123+
},
124+
]),
125+
);
126+
});
127+
128+
for (const testCase of CASES) {
129+
it(`routes ${testCase.name} text-only announce delivery through the outbound adapter`, async () => {
130+
await withTempCronHome(async (home) => {
131+
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
132+
const deps = createCliDeps();
133+
mockAgentPayloads([{ text: "hello from cron" }]);
134+
135+
const res = await runExplicitAnnounceTurn({
136+
home,
137+
storePath,
138+
deps,
139+
channel: testCase.channel,
140+
to: testCase.to,
141+
});
142+
143+
expect(res.status).toBe("ok");
144+
expect(res.delivered).toBe(true);
145+
expect(res.deliveryAttempted).toBe(true);
146+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
147+
148+
const sendFn = deps[testCase.sendKey];
149+
expect(sendFn).toHaveBeenCalledTimes(1);
150+
expect(sendFn).toHaveBeenCalledWith(
151+
testCase.expectedTo,
152+
"hello from cron",
153+
expect.any(Object),
154+
);
155+
});
156+
});
157+
}
158+
});

src/cron/isolated-agent.direct-delivery-forum-topics.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
4848
});
4949

5050
expect(plainRes.status).toBe("ok");
51-
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
52-
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
53-
| { expectsCompletionMessage?: boolean }
54-
| undefined;
55-
expect(announceArgs?.expectsCompletionMessage).toBe(true);
56-
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
51+
expect(plainRes.delivered).toBe(true);
52+
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
53+
expectDirectTelegramDelivery(deps, {
54+
chatId: "123",
55+
text: "plain message",
56+
});
5757
});
5858
});
5959
});

src/cron/isolated-agent.mocks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,9 @@ vi.mock("../agents/subagent-announce.js", () => ({
2626
runSubagentAnnounceFlow: vi.fn(),
2727
}));
2828

29+
vi.mock("../gateway/call.js", () => ({
30+
callGateway: vi.fn(),
31+
}));
32+
2933
export const makeIsolatedAgentJob = makeIsolatedAgentJobFixture;
3034
export const makeIsolatedAgentParams = makeIsolatedAgentParamsFixture;

0 commit comments

Comments
 (0)