cron: infer payload kind for model-only update patches (openclaw#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 <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Rodrigo Uroz
2026-02-15 12:12:51 -03:00
committed by GitHub
parent 3c97ec70d1
commit 89dccc79a7
6 changed files with 135 additions and 4 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.2.14

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { handleToolExecutionStart } from "./pi-embedded-subscribe.handlers.tools.js";
function createTestContext() {
const onBlockReplyFlush = vi.fn();
const warn = vi.fn();
const ctx = {
params: {
runId: "run-test",
onBlockReplyFlush,
onAgentEvent: undefined,
onToolResult: undefined,
},
flushBlockReplyBuffer: vi.fn(),
hookRunner: undefined,
log: {
debug: vi.fn(),
warn,
},
state: {
toolMetaById: new Map<string, string | undefined>(),
toolSummaryById: new Set<string>(),
pendingMessagingTargets: new Map<string, unknown>(),
pendingMessagingTexts: new Map<string, string>(),
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
messagingToolSentTargets: [],
},
shouldEmitToolResult: () => false,
emitToolSummary: vi.fn(),
trimMessagingToolSent: vi.fn(),
} as const;
return { ctx, warn, onBlockReplyFlush };
}
describe("handleToolExecutionStart read path checks", () => {
it("does not warn when read tool uses file_path alias", async () => {
const { ctx, warn, onBlockReplyFlush } = createTestContext();
await handleToolExecutionStart(
ctx as never,
{
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-1",
args: { file_path: "/tmp/example.txt" },
} as never,
);
expect(onBlockReplyFlush).toHaveBeenCalledTimes(1);
expect(warn).not.toHaveBeenCalled();
});
it("warns when read tool has neither path nor file_path", async () => {
const { ctx, warn } = createTestContext();
await handleToolExecutionStart(
ctx as never,
{
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-2",
args: {},
} as never,
);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path");
});
});

View File

@@ -75,12 +75,13 @@ export async function handleToolExecutionStart(
if (toolName === "read") {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const filePath =
const filePathValue =
typeof record.path === "string"
? record.path.trim()
? record.path
: typeof record.file_path === "string"
? record.file_path.trim()
? record.file_path
: "";
const filePath = filePathValue.trim();
if (!filePath) {
const argsPreview = typeof args === "string" ? args.slice(0, 200) : undefined;
ctx.log.warn(

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeCronJobCreate } from "./normalize.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "./normalize.js";
describe("normalizeCronJobCreate", () => {
it("maps legacy payload.provider to payload.channel and strips provider", () => {
@@ -293,3 +293,31 @@ describe("normalizeCronJobCreate", () => {
expect(delivery.to).toBe("123");
});
});
describe("normalizeCronJobPatch", () => {
it("infers agentTurn kind for model-only payload patches", () => {
const normalized = normalizeCronJobPatch({
payload: {
model: "anthropic/claude-sonnet-4-5",
},
}) as unknown as Record<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.model).toBe("anthropic/claude-sonnet-4-5");
});
it("does not infer agentTurn kind for delivery-only legacy hints", () => {
const normalized = normalizeCronJobPatch({
payload: {
channel: "telegram",
to: "+15550001111",
},
}) as unknown as Record<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
expect(payload.kind).toBeUndefined();
expect(payload.channel).toBe("telegram");
expect(payload.to).toBe("+15550001111");
});
});

View File

@@ -74,10 +74,18 @@ function coercePayload(payload: UnknownRecord) {
if (!next.kind) {
const hasMessage = typeof next.message === "string" && next.message.trim().length > 0;
const hasText = typeof next.text === "string" && next.text.trim().length > 0;
const hasAgentTurnHint =
typeof next.model === "string" ||
typeof next.thinking === "string" ||
typeof next.timeoutSeconds === "number" ||
typeof next.allowUnsafeExternalContent === "boolean";
if (hasMessage) {
next.kind = "agentTurn";
} else if (hasText) {
next.kind = "systemEvent";
} else if (hasAgentTurnHint) {
// Accept partial agentTurn payload patches that only tweak agent-turn-only fields.
next.kind = "agentTurn";
}
}
if (typeof next.message === "string") {

View File

@@ -181,6 +181,28 @@ describe("gateway server cron", () => {
expect(merged?.delivery?.channel).toBe("telegram");
expect(merged?.delivery?.to).toBe("19098680");
const modelOnlyPatchRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {
payload: {
model: "anthropic/claude-sonnet-4-5",
},
},
});
expect(modelOnlyPatchRes.ok).toBe(true);
const modelOnlyPatched = modelOnlyPatchRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
};
}
| undefined;
expect(modelOnlyPatched?.payload?.kind).toBe("agentTurn");
expect(modelOnlyPatched?.payload?.message).toBe("hello");
expect(modelOnlyPatched?.payload?.model).toBe("anthropic/claude-sonnet-4-5");
const legacyDeliveryPatchRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {