diff --git a/CHANGELOG.md b/CHANGELOG.md index 15eed386f..d10d73822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Telegram: replace inbound `` 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 diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts new file mode 100644 index 000000000..f4a8061c8 --- /dev/null +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -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(), + toolSummaryById: new Set(), + pendingMessagingTargets: new Map(), + pendingMessagingTexts: new Map(), + 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"); + }); +}); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 54f2429d8..1ae6c1609 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -75,12 +75,13 @@ export async function handleToolExecutionStart( if (toolName === "read") { const record = args && typeof args === "object" ? (args as Record) : {}; - 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( diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 99c674836..f5d791004 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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; + + const payload = normalized.payload as Record; + 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; + + const payload = normalized.payload as Record; + expect(payload.kind).toBeUndefined(); + expect(payload.channel).toBe("telegram"); + expect(payload.to).toBe("+15550001111"); + }); +}); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index f4afc4fc0..00e561646 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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") { diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index 8e9d242e4..94e52d99b 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -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: {