From e5e96fa37d4f0d1c31a018e15cd400a5a378a464 Mon Sep 17 00:00:00 2001 From: SunnyShu Date: Wed, 5 Aug 2026 23:23:01 +0800 Subject: [PATCH] fix(agents): keep exact NO_REPLY silent instead of mirroring messaging-tool text (#119463) * [AI] fix(agents): keep exact NO_REPLY silent instead of mirroring messaging-tool text Exact final NO_REPLY used to be rewritten to the latest messaging-tool text (messagingToolSentTexts.at(-1)). sessions_send bodies are recorded in that global list on success, but carry no channel route target, so an internal escalation note could become the final user-facing payload and be delivered to the originating user channel (#119383). Remove the obsolete generic rewrite: the Doctor migration contract already states exact NO_REPLY is never rewritten to visible fallback text, and both the block-reply delivery path and the final payload normalizer treat exact NO_REPLY as silence. Messaging-tool sent-text/target evidence recording is unchanged so dedupe and lifecycle evidence keep working. Fixes #119383 Co-Authored-By: Claude Opus 4.6 * [AI] refactor(agents): document NO_REPLY invariant without report reference ClawSweeper P3: the inline comment in handleMessageEnd referenced the specific report (#119383). State the durable invariant instead: global messaging-tool send evidence is not a user-route reply and must never be mirrored into the final payload. Also apply oxfmt formatting to the new regression test fixture lines. Related to #119383 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...ubscribe.handlers.messages.test-support.ts | 11 -- ...-agent-subscribe.handlers.messages.test.ts | 110 +++++++++++------- ...edded-agent-subscribe.handlers.messages.ts | 26 +---- 3 files changed, 69 insertions(+), 78 deletions(-) diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts index d6c104806f57..f7850b6d8bb7 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test-support.ts @@ -39,10 +39,6 @@ type EmbeddedSubscribeMessagesTestApi = { visibleDelta: string; }, ): { hold: boolean; text: string }; - resolveSilentReplyFallbackText(params: { - text: unknown; - messagingToolSentTexts: string[]; - }): string; }; function getTestApi(): EmbeddedSubscribeMessagesTestApi { @@ -79,10 +75,3 @@ export function resolveCurrentSourceMessagingToolPartial( ): { hold: boolean; text: string } { return getTestApi().resolveCurrentSourceMessagingToolPartial(state, params); } - -export function resolveSilentReplyFallbackText(params: { - text: unknown; - messagingToolSentTexts: string[]; -}): string { - return getTestApi().resolveSilentReplyFallbackText(params); -} diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts index bba3cf0ec884..25516f954a13 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.test.ts @@ -16,7 +16,6 @@ import { buildAssistantStreamData, recordPendingAssistantReplyDirectives, resolveCurrentSourceMessagingToolPartial, - resolveSilentReplyFallbackText, } from "./embedded-agent-subscribe.handlers.messages.test-support.js"; import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; import { @@ -237,50 +236,6 @@ function createMessageToolEnvelope(message: string, args: Record { - it("replaces NO_REPLY with latest messaging tool text when available", () => { - expect( - resolveSilentReplyFallbackText({ - text: "NO_REPLY", - messagingToolSentTexts: ["first", "final delivered text"], - }), - ).toBe("final delivered text"); - }); - - it("keeps original text when response is not NO_REPLY", () => { - expect( - resolveSilentReplyFallbackText({ - text: "normal assistant reply", - messagingToolSentTexts: ["final delivered text"], - }), - ).toBe("normal assistant reply"); - }); - - it("keeps NO_REPLY when there is no messaging tool text to mirror", () => { - expect( - resolveSilentReplyFallbackText({ - text: "NO_REPLY", - messagingToolSentTexts: [], - }), - ).toBe("NO_REPLY"); - }); - - it("tolerates malformed text payloads without throwing", () => { - expect( - resolveSilentReplyFallbackText({ - text: undefined, - messagingToolSentTexts: ["final delivered text"], - }), - ).toBe(""); - expect( - resolveSilentReplyFallbackText({ - text: "NO_REPLY", - messagingToolSentTexts: [42 as unknown as string], - }), - ).toBe("42"); - }); -}); - describe("hasAssistantVisibleReply", () => { it("treats audio-only payloads as visible", () => { expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true); @@ -1705,6 +1660,71 @@ describe("handleMessageEnd", () => { }, ); + it("keeps exact NO_REPLY silent after a user-facing message send followed by sessions_send (#119383)", () => { + const emitBlockReply = vi.fn(); + const finalizeAssistantTexts = vi.fn(); + const ctx = createMessageEndContext({ + emitBlockReply, + finalizeAssistantTexts, + consumeReplyDirectives: vi.fn((text: string) => ({ text })), + state: { + blockBuffer: "", + deltaBuffer: "", + messagingToolSentTexts: ["", ""], + messagingToolSentTextsNormalized: ["", ""], + messagingToolSentTargets: [ + { + tool: "message", + provider: "whatsapp", + to: "user:123", + text: "", + }, + ], + }, + }); + + void endMessage(ctx, { + message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, + }); + + // The exact silent token must never be rewritten to the sessions_send body: + // the final assistant text keeps NO_REPLY and no block reply carries the note. + expect(finalizeAssistantTexts).toHaveBeenCalledWith( + expect.objectContaining({ text: "NO_REPLY" }), + ); + for (const call of emitBlockReply.mock.calls) { + expect(JSON.stringify(call)).not.toContain(""); + } + }); + + it("keeps exact NO_REPLY silent when only sessions_send delivered (#119383)", () => { + const emitBlockReply = vi.fn(); + const finalizeAssistantTexts = vi.fn(); + const ctx = createMessageEndContext({ + emitBlockReply, + finalizeAssistantTexts, + consumeReplyDirectives: vi.fn((text: string) => ({ text })), + state: { + blockBuffer: "", + deltaBuffer: "", + messagingToolSentTexts: [""], + messagingToolSentTextsNormalized: [""], + messagingToolSentTargets: [], + }, + }); + + void endMessage(ctx, { + message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, + }); + + expect(finalizeAssistantTexts).toHaveBeenCalledWith( + expect.objectContaining({ text: "NO_REPLY" }), + ); + for (const call of emitBlockReply.mock.calls) { + expect(JSON.stringify(call)).not.toContain(""); + } + }); + it.each([ { name: "counts a completed provider assistant message", diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.ts b/src/agents/embedded-agent-subscribe.handlers.messages.ts index faa5fa61f77d..453853e407d5 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -446,23 +446,6 @@ function copyPartialBlockState( target.pendingTagFragment = source.pendingTagFragment; } -/** Replaces a silent-reply token with the latest sent messaging-tool text when available. */ -function resolveSilentReplyFallbackText(params: { - text: unknown; - messagingToolSentTexts: string[]; -}): string { - const text = coerceChatContentText(params.text); - const trimmed = text.trim(); - if (trimmed !== SILENT_REPLY_TOKEN) { - return text; - } - const fallback = coerceChatContentText(params.messagingToolSentTexts.at(-1)).trim(); - if (!fallback) { - return text; - } - return fallback; -} - function clearPendingToolMedia( state: Pick< EmbeddedAgentSubscribeState, @@ -1257,7 +1240,6 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { buildAssistantStreamData, recordPendingAssistantReplyDirectives, resolveCurrentSourceMessagingToolPartial, - resolveSilentReplyFallbackText, }; } @@ -1357,10 +1339,10 @@ export function handleMessageEnd( ? ctx.stripBlockTags(visibleText, { thinking: false, final: false }, { final: true }) : visibleText; - const text = resolveSilentReplyFallbackText({ - text: finalVisibleText, - messagingToolSentTexts: ctx.state.messagingToolSentTexts, - }); + // Exact NO_REPLY stays silent. The legacy rewrite (silentReplyRewrite) was + // removed by contract; global messaging-tool send evidence is not a + // user-route reply and must never be mirrored into the final payload. + const text = finalVisibleText; const rawThinking = ctx.state.includeReasoning || ctx.state.streamReasoning ? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText)