Skip to content

Commit f3a0dab

Browse files
authored
fix(webchat): preserve message tool source replies
1 parent 10fafef commit f3a0dab

6 files changed

Lines changed: 154 additions & 16 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
@@ -276,10 +276,9 @@ export function buildEmbeddedRunPayloads(params: {
276276
};
277277
}> = [];
278278

279-
const sourceReplyPayloads =
280-
params.sourceReplyDeliveryMode === "message_tool_only"
281-
? (params.messagingToolSourceReplyPayloads ?? [])
282-
: [];
279+
// Internal source replies always need transcript/UI mirror payloads. Only a
280+
// message_tool_only run suppresses the separate automatic final answer.
281+
const sourceReplyPayloads = params.messagingToolSourceReplyPayloads ?? [];
283282
const sourceReplyStartIndex = replyItems.length;
284283
sourceReplyPayloads.forEach((payload, index) => {
285284
const text = normalizeOptionalString(payload.text) ?? "";
@@ -320,7 +319,7 @@ export function buildEmbeddedRunPayloads(params: {
320319
const useMarkdown = params.toolResultFormat === "markdown";
321320
const suppressAssistantArtifacts =
322321
params.didSendDeterministicApprovalPrompt === true ||
323-
hasSourceReplyPayload ||
322+
(params.sourceReplyDeliveryMode === "message_tool_only" && hasSourceReplyPayload) ||
324323
deliveredSourceReplyViaMessageTool;
325324
const nonEmptyAssistantTexts = params.assistantTexts.filter((text) => text.trim().length > 0);
326325
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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ describe("message tool secret scoping", () => {
542542
expect(input?.params).toMatchObject({ action: "send", message: "hi" });
543543
});
544544

545-
it("preserves explicit automatic delivery for internal WebChat message tools", async () => {
545+
it("keeps automatic WebChat final-answer guidance while selecting the tool-local sink", async () => {
546546
mockSendResult();
547547

548548
const input = await executeSend({
@@ -554,8 +554,14 @@ describe("message tool secret scoping", () => {
554554
},
555555
});
556556

557-
expect(input?.sourceReplyDeliveryMode).toBe("automatic");
557+
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
558558
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");
559565
});
560566

561567
it("passes current inbound audio to the outbound runner", async () => {

src/agents/tools/message-tool.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,9 +1150,11 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
11501150
const currentChannelIsInternal =
11511151
normalizeMessageChannel(effectiveCurrentChannel.currentChannelProvider) ===
11521152
INTERNAL_MESSAGE_CHANNEL;
1153-
const sourceReplyDeliveryMode =
1154-
options?.sourceReplyDeliveryMode ??
1155-
(currentChannelIsInternal ? "message_tool_only" : undefined);
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;
11561158
const resolvedAgentId =
11571159
options?.agentId ??
11581160
(options?.agentSessionKey
@@ -1187,7 +1189,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
11871189
sessionId: options?.sessionId,
11881190
agentId: resolvedAgentId,
11891191
requireExplicitTarget: options?.requireExplicitTarget,
1190-
sourceReplyDeliveryMode,
1192+
sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode,
11911193
requesterSenderId: options?.requesterSenderId,
11921194
senderIsOwner: options?.senderIsOwner,
11931195
});
@@ -1393,7 +1395,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
13931395
sessionId: options?.sessionId,
13941396
agentId: resolvedAgentId,
13951397
sandboxRoot: options?.sandboxRoot,
1396-
sourceReplyDeliveryMode,
1398+
sourceReplyDeliveryMode: sourceReplySinkDeliveryMode,
13971399
inboundEventKind: options?.inboundEventKind,
13981400
inboundAudio: options?.currentInboundAudio,
13991401
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)