Skip to content

Commit 29371cf

Browse files
fix #96840: [Bug]: Targetless message.send fails with 'Action send requires a target' in WebChat despite docs stating source-reply sink should handle it (#97167)
* fix(message): route WebChat sends to source sink * fix(webchat): preserve message tool source replies --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 1289abd commit 29371cf

6 files changed

Lines changed: 187 additions & 11 deletions

File tree

src/agents/embedded-agent-runner/run/payloads.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -603,10 +603,9 @@ export function buildEmbeddedRunPayloads(params: {
603603
};
604604
}> = [];
605605

606-
const sourceReplyPayloads =
607-
params.sourceReplyDeliveryMode === "message_tool_only"
608-
? (params.messagingToolSourceReplyPayloads ?? [])
609-
: [];
606+
// Internal source replies always need transcript/UI mirror payloads. Only a
607+
// message_tool_only run suppresses the separate automatic final answer.
608+
const sourceReplyPayloads = params.messagingToolSourceReplyPayloads ?? [];
610609
const sourceReplyStartIndex = replyItems.length;
611610
sourceReplyPayloads.forEach((payload, index) => {
612611
const text = normalizeOptionalString(payload.text) ?? "";
@@ -647,7 +646,7 @@ export function buildEmbeddedRunPayloads(params: {
647646
const useMarkdown = params.toolResultFormat === "markdown";
648647
const suppressAssistantArtifacts =
649648
params.didSendDeterministicApprovalPrompt === true ||
650-
hasSourceReplyPayload ||
649+
(params.sourceReplyDeliveryMode === "message_tool_only" && hasSourceReplyPayload) ||
651650
deliveredSourceReplyViaMessageTool;
652651
const nonEmptyAssistantTexts = params.assistantTexts.filter((text) => text.trim().length > 0);
653652
const currentAssistant = params.currentAssistant ?? undefined;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Integration coverage for targetless WebChat tool sends through the internal
2+
// source-reply sink and embedded-run payload projection.
3+
import { describe, expect, it } from "vitest";
4+
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
5+
import { buildReplyPayloads } from "../../auto-reply/reply/agent-runner-payloads.js";
6+
import { buildEmbeddedRunPayloads } from "../embedded-agent-runner/run/payloads.js";
7+
import { extractMessagingToolSourceReplyPayload } from "../embedded-agent-subscribe.tools.js";
8+
import { createMessageTool } from "./message-tool.js";
9+
10+
describe("WebChat message tool internal source reply", () => {
11+
it("projects a real targetless send and preserves the automatic final reply", async () => {
12+
const tool = createMessageTool({
13+
config: {},
14+
currentChannelProvider: "webchat",
15+
sourceReplyDeliveryMode: "automatic",
16+
agentSessionKey: "agent:main:webchat:dm:dashboard",
17+
runId: "webchat-run",
18+
getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set<string>() }),
19+
resolveCommandSecretRefsViaGateway: async ({ config }) => ({
20+
resolvedConfig: config,
21+
diagnostics: [],
22+
targetStatesByPath: {},
23+
hadUnresolvedTargets: false,
24+
}),
25+
});
26+
27+
const toolResult = await tool.execute("message-call", {
28+
action: "send",
29+
message: "Visible progress from the message tool.",
30+
});
31+
expect(toolResult.details).toMatchObject({
32+
channel: "webchat",
33+
target: "current-run",
34+
sourceReplyDeliveryMode: "message_tool_only",
35+
sourceReplySink: "internal-ui",
36+
sourceReply: { text: "Visible progress from the message tool." },
37+
});
38+
39+
const sourceReply = extractMessagingToolSourceReplyPayload(toolResult);
40+
expect(sourceReply).toMatchObject({ text: "Visible progress from the message tool." });
41+
42+
const embeddedPayloads = buildEmbeddedRunPayloads({
43+
assistantTexts: ["Visible automatic final reply."],
44+
toolMetas: [],
45+
lastAssistant: undefined,
46+
currentAssistant: undefined,
47+
sessionKey: "agent:main:webchat:dm:dashboard",
48+
sourceReplyDeliveryMode: "automatic",
49+
messagingToolSourceReplyPayloads: sourceReply ? [sourceReply] : [],
50+
runId: "webchat-run",
51+
inlineToolResultsAllowed: false,
52+
verboseLevel: "off",
53+
reasoningLevel: "off",
54+
toolResultFormat: "plain",
55+
});
56+
const { replyPayloads: payloads } = await buildReplyPayloads({
57+
payloads: embeddedPayloads,
58+
isHeartbeat: false,
59+
didLogHeartbeatStrip: false,
60+
blockStreamingEnabled: false,
61+
blockReplyPipeline: null,
62+
replyToMode: "off",
63+
messagingToolSentTexts: ["Visible progress from the message tool."],
64+
});
65+
66+
expect(payloads.map((payload) => payload.text)).toEqual([
67+
"Visible progress from the message tool.",
68+
"Visible automatic final reply.",
69+
]);
70+
expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({
71+
deliverDespiteSourceReplySuppression: true,
72+
sourceReplyTranscriptMirror: {
73+
sessionKey: "agent:main:webchat:dm:dashboard",
74+
text: "Visible progress from the message tool.",
75+
idempotencyKey: "webchat-run:internal-source-reply:0",
76+
},
77+
});
78+
expect(getReplyPayloadMetadata(payloads[1] as object)?.sourceReplyTranscriptMirror).toBe(
79+
undefined,
80+
);
81+
});
82+
});

src/agents/tools/message-tool.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,44 @@ describe("message tool secret scoping", () => {
526526
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
527527
});
528528

529+
it("defaults internal WebChat message tool sends to the source-reply sink", async () => {
530+
mockSendResult();
531+
532+
const input = await executeSend({
533+
action: { message: "hi" },
534+
toolOptions: {
535+
currentChannelProvider: "webchat",
536+
agentSessionKey: "agent:main:webchat:dm:dashboard",
537+
},
538+
});
539+
540+
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
541+
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
542+
expect(input?.params).toMatchObject({ action: "send", message: "hi" });
543+
});
544+
545+
it("keeps automatic WebChat final-answer guidance while selecting the tool-local sink", async () => {
546+
mockSendResult();
547+
548+
const input = await executeSend({
549+
action: { message: "hi" },
550+
toolOptions: {
551+
currentChannelProvider: "webchat",
552+
sourceReplyDeliveryMode: "automatic",
553+
agentSessionKey: "agent:main:webchat:dm:dashboard",
554+
},
555+
});
556+
557+
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
558+
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
559+
const tool = createMessageTool({
560+
currentChannelProvider: "webchat",
561+
sourceReplyDeliveryMode: "automatic",
562+
agentSessionKey: "agent:main:webchat:dm:dashboard",
563+
});
564+
expect(tool.description).not.toContain("Normal final answers stay private");
565+
});
566+
529567
it("passes current inbound audio to the outbound runner", async () => {
530568
mockSendResult();
531569

src/agents/tools/message-tool.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
5959
import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
6060
import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js";
6161
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
62-
import { normalizeMessageChannel } from "../../utils/message-channel.js";
62+
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
6363
import { resolveSessionAgentId } from "../agent-scope.js";
6464
import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js";
6565
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
@@ -1147,6 +1147,14 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
11471147
const replyToMode = options?.replyToMode ?? (currentThreadTs ? "all" : undefined);
11481148
const agentAccountId =
11491149
resolveAgentAccountId(options?.agentAccountId) ?? effectiveCurrentChannel.accountId;
1150+
const currentChannelIsInternal =
1151+
normalizeMessageChannel(effectiveCurrentChannel.currentChannelProvider) ===
1152+
INTERNAL_MESSAGE_CHANNEL;
1153+
// WebChat tool sends use the private sink without changing the run-level
1154+
// contract: ordinary final answers must remain automatic and visible.
1155+
const sourceReplySinkDeliveryMode = currentChannelIsInternal
1156+
? "message_tool_only"
1157+
: options?.sourceReplyDeliveryMode;
11501158
const resolvedAgentId =
11511159
options?.agentId ??
11521160
(options?.agentSessionKey
@@ -1387,7 +1395,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
13871395
sessionId: options?.sessionId,
13881396
agentId: resolvedAgentId,
13891397
sandboxRoot: options?.sandboxRoot,
1390-
sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode,
1398+
sourceReplyDeliveryMode: sourceReplySinkDeliveryMode,
13911399
inboundEventKind: options?.inboundEventKind,
13921400
inboundAudio: options?.currentInboundAudio,
13931401
abortSignal: signal,

src/auto-reply/reply/agent-runner-payloads.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,13 @@ export async function buildReplyPayloads(params: {
303303
});
304304
dedupedPayloads = [];
305305
for (const payload of silentFilteredPayloads) {
306+
const payloadMetadata = getReplyPayloadMetadata(payload);
307+
// Source mirrors exist because an internal sink send must reach the UI and
308+
// transcript; that send is not outbound evidence for dropping the mirror.
309+
if (payloadMetadata?.sourceReplyTranscriptMirror) {
310+
dedupedPayloads.push(payload);
311+
continue;
312+
}
306313
const decision = dedupeRuntime.resolveMessagingToolPayloadDedupe({
307314
config: params.config,
308315
messageProvider,
@@ -311,11 +318,9 @@ export async function buildReplyPayloads(params: {
311318
originatingThreadId: params.originatingThreadId,
312319
replyToId: payload.replyToId,
313320
replyToIsExplicit: Boolean(
314-
getReplyPayloadMetadata(payload)?.replyToIdExplicit ||
315-
payload.replyToTag ||
316-
payload.replyToCurrent,
321+
payloadMetadata?.replyToIdExplicit || payload.replyToTag || payload.replyToCurrent,
317322
),
318-
replyDelivery: getReplyPayloadMetadata(payload)?.replyDelivery,
323+
replyDelivery: payloadMetadata?.replyDelivery,
319324
accountId,
320325
});
321326
if (!decision.shouldDedupePayloads) {

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,50 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
271271
}
272272
});
273273

274+
it("keeps a targetless message-tool source reply beside the automatic final reply", async () => {
275+
const context = await newBrowserContext({
276+
locale: "en-US",
277+
serviceWorkers: "block",
278+
viewport: { height: 900, width: 1280 },
279+
});
280+
const page = await context.newPage();
281+
const gateway = await installMockGateway(page);
282+
283+
try {
284+
await page.goto(`${server.baseUrl}chat`);
285+
286+
const prompt = "send progress through the message tool and then finish";
287+
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
288+
await page.getByRole("button", { name: "Send message" }).click();
289+
290+
const sendRequest = await gateway.waitForRequest("chat.send");
291+
const params = requireRecord(sendRequest.params);
292+
expect(params).toMatchObject({ sessionKey: "main", message: prompt, deliver: false });
293+
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
294+
295+
await gateway.emitChatFinal({
296+
runId,
297+
text: "Visible progress from the targetless message tool.",
298+
});
299+
await page
300+
.getByText("Visible progress from the targetless message tool.")
301+
.waitFor({ timeout: 10_000 });
302+
303+
await gateway.emitChatFinal({ runId, text: "Visible automatic final reply." });
304+
await page.getByText("Visible automatic final reply.").waitFor({ timeout: 10_000 });
305+
const bubbleTexts = await page.locator(".chat-thread .chat-bubble").allTextContents();
306+
for (const expectedText of [
307+
prompt,
308+
"Visible progress from the targetless message tool.",
309+
"Visible automatic final reply.",
310+
]) {
311+
expect(bubbleTexts.some((text) => text.includes(expectedText))).toBe(true);
312+
}
313+
} finally {
314+
await closeBrowserContext(context);
315+
}
316+
});
317+
274318
it("keeps the composer clear when a stale native input replay arrives after send", async () => {
275319
const context = await newBrowserContext({
276320
locale: "en-US",

0 commit comments

Comments
 (0)