diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 7666188db1ef..24d0749fcbed 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -2937,6 +2937,51 @@ describe("runMessageAction plugin dispatch", () => { expectRecordFields(gatewayActionParams, { presentation }, "gateway action params"); }); + it("omits a blank shared-schema location from gateway-routed sends", async () => { + const cfg = { + channels: { + cardchat: { + enabled: true, + }, + }, + messages: { responsePrefix: "[Nexus]" }, + } as OpenClawConfig; + mocks.callGatewayLeastPrivilege.mockResolvedValueOnce({ + ok: true, + messageId: "card-location", + }); + + const result = await runMessageAction({ + cfg, + action: "send", + params: { + channel: "cardchat", + target: "channel:test-card", + message: "hello", + location: "", + }, + gateway: { + clientName: "cli", + mode: "cli", + }, + dryRun: false, + }); + + expect(result.kind).toBe("send"); + expect(result.handledBy).toBe("plugin"); + expect(handleAction).not.toHaveBeenCalled(); + const gatewayCall = readMockCallArg( + mocks.callGatewayLeastPrivilege, + "gateway least privilege call", + ); + const gatewayActionParams = readRecordField( + readRecordField(gatewayCall, "params", "gateway call params"), + "params", + "gateway action params", + ); + expect(gatewayActionParams).not.toHaveProperty("location"); + }); + it("keeps gateway-routed chart presentations on the gateway", async () => { const presentation = { blocks: [ diff --git a/src/infra/outbound/message-action-runner.send-validation.test.ts b/src/infra/outbound/message-action-runner.send-validation.test.ts index fbdaea1d41da..fa488526b256 100644 --- a/src/infra/outbound/message-action-runner.send-validation.test.ts +++ b/src/infra/outbound/message-action-runner.send-validation.test.ts @@ -472,6 +472,39 @@ describe("runMessageAction send validation", () => { expect(result.kind).toBe("send"); }); + + it.each(["", " \t\n"])( + "treats blank shared-schema event location %j as omitted on send", + async (location) => { + const result = await runDrySend({ + cfg: workspaceConfig, + actionParams: { + channel: "workspace", + target: "#C12345678", + message: "hello", + location, + }, + toolContext: { currentChannelId: "C12345678" }, + }); + + expect(result.kind).toBe("send"); + }, + ); + + it("keeps rejecting a non-empty event location string on send", async () => { + await expect( + runDrySend({ + cfg: workspaceConfig, + actionParams: { + channel: "workspace", + target: "#C12345678", + message: "hello", + location: "Main stage", + }, + toolContext: { currentChannelId: "C12345678" }, + }), + ).rejects.toThrow("location must be an object"); + }); }); describe("message body alias normalization", () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 9ab79c293e96..ecff00e3e36b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -686,6 +686,17 @@ function updateSendPayloadPartsFromReplyPayload( }; } +function applySendLocationToActionParams( + actionParams: Record, + location: ReplyPayload["location"], +) { + if (location) { + actionParams.location = location; + } else { + delete actionParams.location; + } +} + function applySendPayloadPartsToActionParams( actionParams: Record, parts: SendPayloadParts, @@ -703,7 +714,7 @@ function applySendPayloadPartsToActionParams( actionParams.asVoice = parts.asVoice || undefined; actionParams.audioAsVoice = parts.asVoice || undefined; actionParams.asVideoNote = parts.payload.videoAsNote || undefined; - actionParams.location = parts.payload.location; + applySendLocationToActionParams(actionParams, parts.payload.location); } function collectMessageAttachmentMediaHints(value: unknown): string[] { @@ -1188,7 +1199,14 @@ async function buildSendPayloadParts(params: { Boolean(mediaHint) || mediaUrlHints.length > 0 || attachmentMediaHints.length > 0; const hasPresentation = hasMessagePresentationBlocks(actionParams.presentation); const hasInteractive = hasLegacyInteractiveReplyBlocks(actionParams.interactive); - const location = normalizeOutboundLocation(actionParams.location); + const rawLocation = actionParams.location; + // The flat tool schema also carries scheduled-event `location` as a string, + // and some models pad unused optional slots with blanks. Keep real send locations strict. + const location = + typeof rawLocation === "string" && normalizeOptionalString(rawLocation) === undefined + ? undefined + : normalizeOutboundLocation(rawLocation); + applySendLocationToActionParams(actionParams, location); const caption = readStringParam(actionParams, "caption", { allowEmpty: true }) ?? ""; let message = readStringParam(actionParams, "message", {