Skip to content

Commit 7d9a9d8

Browse files
authored
fix: preserve isolated message targets (#69153)
* test(cron): cover delivery target context for mode none * fix(cron): preserve target context for delivery mode none * test(cron): cover isolated message target forwarding * fix(cron): forward isolated message targets into embedded runs * fix(cron): ignore implicit last-target context for mode none * fix(cron): keep mode none channel explicit only * test(cron): fix isolated target test typing * fix: preserve isolated message targets (#69153) * fix: preserve isolated message targets (#69153)
1 parent d5b3265 commit 7d9a9d8

6 files changed

Lines changed: 260 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828
- Matrix/commands: recognize slash commands that are prefixed with the bot's Matrix mention, so room messages like `@bot:server /new` trigger the command path without requiring custom mention regexes. (#68570) Thanks @nightq and @johnlanni.
2929
- Agents/subagents: include requested role and runtime timing on subagent failure payloads so parent agents can correlate failed or timed-out child work. (#68726) Thanks @BKF-Gitty.
3030
- Gateway/sessions: reject stale agent-scoped sessions after an agent is removed from config while preserving legacy default-agent main-session aliases. (#65986) Thanks @bittoby.
31+
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @obviyus.
3132

3233
## 2026.4.19-beta.2
3334

src/cron/delivery-plan.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCronDeliveryPlan } from "./delivery-plan.js";
3+
import type { CronJob } from "./types.js";
4+
5+
function makeJob(overrides: Partial<CronJob>): CronJob {
6+
const now = Date.now();
7+
return {
8+
id: "job-1",
9+
name: "test",
10+
enabled: true,
11+
createdAtMs: now,
12+
updatedAtMs: now,
13+
schedule: { kind: "every", everyMs: 60_000 },
14+
sessionTarget: "isolated",
15+
wakeMode: "next-heartbeat",
16+
payload: { kind: "agentTurn", message: "hello" },
17+
state: {},
18+
...overrides,
19+
};
20+
}
21+
22+
describe("resolveCronDeliveryPlan", () => {
23+
it("preserves explicit message target context for delivery.mode=none", () => {
24+
const plan = resolveCronDeliveryPlan(
25+
makeJob({
26+
name: "Cron Target Context",
27+
payload: { kind: "agentTurn", message: "send a message" },
28+
delivery: {
29+
mode: "none",
30+
channel: "telegram",
31+
to: "123:topic:42",
32+
threadId: 42,
33+
accountId: "ops",
34+
},
35+
}),
36+
);
37+
38+
expect(plan).toEqual({
39+
mode: "none",
40+
channel: "telegram",
41+
to: "123:topic:42",
42+
threadId: 42,
43+
accountId: "ops",
44+
source: "delivery",
45+
requested: false,
46+
});
47+
});
48+
});

src/cron/delivery-plan.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,18 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
5050
const deliveryThreadId = normalizeOptionalThreadValue(
5151
(delivery as { threadId?: unknown } | undefined)?.threadId,
5252
);
53-
const channel = deliveryChannel ?? "last";
5453
const to = deliveryTo;
5554
const deliveryAccountId = normalizeOptionalString(
5655
(delivery as { accountId?: unknown } | undefined)?.accountId,
5756
);
5857
if (hasDelivery) {
5958
const resolvedMode = mode ?? "announce";
59+
const channel = resolvedMode === "announce" ? (deliveryChannel ?? "last") : deliveryChannel;
6060
return {
6161
mode: resolvedMode,
62-
channel: resolvedMode === "announce" ? channel : undefined,
62+
channel: resolvedMode === "webhook" ? undefined : channel,
6363
to,
64-
threadId: resolvedMode === "announce" ? deliveryThreadId : undefined,
64+
threadId: resolvedMode === "webhook" ? undefined : deliveryThreadId,
6565
accountId: deliveryAccountId,
6666
source: "delivery",
6767
requested: resolvedMode === "announce",

src/cron/isolated-agent/run-executor.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ type CronSubagentRegistryRuntime = typeof import("./run-subagent-registry.runtim
3333
let cronEmbeddedRuntimePromise: Promise<CronEmbeddedRuntime> | undefined;
3434
let cronSubagentRegistryRuntimePromise: Promise<CronSubagentRegistryRuntime> | undefined;
3535

36+
function resolveCurrentChannelTarget(params: {
37+
channel?: string;
38+
to?: string;
39+
threadId?: string | number;
40+
}): string | undefined {
41+
if (!params.to) {
42+
return undefined;
43+
}
44+
if (params.channel !== "telegram" || params.threadId == null) {
45+
return params.to;
46+
}
47+
return params.to.includes(":topic:") ? params.to : `${params.to}:topic:${params.threadId}`;
48+
}
49+
3650
async function loadCronEmbeddedRuntime() {
3751
cronEmbeddedRuntimePromise ??= import("./run-embedded.runtime.js");
3852
return await cronEmbeddedRuntimePromise;
@@ -65,7 +79,11 @@ export function createCronPromptExecutor(params: {
6579
thinkLevel: ThinkLevel | undefined;
6680
timeoutMs: number;
6781
messageChannel: string | undefined;
68-
resolvedDelivery: { accountId?: string };
82+
resolvedDelivery: {
83+
accountId?: string;
84+
to?: string;
85+
threadId?: string | number;
86+
};
6987
toolPolicy: {
7088
requireExplicitMessageTarget: boolean;
7189
disableMessageTool: boolean;
@@ -150,6 +168,13 @@ export function createCronPromptExecutor(params: {
150168
senderIsOwner: false,
151169
messageChannel: params.messageChannel,
152170
agentAccountId: params.resolvedDelivery.accountId,
171+
messageTo: params.resolvedDelivery.to,
172+
messageThreadId: params.resolvedDelivery.threadId,
173+
currentChannelId: resolveCurrentChannelTarget({
174+
channel: params.messageChannel,
175+
to: params.resolvedDelivery.to,
176+
threadId: params.resolvedDelivery.threadId,
177+
}),
153178
sessionFile,
154179
agentDir: params.agentDir,
155180
workspaceDir: params.workspaceDir,
@@ -222,6 +247,8 @@ export async function executeCronRun(params: {
222247
resolvedDelivery: {
223248
channel?: string;
224249
accountId?: string;
250+
to?: string;
251+
threadId?: string | number;
225252
};
226253
toolPolicy: {
227254
requireExplicitMessageTarget: boolean;

src/cron/isolated-agent/run.message-tool-policy.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import type { SkillSnapshot } from "../../agents/skills.js";
23
import type { CronDeliveryMode } from "../types.js";
4+
import type { MutableCronSession } from "./run-session-state.js";
35
import {
46
clearFastTestEnv,
57
dispatchCronDeliveryMock,
68
isHeartbeatOnlyResponseMock,
79
loadRunCronIsolatedAgentTurn,
10+
makeCronSession,
811
mockRunCronFallbackPassthrough,
912
resetRunCronIsolatedAgentTurnHarness,
1013
resolveCronDeliveryPlanMock,
@@ -14,6 +17,7 @@ import {
1417
} from "./run.test-harness.js";
1518

1619
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
20+
const { createCronPromptExecutor } = await import("./run-executor.js");
1721

1822
function makeParams() {
1923
return {
@@ -73,6 +77,13 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
7377
});
7478
});
7579

80+
const emptySkillsSnapshot: SkillSnapshot = {
81+
prompt: "",
82+
skills: [],
83+
resolvedSkills: [],
84+
version: 1,
85+
};
86+
7687
afterEach(() => {
7788
restoreFastTestEnv(previousFastTestEnv);
7889
});
@@ -84,6 +95,167 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
8495
});
8596
});
8697

98+
it("preserves explicit delivery targets for agent-initiated messaging when delivery.mode is none", async () => {
99+
mockRunCronFallbackPassthrough();
100+
resolveCronDeliveryPlanMock.mockReturnValue({
101+
requested: false,
102+
mode: "none",
103+
channel: "telegram",
104+
to: "123:topic:42",
105+
threadId: 42,
106+
});
107+
resolveDeliveryTargetMock.mockResolvedValue({
108+
ok: true,
109+
channel: "telegram",
110+
to: "123:topic:42",
111+
threadId: 42,
112+
accountId: undefined,
113+
error: undefined,
114+
});
115+
116+
await runCronIsolatedAgentTurn({
117+
...makeParams(),
118+
job: {
119+
id: "message-tool-policy",
120+
name: "Message Tool Policy",
121+
schedule: { kind: "every", everyMs: 60_000 },
122+
sessionTarget: "isolated",
123+
payload: { kind: "agentTurn", message: "send a message" },
124+
delivery: { mode: "none", channel: "telegram", to: "123:topic:42", threadId: 42 },
125+
} as never,
126+
});
127+
128+
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
129+
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
130+
disableMessageTool: false,
131+
messageChannel: "telegram",
132+
messageTo: "123:topic:42",
133+
messageThreadId: 42,
134+
currentChannelId: "123:topic:42",
135+
});
136+
});
137+
138+
it("does not resolve implicit last-target context for bare delivery.mode none", async () => {
139+
mockRunCronFallbackPassthrough();
140+
resolveCronDeliveryPlanMock.mockReturnValue({
141+
requested: false,
142+
mode: "none",
143+
channel: "last",
144+
});
145+
146+
await runCronIsolatedAgentTurn({
147+
...makeParams(),
148+
job: {
149+
id: "message-tool-policy",
150+
name: "Message Tool Policy",
151+
schedule: { kind: "every", everyMs: 60_000 },
152+
sessionTarget: "isolated",
153+
payload: { kind: "agentTurn", message: "send a message" },
154+
delivery: { mode: "none" },
155+
} as never,
156+
});
157+
158+
expect(resolveDeliveryTargetMock).not.toHaveBeenCalled();
159+
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
160+
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
161+
disableMessageTool: false,
162+
messageChannel: undefined,
163+
messageTo: undefined,
164+
messageThreadId: undefined,
165+
currentChannelId: undefined,
166+
});
167+
});
168+
169+
it("forwards explicit message targets into the embedded run", async () => {
170+
mockRunCronFallbackPassthrough();
171+
const executor = createCronPromptExecutor({
172+
cfg: {},
173+
cfgWithAgentDefaults: {},
174+
job: makeParams().job,
175+
agentId: "default",
176+
agentDir: "/tmp/agent-dir",
177+
agentSessionKey: "cron:message-tool-policy",
178+
workspaceDir: "/tmp/workspace",
179+
resolvedVerboseLevel: "off",
180+
thinkLevel: undefined,
181+
timeoutMs: 60_000,
182+
messageChannel: "telegram",
183+
resolvedDelivery: {
184+
accountId: "ops",
185+
to: "123:topic:42",
186+
threadId: 42,
187+
},
188+
toolPolicy: {
189+
requireExplicitMessageTarget: false,
190+
disableMessageTool: false,
191+
},
192+
skillsSnapshot: emptySkillsSnapshot,
193+
agentPayload: null,
194+
liveSelection: {
195+
provider: "openai",
196+
model: "gpt-5.4",
197+
},
198+
cronSession: makeCronSession() as MutableCronSession,
199+
abortReason: () => "aborted",
200+
});
201+
202+
await executor.runPrompt("send a message");
203+
204+
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
205+
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
206+
messageChannel: "telegram",
207+
agentAccountId: "ops",
208+
messageTo: "123:topic:42",
209+
messageThreadId: 42,
210+
currentChannelId: "123:topic:42",
211+
});
212+
});
213+
214+
it("preserves topic routing when inferred currentChannelId is built from split delivery fields", async () => {
215+
mockRunCronFallbackPassthrough();
216+
const executor = createCronPromptExecutor({
217+
cfg: {},
218+
cfgWithAgentDefaults: {},
219+
job: makeParams().job,
220+
agentId: "default",
221+
agentDir: "/tmp/agent-dir",
222+
agentSessionKey: "cron:message-tool-policy",
223+
workspaceDir: "/tmp/workspace",
224+
resolvedVerboseLevel: "off",
225+
thinkLevel: undefined,
226+
timeoutMs: 60_000,
227+
messageChannel: "telegram",
228+
resolvedDelivery: {
229+
accountId: "ops",
230+
to: "123",
231+
threadId: 42,
232+
},
233+
toolPolicy: {
234+
requireExplicitMessageTarget: false,
235+
disableMessageTool: false,
236+
},
237+
skillsSnapshot: emptySkillsSnapshot,
238+
agentPayload: null,
239+
liveSelection: {
240+
provider: "openai",
241+
model: "gpt-5.4",
242+
},
243+
cronSession: makeCronSession() as MutableCronSession,
244+
abortReason: () => "aborted",
245+
});
246+
247+
await executor.runPrompt("send a message");
248+
249+
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
250+
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
251+
messageChannel: "telegram",
252+
agentAccountId: "ops",
253+
messageTo: "123",
254+
messageThreadId: 42,
255+
currentChannelId: "123:topic:42",
256+
});
257+
});
258+
87259
it("disables the message tool when cron delivery is active", async () => {
88260
await expectMessageToolDisabledForPlan({
89261
requested: true,

src/cron/isolated-agent/run.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ async function resolveCronDeliveryContext(params: {
142142
deliveryContract: IsolatedDeliveryContract;
143143
}) {
144144
const deliveryPlan = resolveCronDeliveryPlan(params.job);
145-
if (!deliveryPlan.requested) {
145+
const hasMessageTargetContext =
146+
deliveryPlan.mode !== "webhook" &&
147+
(deliveryPlan.to !== undefined ||
148+
deliveryPlan.threadId !== undefined ||
149+
deliveryPlan.accountId !== undefined);
150+
if (!deliveryPlan.requested && !hasMessageTargetContext) {
146151
const resolvedDelivery = {
147152
ok: false as const,
148153
channel: undefined,
@@ -746,7 +751,9 @@ export async function runCronIsolatedAgentTurn(params: {
746751
lane: params.lane,
747752
resolvedDelivery: {
748753
channel: prepared.context.resolvedDelivery.channel,
754+
to: prepared.context.resolvedDelivery.to,
749755
accountId: prepared.context.resolvedDelivery.accountId,
756+
threadId: prepared.context.resolvedDelivery.threadId,
750757
},
751758
toolPolicy: prepared.context.toolPolicy,
752759
skillsSnapshot: prepared.context.skillsSnapshot,

0 commit comments

Comments
 (0)