fix: message sends fail when optional location is blank (#112013)

* fix(outbound): ignore blank shared-schema send location

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b529c5fd-6822-4c0d-ab8d-9906bd7dc8d9

* fix(outbound): normalize blank send locations

Co-authored-by: ronan-dandelion-cult <ronan.dandelion.cult@hotmail.com>

---------

Co-authored-by: ronan-dandelion-cult <ronan.dandelion.cult@hotmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Gwydion Nanashi Ferrinas Solidor
2026-07-22 14:16:18 -07:00
committed by GitHub
parent 267d9f89ef
commit 4e6a6bdbcc
3 changed files with 98 additions and 2 deletions
@@ -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: [
@@ -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", () => {
+20 -2
View File
@@ -686,6 +686,17 @@ function updateSendPayloadPartsFromReplyPayload(
};
}
function applySendLocationToActionParams(
actionParams: Record<string, unknown>,
location: ReplyPayload["location"],
) {
if (location) {
actionParams.location = location;
} else {
delete actionParams.location;
}
}
function applySendPayloadPartsToActionParams(
actionParams: Record<string, unknown>,
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", {