Skip to content

Commit 89dccc7

Browse files
cron: infer payload kind for model-only update patches (#15664) thanks @rodrigouroz
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check (fails on current origin/main in src/memory/embedding-manager.test-harness.ts; unchanged by this PR) Co-authored-by: rodrigouroz <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 3c97ec7 commit 89dccc7

6 files changed

Lines changed: 135 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
2424
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
2525
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
26+
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
2627

2728
## 2026.2.14
2829

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { handleToolExecutionStart } from "./pi-embedded-subscribe.handlers.tools.js";
3+
4+
function createTestContext() {
5+
const onBlockReplyFlush = vi.fn();
6+
const warn = vi.fn();
7+
const ctx = {
8+
params: {
9+
runId: "run-test",
10+
onBlockReplyFlush,
11+
onAgentEvent: undefined,
12+
onToolResult: undefined,
13+
},
14+
flushBlockReplyBuffer: vi.fn(),
15+
hookRunner: undefined,
16+
log: {
17+
debug: vi.fn(),
18+
warn,
19+
},
20+
state: {
21+
toolMetaById: new Map<string, string | undefined>(),
22+
toolSummaryById: new Set<string>(),
23+
pendingMessagingTargets: new Map<string, unknown>(),
24+
pendingMessagingTexts: new Map<string, string>(),
25+
messagingToolSentTexts: [],
26+
messagingToolSentTextsNormalized: [],
27+
messagingToolSentTargets: [],
28+
},
29+
shouldEmitToolResult: () => false,
30+
emitToolSummary: vi.fn(),
31+
trimMessagingToolSent: vi.fn(),
32+
} as const;
33+
34+
return { ctx, warn, onBlockReplyFlush };
35+
}
36+
37+
describe("handleToolExecutionStart read path checks", () => {
38+
it("does not warn when read tool uses file_path alias", async () => {
39+
const { ctx, warn, onBlockReplyFlush } = createTestContext();
40+
41+
await handleToolExecutionStart(
42+
ctx as never,
43+
{
44+
type: "tool_execution_start",
45+
toolName: "read",
46+
toolCallId: "tool-1",
47+
args: { file_path: "/tmp/example.txt" },
48+
} as never,
49+
);
50+
51+
expect(onBlockReplyFlush).toHaveBeenCalledTimes(1);
52+
expect(warn).not.toHaveBeenCalled();
53+
});
54+
55+
it("warns when read tool has neither path nor file_path", async () => {
56+
const { ctx, warn } = createTestContext();
57+
58+
await handleToolExecutionStart(
59+
ctx as never,
60+
{
61+
type: "tool_execution_start",
62+
toolName: "read",
63+
toolCallId: "tool-2",
64+
args: {},
65+
} as never,
66+
);
67+
68+
expect(warn).toHaveBeenCalledTimes(1);
69+
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path");
70+
});
71+
});

src/agents/pi-embedded-subscribe.handlers.tools.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,13 @@ export async function handleToolExecutionStart(
7575

7676
if (toolName === "read") {
7777
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
78-
const filePath =
78+
const filePathValue =
7979
typeof record.path === "string"
80-
? record.path.trim()
80+
? record.path
8181
: typeof record.file_path === "string"
82-
? record.file_path.trim()
82+
? record.file_path
8383
: "";
84+
const filePath = filePathValue.trim();
8485
if (!filePath) {
8586
const argsPreview = typeof args === "string" ? args.slice(0, 200) : undefined;
8687
ctx.log.warn(

src/cron/normalize.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { normalizeCronJobCreate } from "./normalize.js";
2+
import { normalizeCronJobCreate, normalizeCronJobPatch } from "./normalize.js";
33

44
describe("normalizeCronJobCreate", () => {
55
it("maps legacy payload.provider to payload.channel and strips provider", () => {
@@ -293,3 +293,31 @@ describe("normalizeCronJobCreate", () => {
293293
expect(delivery.to).toBe("123");
294294
});
295295
});
296+
297+
describe("normalizeCronJobPatch", () => {
298+
it("infers agentTurn kind for model-only payload patches", () => {
299+
const normalized = normalizeCronJobPatch({
300+
payload: {
301+
model: "anthropic/claude-sonnet-4-5",
302+
},
303+
}) as unknown as Record<string, unknown>;
304+
305+
const payload = normalized.payload as Record<string, unknown>;
306+
expect(payload.kind).toBe("agentTurn");
307+
expect(payload.model).toBe("anthropic/claude-sonnet-4-5");
308+
});
309+
310+
it("does not infer agentTurn kind for delivery-only legacy hints", () => {
311+
const normalized = normalizeCronJobPatch({
312+
payload: {
313+
channel: "telegram",
314+
to: "+15550001111",
315+
},
316+
}) as unknown as Record<string, unknown>;
317+
318+
const payload = normalized.payload as Record<string, unknown>;
319+
expect(payload.kind).toBeUndefined();
320+
expect(payload.channel).toBe("telegram");
321+
expect(payload.to).toBe("+15550001111");
322+
});
323+
});

src/cron/normalize.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,18 @@ function coercePayload(payload: UnknownRecord) {
7474
if (!next.kind) {
7575
const hasMessage = typeof next.message === "string" && next.message.trim().length > 0;
7676
const hasText = typeof next.text === "string" && next.text.trim().length > 0;
77+
const hasAgentTurnHint =
78+
typeof next.model === "string" ||
79+
typeof next.thinking === "string" ||
80+
typeof next.timeoutSeconds === "number" ||
81+
typeof next.allowUnsafeExternalContent === "boolean";
7782
if (hasMessage) {
7883
next.kind = "agentTurn";
7984
} else if (hasText) {
8085
next.kind = "systemEvent";
86+
} else if (hasAgentTurnHint) {
87+
// Accept partial agentTurn payload patches that only tweak agent-turn-only fields.
88+
next.kind = "agentTurn";
8189
}
8290
}
8391
if (typeof next.message === "string") {

src/gateway/server.cron.e2e.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ describe("gateway server cron", () => {
181181
expect(merged?.delivery?.channel).toBe("telegram");
182182
expect(merged?.delivery?.to).toBe("19098680");
183183

184+
const modelOnlyPatchRes = await rpcReq(ws, "cron.update", {
185+
id: mergeJobId,
186+
patch: {
187+
payload: {
188+
model: "anthropic/claude-sonnet-4-5",
189+
},
190+
},
191+
});
192+
expect(modelOnlyPatchRes.ok).toBe(true);
193+
const modelOnlyPatched = modelOnlyPatchRes.payload as
194+
| {
195+
payload?: {
196+
kind?: unknown;
197+
message?: unknown;
198+
model?: unknown;
199+
};
200+
}
201+
| undefined;
202+
expect(modelOnlyPatched?.payload?.kind).toBe("agentTurn");
203+
expect(modelOnlyPatched?.payload?.message).toBe("hello");
204+
expect(modelOnlyPatched?.payload?.model).toBe("anthropic/claude-sonnet-4-5");
205+
184206
const legacyDeliveryPatchRes = await rpcReq(ws, "cron.update", {
185207
id: mergeJobId,
186208
patch: {

0 commit comments

Comments
 (0)