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:
@@ -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
|
||||
|
||||
|
||||
71
src/agents/pi-embedded-subscribe.handlers.tools.test.ts
Normal file
71
src/agents/pi-embedded-subscribe.handlers.tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user