fix(reply): narrow empty-body history guard

This commit is contained in:
Peter Steinberger
2026-04-25 11:12:15 +01:00
parent 1559e28d6b
commit 51e6f9c27e
3 changed files with 93 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Google video generation: fall back to the REST `predictLongRunning` Veo endpoint for text-only SDK 404s while keeping reference image/video generation on the SDK path. Fixes #62309 and #63008. (#62343) Thanks @leoleedev.
- MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and addresses the music default from #62315. Thanks @noahclanman and @edwardzheng1.
- Cron: hydrate flat legacy job rows with top-level `cron`, `tz`, `session`, and `message` fields into canonical schedule, target, and payload objects before startup recomputes run times. Fixes #43351.
- Agents/replies: let pending group chat history trigger bare mentioned turns without treating metadata-only inbound context as user input. Fixes #71489. (#71520) Thanks @SymbolStar.
- Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss.
- Discord: restore direct-message voice-note preflight transcription and classify URL-only Ogg/Opus voice attachments as audio while skipping partial attachments without usable URLs. Fixes #61314 and #64803.
- Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm.

View File

@@ -458,6 +458,90 @@ describe("runPreparedReply media-only handling", () => {
expect(vi.mocked(runReplyAgent)).not.toHaveBeenCalled();
});
it("allows pending inbound history to trigger a bare mention turn", async () => {
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
[
"Chat history since last reply (untrusted, for context):",
"```json",
JSON.stringify(
[{ sender: "Alice", timestamp_ms: 1_700_000_000_000, body: "what changed?" }],
null,
2,
),
"```",
].join("\n"),
);
const result = await runPreparedReply(
baseParams({
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
ChatType: "group",
WasMentioned: true,
},
sessionCtx: {
Body: "",
BodyStripped: "",
Provider: "feishu",
OriginatingChannel: "feishu",
OriginatingTo: "chat-1",
ChatType: "group",
WasMentioned: true,
InboundHistory: [
{ sender: "Alice", timestamp: 1_700_000_000_000, body: "what changed?" },
],
},
}),
);
expect(result).toEqual({ text: "ok" });
expect(vi.mocked(runReplyAgent)).toHaveBeenCalledOnce();
const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0];
expect(call?.followupRun.prompt).toContain("Chat history since last reply");
expect(call?.followupRun.prompt).toContain("what changed?");
expect(call?.followupRun.prompt).not.toContain("[User sent media without caption]");
});
it("does not treat blank pending inbound history as user input", async () => {
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
[
"Chat history since last reply (untrusted, for context):",
"```json",
JSON.stringify([{ sender: "Alice", timestamp_ms: 1_700_000_000_000, body: "" }], null, 2),
"```",
].join("\n"),
);
const result = await runPreparedReply(
baseParams({
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
ChatType: "group",
WasMentioned: true,
},
sessionCtx: {
Body: "",
BodyStripped: "",
Provider: "feishu",
OriginatingChannel: "feishu",
OriginatingTo: "chat-1",
ChatType: "group",
WasMentioned: true,
InboundHistory: [{ sender: "Alice", timestamp: 1_700_000_000_000, body: "\u0000 " }],
},
}),
);
expect(result).toEqual({
text: "I didn't receive any text in your message. Please resend or add a caption.",
});
expect(vi.mocked(runReplyAgent)).not.toHaveBeenCalled();
});
it("allows webchat pure-image turns when image content is carried outside MediaPath", async () => {
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
[

View File

@@ -165,6 +165,13 @@ function stripPromptThinkingDirectives(body: string): string {
.join("\n");
}
function hasInboundHistoryBody(ctx: TemplateContext): boolean {
return (
Array.isArray(ctx.InboundHistory) &&
ctx.InboundHistory.some((entry) => entry.body.replaceAll("\u0000", "").trim().length > 0)
);
}
type RunPreparedReplyParams = {
ctx: MsgContext;
sessionCtx: TemplateContext;
@@ -458,7 +465,7 @@ export async function runPreparedReply(
const hasUserBody =
baseBodyFinal.trim().length > 0 ||
softResetTail.length > 0 ||
(inboundUserContext != null && inboundUserContext.trim().length > 0);
hasInboundHistoryBody(sessionCtx);
const hasMediaAttachment = hasInboundMedia(sessionCtx) || (opts?.images?.length ?? 0) > 0;
if (!hasUserBody && !hasMediaAttachment) {
// Skip onReplyStart when typing is suppressed (e.g. sendPolicy deny) —