Skip to content

Commit 512c314

Browse files
committed
fix: canonicalize embedded reply payloads
1 parent e60928d commit 512c314

4 files changed

Lines changed: 125 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Browser: wait longer for existing-session Chrome MCP status and non-deep doctor probes so slow first attaches do not falsely report offline while keeping raw CDP status probes short. (#77473) Thanks @rubencu.
2222
- Exec approvals: keep `exec.approval.list` on the lightweight policy-summary path so listing pending approvals no longer loads the rich tree-sitter command explainer. (#76943) Thanks @rubencu.
2323
- Agents: surface concise default-visible warnings when `exec`/`bash` tool calls fail after the assistant claims success, while keeping raw stderr hidden unless verbose details are enabled. Fixes #60497. (#80003) Thanks @jbetala7.
24+
- Agents/Telegram: deliver the canonical final assistant answer instead of replaying accumulated pre-tool text blocks, preventing duplicate Telegram replies and raw-looking tool-output fragments from leaking into chat delivery. Fixes #79621 and #79986. Thanks @nonzeroclaw and @dudaefj.
2425
- Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle.
2526
- CLI/agent: let `openclaw agent --model` use the backend/admin Gateway scope without cached device-token scopes silently downscoping the request. (#78837) Thanks @VACInc.
2627
- CLI/help: keep help and version invocations configless while improving shared port, channel, plugin, task, session, message, pairing, and auth recovery text.

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1099,7 +1099,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
10991099
// When the model successfully produces post-tool text, lastAssistant has
11001100
// stopReason=end_turn. The incomplete-turn guard should not fire.
11011101
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1102-
payloadCount: 2,
1102+
payloadCount: 1,
11031103
aborted: false,
11041104
timedOut: false,
11051105
attempt: makeAttemptResult({

src/agents/pi-embedded-runner/run/payloads.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,81 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
8888
expectSinglePayloadText(payloads, "Fixed.");
8989
});
9090

91+
it("delivers only the final assistant answer when accumulated text includes pre-tool progress", () => {
92+
const payloads = buildPayloads({
93+
assistantTexts: ["I'll inspect that first.", "Done."],
94+
lastAssistant: {
95+
role: "assistant",
96+
stopReason: "stop",
97+
content: [
98+
{
99+
type: "text",
100+
text: "Done.",
101+
textSignature: JSON.stringify({
102+
v: 1,
103+
id: "item_final",
104+
phase: "final_answer",
105+
}),
106+
},
107+
],
108+
} as AssistantMessage,
109+
});
110+
111+
expectSinglePayloadText(payloads, "Done.");
112+
});
113+
114+
it("does not replay raw-looking accumulated tool output when final answer text is available", () => {
115+
const payloads = buildPayloads({
116+
assistantTexts: [
117+
"/root/openclaw/src/gateway/protocol/schema/protocol-schemas.ts:181: PluginControlUiDescriptorSchema,",
118+
"The schema export is fixed.",
119+
],
120+
lastAssistant: {
121+
role: "assistant",
122+
stopReason: "stop",
123+
content: [
124+
{
125+
type: "text",
126+
text: "The schema export is fixed.",
127+
textSignature: JSON.stringify({
128+
v: 1,
129+
id: "item_final",
130+
phase: "final_answer",
131+
}),
132+
},
133+
],
134+
} as AssistantMessage,
135+
});
136+
137+
expectSinglePayloadText(payloads, "The schema export is fixed.");
138+
});
139+
140+
it("ignores accumulated internal/status text after the final answer", () => {
141+
const payloads = buildPayloads({
142+
assistantTexts: [
143+
"Done.",
144+
"Background task done: Context engine turn maintenance. Rewrote 0 transcript entries and freed 0 bytes.",
145+
],
146+
lastAssistant: {
147+
role: "assistant",
148+
stopReason: "stop",
149+
content: [
150+
{
151+
type: "text",
152+
text: "Done.",
153+
textSignature: JSON.stringify({
154+
v: 1,
155+
id: "item_final",
156+
phase: "final_answer",
157+
}),
158+
},
159+
],
160+
} as AssistantMessage,
161+
});
162+
163+
expectSinglePayloadText(payloads, "Done.");
164+
});
165+
91166
it("surfaces concise exec tool errors when verbose mode is off", () => {
92167
const payloads = buildPayloads({
93168
lastToolError: { toolName: "exec", error: "command failed" },
@@ -283,6 +358,32 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
283358
expect(payloads[0]?.mediaUrls).toEqual(["/tmp/reply-image.png"]);
284359
});
285360

361+
it("keeps media directives when collapsing accumulated pre-tool text to the final answer", () => {
362+
const payloads = buildPayloads({
363+
assistantTexts: ["Preparing the image...", "Attached image"],
364+
lastAssistant: {
365+
role: "assistant",
366+
stopReason: "stop",
367+
content: [
368+
{
369+
type: "text",
370+
text: "MEDIA:/tmp/reply-image.png\nAttached image",
371+
textSignature: JSON.stringify({
372+
v: 1,
373+
id: "item_final",
374+
phase: "final_answer",
375+
}),
376+
},
377+
],
378+
} as AssistantMessage,
379+
});
380+
381+
expect(payloads).toHaveLength(1);
382+
expect(payloads[0]?.text).toBe("Attached image");
383+
expect(payloads[0]?.mediaUrl).toBe("/tmp/reply-image.png");
384+
expect(payloads[0]?.mediaUrls).toEqual(["/tmp/reply-image.png"]);
385+
});
386+
286387
it("uses raw final assistant text when visible-text extraction removed a media-only directive line", () => {
287388
const payloads = buildPayloads({
288389
lastAssistant: {

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ function resolveRawAssistantAnswerText(lastAssistant: AssistantMessage | undefin
108108
);
109109
}
110110

111+
function normalizeReplyTextForComparison(text: string): string {
112+
return normalizeTextForComparison(parseReplyDirectives(text).text ?? "");
113+
}
114+
111115
function shouldIncludeToolErrorDetails(params: {
112116
lastToolError: ToolErrorSummary;
113117
isCronTrigger?: boolean;
@@ -357,16 +361,27 @@ export function buildEmbeddedRunPayloads(params: {
357361
(!assistantTextsHaveMedia &&
358362
normalizedAssistantTexts.length > 0 &&
359363
normalizedAssistantTexts === normalizedRawAnswerText));
364+
const fallbackAnswerSourceText =
365+
shouldPreferRawAnswerText && fallbackRawAnswerText ? fallbackRawAnswerText : fallbackAnswerText;
366+
const normalizedFallbackAnswerSourceText = fallbackAnswerSourceText
367+
? normalizeReplyTextForComparison(fallbackAnswerSourceText)
368+
: "";
369+
const shouldUseCanonicalFinalAnswer =
370+
nonEmptyAssistantTexts.length > 1 &&
371+
fallbackAnswerSourceText.length > 0 &&
372+
normalizedFallbackAnswerSourceText.length > 0;
360373
const hasAssistantTextPayload = nonEmptyAssistantTexts.length > 0;
361374
const answerTexts = suppressAssistantArtifacts
362375
? []
363-
: (shouldPreferRawAnswerText && fallbackRawAnswerText
364-
? [fallbackRawAnswerText]
365-
: hasAssistantTextPayload
366-
? nonEmptyAssistantTexts
367-
: fallbackAnswerText
368-
? [fallbackAnswerText]
369-
: []
376+
: (shouldUseCanonicalFinalAnswer
377+
? [fallbackAnswerSourceText]
378+
: shouldPreferRawAnswerText && fallbackRawAnswerText
379+
? [fallbackRawAnswerText]
380+
: hasAssistantTextPayload
381+
? nonEmptyAssistantTexts
382+
: fallbackAnswerText
383+
? [fallbackAnswerText]
384+
: []
370385
).filter((text) => !shouldSuppressRawErrorText(text));
371386

372387
let hasUserFacingAssistantReply = false;

0 commit comments

Comments
 (0)