Skip to content

Commit d7a078f

Browse files
authored
fix(agents): mirror internal ui message tool replies (#85564)
* fix(agents): mirror internal ui message tool replies * test(tui): prove internal source reply rendering * fix(agents): preserve source reply idempotency
1 parent 463929d commit d7a078f

10 files changed

Lines changed: 310 additions & 7 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
4949

5050
- Windows installer: fail Git checkout installs when `pnpm install` or `pnpm build` fails instead of writing a wrapper to a missing CLI build.
5151
- Sessions: surface previous-transcript archive failures during `/new` rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.
52+
- TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in `openclaw tui`. Fixes #85538. Thanks @danpolasek.
5253
- Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.
5354
- Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.
5455
- Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.

src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
normalizeOptionalLowercaseString,
2121
} from "../../../shared/string-coerce.js";
2222
import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js";
23-
import type { MessagingToolSend } from "../../pi-embedded-messaging.types.js";
23+
import type {
24+
MessagingToolSend,
25+
MessagingToolSourceReplyPayload,
26+
} from "../../pi-embedded-messaging.types.js";
2427
import type { WorkspaceBootstrapFile } from "../../workspace.js";
2528

2629
type SubscribeEmbeddedPiSessionFn =
@@ -104,6 +107,7 @@ export function createSubscriptionMock(): SubscriptionMock {
104107
getMessagingToolSentTexts: () => [] as string[],
105108
getMessagingToolSentMediaUrls: () => [] as string[],
106109
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
110+
getMessagingToolSourceReplyPayloads: () => [] as MessagingToolSourceReplyPayload[],
107111
getHeartbeatToolResponse: () => undefined,
108112
getPendingToolMediaReply: () => null,
109113
getVisibleBlockReplyCount: () => 0,

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3288,6 +3288,7 @@ export async function runEmbeddedAttempt(
32883288
getMessagingToolSentTexts,
32893289
getMessagingToolSentMediaUrls,
32903290
getMessagingToolSentTargets,
3291+
getMessagingToolSourceReplyPayloads,
32913292
getHeartbeatToolResponse,
32923293
getPendingToolMediaReply,
32933294
getVisibleBlockReplyCount,
@@ -4704,6 +4705,7 @@ export async function runEmbeddedAttempt(
47044705
const didSendDeterministicApprovalPromptNow = didSendDeterministicApprovalPrompt();
47054706
const lastToolError = getLastToolError?.();
47064707
const heartbeatToolResponse = getHeartbeatToolResponse();
4708+
const messagingToolSourceReplyPayloads = getMessagingToolSourceReplyPayloads();
47074709
const pendingToolMediaPayloadCount = hasVisiblePendingToolMediaReply(pendingToolMediaReply)
47084710
? 1
47094711
: 0;
@@ -4725,6 +4727,7 @@ export async function runEmbeddedAttempt(
47254727
const synthesizedPayloadCount =
47264728
visibleBlockReplyCount +
47274729
pendingToolMediaPayloadCount +
4730+
messagingToolSourceReplyPayloads.length +
47284731
(silentToolResultReplyPayload ? 1 : 0);
47294732
const emptyAssistantReplyIsSilent = shouldTreatEmptyAssistantReplyAsSilent({
47304733
allowEmptyAssistantReplyAsSilent: params.allowEmptyAssistantReplyAsSilent,
@@ -4866,6 +4869,7 @@ export async function runEmbeddedAttempt(
48664869
messagingToolSentTexts: getMessagingToolSentTexts(),
48674870
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
48684871
messagingToolSentTargets: getMessagingToolSentTargets(),
4872+
messagingToolSourceReplyPayloads,
48694873
heartbeatToolResponse,
48704874
toolMediaUrls: pendingToolMediaReply?.mediaUrls,
48714875
toolAudioAsVoice: pendingToolMediaReply?.audioAsVoice,

src/agents/pi-embedded-subscribe.handlers.tools.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function createTestContext(): {
6363
messagingToolSentTexts: [],
6464
messagingToolSentTextsNormalized: [],
6565
messagingToolSentMediaUrls: [],
66+
messagingToolSourceReplyPayloads: [],
6667
messagingToolSentTargets: [],
6768
successfulCronAdds: 0,
6869
deterministicApprovalPromptSent: false,
@@ -1407,6 +1408,95 @@ describe("messaging tool media URL tracking", () => {
14071408
});
14081409
});
14091410

1411+
it("commits internal-ui source replies from successful message sends", async () => {
1412+
const { ctx } = createTestContext();
1413+
1414+
const startEvt: ToolExecutionStartEvent = {
1415+
type: "tool_execution_start",
1416+
toolName: "message",
1417+
toolCallId: "tool-internal-source-reply",
1418+
args: { action: "send", message: "visible in tui" },
1419+
};
1420+
await handleToolExecutionStart(ctx, startEvt);
1421+
1422+
const endEvt: ToolExecutionEndEvent = {
1423+
type: "tool_execution_end",
1424+
toolName: "message",
1425+
toolCallId: "tool-internal-source-reply",
1426+
isError: false,
1427+
result: {
1428+
details: {
1429+
status: "ok",
1430+
deliveryStatus: "sent",
1431+
sourceReplySink: "internal-ui",
1432+
idempotencyKey: "stable-source-reply",
1433+
sourceReply: {
1434+
text: "visible in tui",
1435+
mediaUrls: ["file:///tmp/reply.png"],
1436+
channelData: { source: "tui" },
1437+
},
1438+
},
1439+
},
1440+
};
1441+
await handleToolExecutionEnd(ctx, endEvt);
1442+
1443+
expect(ctx.state.messagingToolSourceReplyPayloads).toEqual([
1444+
{
1445+
text: "visible in tui",
1446+
mediaUrls: ["file:///tmp/reply.png"],
1447+
channelData: { source: "tui" },
1448+
idempotencyKey: "stable-source-reply",
1449+
},
1450+
]);
1451+
});
1452+
1453+
it("does not commit dry-run or external message sends as internal-ui source replies", async () => {
1454+
const { ctx } = createTestContext();
1455+
1456+
await handleToolExecutionStart(ctx, {
1457+
type: "tool_execution_start",
1458+
toolName: "message",
1459+
toolCallId: "tool-dry-run-source-reply",
1460+
args: { action: "send", message: "preview" },
1461+
});
1462+
await handleToolExecutionEnd(ctx, {
1463+
type: "tool_execution_end",
1464+
toolName: "message",
1465+
toolCallId: "tool-dry-run-source-reply",
1466+
isError: false,
1467+
result: {
1468+
details: {
1469+
status: "ok",
1470+
deliveryStatus: "dry_run",
1471+
sourceReplySink: "internal-ui",
1472+
sourceReply: { text: "preview" },
1473+
},
1474+
},
1475+
});
1476+
1477+
await handleToolExecutionStart(ctx, {
1478+
type: "tool_execution_start",
1479+
toolName: "message",
1480+
toolCallId: "tool-external-source-reply",
1481+
args: { action: "send", to: "channel:123", message: "sent externally" },
1482+
});
1483+
await handleToolExecutionEnd(ctx, {
1484+
type: "tool_execution_end",
1485+
toolName: "message",
1486+
toolCallId: "tool-external-source-reply",
1487+
isError: false,
1488+
result: {
1489+
details: {
1490+
status: "ok",
1491+
deliveryStatus: "sent",
1492+
sourceReply: { text: "sent externally" },
1493+
},
1494+
},
1495+
});
1496+
1497+
expect(ctx.state.messagingToolSourceReplyPayloads).toHaveLength(0);
1498+
});
1499+
14101500
it("commits sendAttachment args as message delivery evidence", async () => {
14111501
const { ctx } = createTestContext();
14121502

src/agents/pi-embedded-subscribe.handlers.tools.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
emitAgentPatchSummaryEvent,
1818
} from "../infra/agent-events.js";
1919
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
20+
import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js";
2021
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
2122
import { createLazyImportLoader } from "../shared/lazy-promise.js";
2223
import { normalizeOptionalLowercaseString, readStringValue } from "../shared/string-coerce.js";
@@ -27,6 +28,7 @@ import type { ExecToolDetails } from "./bash-tools.exec-types.js";
2728
import { parseExecApprovalResultText } from "./exec-approval-result.js";
2829
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
2930
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
31+
import type { MessagingToolSourceReplyPayload } from "./pi-embedded-messaging.types.js";
3032
import { mergeEmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js";
3133
import type {
3234
ToolCallSummary,
@@ -425,6 +427,87 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
425427
return urls;
426428
}
427429

430+
function readRecordField(value: unknown): Record<string, unknown> | undefined {
431+
return value && typeof value === "object" && !Array.isArray(value)
432+
? (value as Record<string, unknown>)
433+
: undefined;
434+
}
435+
436+
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
437+
const value = record[key];
438+
return typeof value === "string" && value.trim() ? value : undefined;
439+
}
440+
441+
function readStringArrayField(record: Record<string, unknown>, key: string): string[] | undefined {
442+
const value = record[key];
443+
if (!Array.isArray(value)) {
444+
return undefined;
445+
}
446+
const strings = value.filter(
447+
(item): item is string => typeof item === "string" && item.trim().length > 0,
448+
);
449+
return strings.length ? strings : undefined;
450+
}
451+
452+
function copyRecordField(
453+
record: Record<string, unknown>,
454+
key: string,
455+
): Record<string, unknown> | undefined {
456+
const value = record[key];
457+
return readRecordField(value) ? { ...(value as Record<string, unknown>) } : undefined;
458+
}
459+
460+
function extractMessagingToolSourceReplyPayload(
461+
result: unknown,
462+
): MessagingToolSourceReplyPayload | undefined {
463+
const details = readToolResultDetailsRecord(result);
464+
if (!details || details.sourceReplySink !== "internal-ui") {
465+
return undefined;
466+
}
467+
const status = normalizeOptionalLowercaseString(details.deliveryStatus);
468+
if (status && status !== "sent") {
469+
return undefined;
470+
}
471+
const sourceReply = readRecordField(details.sourceReply) ?? details;
472+
const payload: MessagingToolSourceReplyPayload = {};
473+
const text = readStringField(sourceReply, "text") ?? readStringField(details, "message");
474+
if (text) {
475+
payload.text = text;
476+
}
477+
const mediaUrl = readStringField(sourceReply, "mediaUrl") ?? readStringField(details, "mediaUrl");
478+
if (mediaUrl) {
479+
payload.mediaUrl = mediaUrl;
480+
}
481+
const mediaUrls =
482+
readStringArrayField(sourceReply, "mediaUrls") ?? readStringArrayField(details, "mediaUrls");
483+
if (mediaUrls) {
484+
payload.mediaUrls = mediaUrls;
485+
}
486+
const audioAsVoice =
487+
sourceReply.audioAsVoice === true || details.audioAsVoice === true ? true : undefined;
488+
if (audioAsVoice) {
489+
payload.audioAsVoice = true;
490+
}
491+
const presentation = normalizeMessagePresentation(sourceReply.presentation);
492+
if (presentation) {
493+
payload.presentation = presentation;
494+
}
495+
const interactive = normalizeInteractiveReply(sourceReply.interactive);
496+
if (interactive) {
497+
payload.interactive = interactive;
498+
}
499+
const channelData = copyRecordField(sourceReply, "channelData");
500+
if (channelData) {
501+
payload.channelData = channelData;
502+
}
503+
const idempotencyKey =
504+
readStringField(sourceReply, "idempotencyKey") ?? readStringField(details, "idempotencyKey");
505+
if (idempotencyKey) {
506+
payload.idempotencyKey = idempotencyKey;
507+
}
508+
return Object.keys(payload).length > 0 ? payload : undefined;
509+
}
510+
428511
function queuePendingToolMedia(
429512
ctx: ToolHandlerContext,
430513
mediaReply: { mediaUrls: string[]; audioAsVoice?: boolean; trustedLocalMedia?: boolean },
@@ -1033,6 +1116,11 @@ export async function handleToolExecutionEnd(
10331116
ctx.state.messagingToolSentMediaUrls.push(...committedMediaUrls);
10341117
ctx.trimMessagingToolSent();
10351118
}
1119+
const sourceReplyPayload = extractMessagingToolSourceReplyPayload(result);
1120+
if (sourceReplyPayload) {
1121+
ctx.state.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
1122+
ctx.trimMessagingToolSent();
1123+
}
10361124
}
10371125

10381126
// Track committed reminders only when cron.add completed successfully.

src/agents/pi-embedded-subscribe.handlers.types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type { InlineCodeState } from "../markdown/code-spans.js";
77
import type { HookRunner } from "../plugins/hooks.js";
88
import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js";
99
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
10-
import type { MessagingToolSend } from "./pi-embedded-messaging.types.js";
10+
import type {
11+
MessagingToolSend,
12+
MessagingToolSourceReplyPayload,
13+
} from "./pi-embedded-messaging.types.js";
1114
import type { BlockReplyPayload } from "./pi-embedded-payloads.js";
1215
import type { EmbeddedRunReplayState } from "./pi-embedded-runner/replay-state.js";
1316
import type { EmbeddedRunLivenessState } from "./pi-embedded-runner/types.js";
@@ -103,6 +106,7 @@ export type EmbeddedPiSubscribeState = {
103106
messagingToolSentTargets: MessagingToolSend[];
104107
heartbeatToolResponse?: HeartbeatToolResponse;
105108
messagingToolSentMediaUrls: string[];
109+
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[];
106110
pendingMessagingTexts: Map<string, string>;
107111
pendingMessagingTargets: Map<string, MessagingToolSend>;
108112
successfulCronAdds: number;
@@ -224,6 +228,7 @@ type ToolHandlerState = Pick<
224228
| "messagingToolSentTexts"
225229
| "messagingToolSentTextsNormalized"
226230
| "messagingToolSentMediaUrls"
231+
| "messagingToolSourceReplyPayloads"
227232
| "messagingToolSentTargets"
228233
| "heartbeatToolResponse"
229234
| "successfulCronAdds"

src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,29 @@ describe("subscribeEmbeddedPiSession", () => {
9696
expect(subscription.getMessagingToolSentMediaUrls()).toEqual(["file:///tmp/render.mp4"]);
9797
});
9898

99+
it("tracks internal-ui source replies for message-tool-only final payloads", async () => {
100+
const { emit, subscription } = createBlockReplyHarness("message_end");
101+
102+
await emitMessageToolLifecycle({
103+
emit,
104+
toolCallId: "tool-message-source-reply",
105+
message: "Visible terminal answer.",
106+
result: {
107+
details: {
108+
status: "ok",
109+
deliveryStatus: "sent",
110+
sourceReplySink: "internal-ui",
111+
sourceReply: { text: "Visible terminal answer." },
112+
},
113+
},
114+
});
115+
await Promise.resolve();
116+
117+
expect(subscription.getMessagingToolSourceReplyPayloads()).toEqual([
118+
{ text: "Visible terminal answer." },
119+
]);
120+
});
121+
99122
it("suppresses text-only tool summaries after message-tool-only delivery", async () => {
100123
const onToolResult = vi.fn();
101124
const { emit } = createSubscribedSessionHarness({

src/agents/pi-embedded-subscribe.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
185185
messagingToolSentTargets: [],
186186
heartbeatToolResponse: undefined,
187187
messagingToolSentMediaUrls: [],
188+
messagingToolSourceReplyPayloads: [],
188189
pendingMessagingTexts: new Map(),
189190
pendingMessagingTargets: new Map(),
190191
successfulCronAdds: 0,
@@ -215,6 +216,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
215216
const messagingToolSentTextsNormalized = state.messagingToolSentTextsNormalized;
216217
const messagingToolSentTargets = state.messagingToolSentTargets;
217218
const messagingToolSentMediaUrls = state.messagingToolSentMediaUrls;
219+
const messagingToolSourceReplyPayloads = state.messagingToolSourceReplyPayloads;
218220
const pendingMessagingTexts = state.pendingMessagingTexts;
219221
const pendingMessagingTargets = state.pendingMessagingTargets;
220222
const pendingBlockReplyTasks = new Set<Promise<void>>();
@@ -375,6 +377,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
375377
const MAX_MESSAGING_SENT_TEXTS = 200;
376378
const MAX_MESSAGING_SENT_TARGETS = 200;
377379
const MAX_MESSAGING_SENT_MEDIA_URLS = 200;
380+
const MAX_MESSAGING_SOURCE_REPLY_PAYLOADS = 200;
378381
const trimMessagingToolSent = () => {
379382
if (messagingToolSentTexts.length > MAX_MESSAGING_SENT_TEXTS) {
380383
const overflow = messagingToolSentTexts.length - MAX_MESSAGING_SENT_TEXTS;
@@ -389,6 +392,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
389392
const overflow = messagingToolSentMediaUrls.length - MAX_MESSAGING_SENT_MEDIA_URLS;
390393
messagingToolSentMediaUrls.splice(0, overflow);
391394
}
395+
if (messagingToolSourceReplyPayloads.length > MAX_MESSAGING_SOURCE_REPLY_PAYLOADS) {
396+
const overflow =
397+
messagingToolSourceReplyPayloads.length - MAX_MESSAGING_SOURCE_REPLY_PAYLOADS;
398+
messagingToolSourceReplyPayloads.splice(0, overflow);
399+
}
392400
};
393401

394402
const ensureCompactionPromise = () => {
@@ -968,6 +976,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
968976
messagingToolSentTextsNormalized.length = 0;
969977
messagingToolSentTargets.length = 0;
970978
messagingToolSentMediaUrls.length = 0;
979+
messagingToolSourceReplyPayloads.length = 0;
971980
pendingMessagingTexts.clear();
972981
pendingMessagingTargets.clear();
973982
state.successfulCronAdds = 0;
@@ -1135,6 +1144,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
11351144
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
11361145
getMessagingToolSentMediaUrls: () => messagingToolSentMediaUrls.slice(),
11371146
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
1147+
getMessagingToolSourceReplyPayloads: () => messagingToolSourceReplyPayloads.slice(),
11381148
getHeartbeatToolResponse: () =>
11391149
state.heartbeatToolResponse ? { ...state.heartbeatToolResponse } : undefined,
11401150
getPendingToolMediaReply: () => readPendingToolMediaReply(state),

src/agents/pi-tool-handler-state.test-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function createBaseToolHandlerState() {
2222
messagingToolSentTexts: [] as string[],
2323
messagingToolSentTextsNormalized: [] as string[],
2424
messagingToolSentMediaUrls: [] as string[],
25+
messagingToolSourceReplyPayloads: [],
2526
messagingToolSentTargets: [] as unknown[],
2627
deterministicApprovalPromptSent: false,
2728
blockBuffer: "",

0 commit comments

Comments
 (0)