Skip to content

Commit 98ce1c2

Browse files
committed
fix: hide commentary partial leaks until final answer (openclaw#59643) (thanks @ringlochid)
1 parent 7bef5a7 commit 98ce1c2

7 files changed

Lines changed: 64 additions & 86 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai
7171
- Telegram/local Bot API: honor `channels.telegram.apiRoot` for buffered media downloads, add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP setups, and require `channels.telegram.trustedLocalFileRoots` before reading absolute Bot API `file_path` values. (#59544, #60705) Thanks @SARAMALI15792 and @obviyus.
7272
- Outbound/sanitizer: strip leaked `<tool_call>`, `<function_calls>`, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg.
7373
- Agents/output delivery: suppress `phase:”commentary”` assistant text at the embedded subscribe boundary so internal planning text cannot leak into user-visible replies or Telegram partials. (#61282) Thanks @mbelinky.
74+
- Agents/streaming: keep commentary-only partials hidden until `final_answer` is available and buffer OpenAI Responses websocket text deltas until phase metadata arrives, so commentary does not leak into visible embedded replies. (#59643) Thanks @ringlochid.
7475
- Agents/errors: surface an explicit disk-full message when local session or transcript writes fail with `ENOSPC`/`disk full`, so those runs stop degrading into opaque `NO_REPLY`-style failures. Thanks @vincentkoc.
7576
- Exec approvals: remove heuristic command-obfuscation gating from host exec so gateway and node runs rely on explicit policy, allowlist, and strict inline-eval rules only.
7677
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.

src/agents/openai-ws-stream.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,7 +1839,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
18391839
]);
18401840
});
18411841

1842-
it("degrades safely when a text delta arrives before its item mapping", async () => {
1842+
it("buffers text deltas until item mapping is available", async () => {
18431843
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase-late-map");
18441844
const stream = streamFn(
18451845
modelStub as Parameters<typeof streamFn>[0],
@@ -1910,12 +1910,12 @@ describe("createOpenAIWebSocketStreamFn", () => {
19101910
const deltas = events.filter((event) => event.type === "text_delta");
19111911
expect(deltas).toHaveLength(2);
19121912
expect(deltas[0]).toMatchObject({ delta: "Working" });
1913-
expect(deltas[0]?.partial?.phase).toBeUndefined();
1913+
expect(deltas[0]?.partial?.phase).toBe("commentary");
19141914
expect(deltas[0]?.partial?.content).toEqual([
19151915
{
19161916
type: "text",
19171917
text: "Working",
1918-
textSignature: JSON.stringify({ v: 1, id: "item_late" }),
1918+
textSignature: JSON.stringify({ v: 1, id: "item_late", phase: "commentary" }),
19191919
},
19201920
]);
19211921
expect(deltas[1]).toMatchObject({ delta: "..." });

src/agents/openai-ws-stream.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,10 @@ export function createOpenAIWebSocketStreamFn(
907907
}
908908

909909
const outputItemPhaseById = new Map<string, OpenAIResponsesAssistantPhase | undefined>();
910+
const outputTextByPart = new Map<string, string>();
911+
const emittedTextByPart = new Map<string, string>();
912+
const getOutputTextKey = (itemId: string, contentIndex: number) =>
913+
`${itemId}:${contentIndex}`;
910914
const emitTextDelta = (params: {
911915
fullText: string;
912916
deltaText: string;
@@ -946,13 +950,33 @@ export function createOpenAIWebSocketStreamFn(
946950
partial: partialMsg,
947951
});
948952
};
953+
const emitBufferedTextDelta = (params: { itemId: string; contentIndex: number }) => {
954+
const key = getOutputTextKey(params.itemId, params.contentIndex);
955+
const fullText = outputTextByPart.get(key) ?? "";
956+
const emittedText = emittedTextByPart.get(key) ?? "";
957+
if (!fullText || fullText === emittedText) {
958+
return;
959+
}
960+
const deltaText = fullText.startsWith(emittedText)
961+
? fullText.slice(emittedText.length)
962+
: fullText;
963+
emittedTextByPart.set(key, fullText);
964+
emitTextDelta({
965+
fullText,
966+
deltaText,
967+
itemId: params.itemId,
968+
contentIndex: params.contentIndex,
969+
});
970+
};
949971
const capturedContextLength = context.messages.length;
950972
let sawWsOutput = false;
951973

952974
try {
953975
await new Promise<void>((resolve, reject) => {
954976
const abortHandler = () => {
955977
outputItemPhaseById.clear();
978+
outputTextByPart.clear();
979+
emittedTextByPart.clear();
956980
cleanup();
957981
reject(new Error("aborted"));
958982
};
@@ -964,6 +988,8 @@ export function createOpenAIWebSocketStreamFn(
964988

965989
const closeHandler = (code: number, reason: string) => {
966990
outputItemPhaseById.clear();
991+
outputTextByPart.clear();
992+
emittedTextByPart.clear();
967993
cleanup();
968994
const closeInfo = session.manager.lastCloseInfo;
969995
reject(
@@ -986,10 +1012,6 @@ export function createOpenAIWebSocketStreamFn(
9861012
unsubscribe();
9871013
};
9881014

989-
const outputTextByPart = new Map<string, string>();
990-
const getOutputTextKey = (itemId: string, contentIndex: number) =>
991-
`${itemId}:${contentIndex}`;
992-
9931015
const unsubscribe = session.manager.onMessage((event) => {
9941016
if (
9951017
event.type === "response.output_item.added" ||
@@ -1014,6 +1036,15 @@ export function createOpenAIWebSocketStreamFn(
10141036
? normalizeAssistantPhase((event.item as { phase?: unknown }).phase)
10151037
: undefined;
10161038
outputItemPhaseById.set(event.item.id, itemPhase);
1039+
for (const key of outputTextByPart.keys()) {
1040+
if (key.startsWith(`${event.item.id}:`)) {
1041+
const [, contentIndexText] = key.split(":");
1042+
emitBufferedTextDelta({
1043+
itemId: event.item.id,
1044+
contentIndex: Number.parseInt(contentIndexText ?? "0", 10) || 0,
1045+
});
1046+
}
1047+
}
10171048
}
10181049
return;
10191050
}
@@ -1022,26 +1053,22 @@ export function createOpenAIWebSocketStreamFn(
10221053
const key = getOutputTextKey(event.item_id, event.content_index);
10231054
const nextText = `${outputTextByPart.get(key) ?? ""}${event.delta}`;
10241055
outputTextByPart.set(key, nextText);
1025-
emitTextDelta({
1026-
fullText: nextText,
1027-
deltaText: event.delta,
1028-
itemId: event.item_id,
1029-
contentIndex: event.content_index,
1030-
});
1056+
if (outputItemPhaseById.has(event.item_id)) {
1057+
emitBufferedTextDelta({
1058+
itemId: event.item_id,
1059+
contentIndex: event.content_index,
1060+
});
1061+
}
10311062
return;
10321063
}
10331064

10341065
if (event.type === "response.output_text.done") {
10351066
const key = getOutputTextKey(event.item_id, event.content_index);
1036-
const previousText = outputTextByPart.get(key) ?? "";
1037-
if (event.text && event.text !== previousText) {
1067+
if (event.text && event.text !== outputTextByPart.get(key)) {
10381068
outputTextByPart.set(key, event.text);
1039-
const deltaText = event.text.startsWith(previousText)
1040-
? event.text.slice(previousText.length)
1041-
: event.text;
1042-
emitTextDelta({
1043-
fullText: event.text,
1044-
deltaText,
1069+
}
1070+
if (outputItemPhaseById.has(event.item_id)) {
1071+
emitBufferedTextDelta({
10451072
itemId: event.item_id,
10461073
contentIndex: event.content_index,
10471074
});
@@ -1052,6 +1079,7 @@ export function createOpenAIWebSocketStreamFn(
10521079
if (event.type === "response.completed") {
10531080
outputItemPhaseById.clear();
10541081
outputTextByPart.clear();
1082+
emittedTextByPart.clear();
10551083
cleanup();
10561084
session.lastContextLength = capturedContextLength;
10571085
const assistantMsg = buildAssistantMessageFromResponse(event.response, {
@@ -1066,6 +1094,7 @@ export function createOpenAIWebSocketStreamFn(
10661094
} else if (event.type === "response.failed") {
10671095
outputItemPhaseById.clear();
10681096
outputTextByPart.clear();
1097+
emittedTextByPart.clear();
10691098
cleanup();
10701099
reject(
10711100
new OpenAIWebSocketRuntimeError(
@@ -1079,6 +1108,7 @@ export function createOpenAIWebSocketStreamFn(
10791108
} else if (event.type === "error") {
10801109
outputItemPhaseById.clear();
10811110
outputTextByPart.clear();
1111+
emittedTextByPart.clear();
10821112
cleanup();
10831113
reject(
10841114
new OpenAIWebSocketRuntimeError(

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

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ describe("handleMessageUpdate", () => {
254254
expect(ctx.state.blockBuffer).toBe("");
255255
});
256256

257-
it("replaces commentary preview text when a final_answer partial arrives", () => {
257+
it("suppresses commentary partials until a final_answer partial arrives", () => {
258258
const onAgentEvent = vi.fn();
259259
const ctx = {
260260
params: {
@@ -339,20 +339,12 @@ describe("handleMessageUpdate", () => {
339339
},
340340
} as never);
341341

342-
expect(onAgentEvent).toHaveBeenCalledTimes(2);
342+
expect(onAgentEvent).toHaveBeenCalledTimes(1);
343343
expect(onAgentEvent.mock.calls[0]?.[0]).toMatchObject({
344-
stream: "assistant",
345-
data: {
346-
text: "Working...",
347-
delta: "Working...",
348-
},
349-
});
350-
expect(onAgentEvent.mock.calls[1]?.[0]).toMatchObject({
351344
stream: "assistant",
352345
data: {
353346
text: "Done.",
354-
delta: "",
355-
replace: true,
347+
delta: "Done.",
356348
},
357349
});
358350
});

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

Lines changed: 7 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -350,33 +350,11 @@ export function handleMessageUpdate(
350350
? (assistantRecord.partial as AssistantMessage)
351351
: msg;
352352
const phaseAwareVisibleText = coerceText(extractAssistantVisibleText(partialAssistant)).trim();
353-
const partialVisiblePhase = normalizeAssistantPhase(
354-
(partialAssistant as { phase?: unknown }).phase,
355-
);
356-
const explicitStructuredPartialPhases = new Set<AssistantPhase>();
357-
if (Array.isArray(partialAssistant.content)) {
358-
for (const block of partialAssistant.content) {
359-
if (!block || typeof block !== "object") {
360-
continue;
361-
}
362-
const record = block as { type?: unknown; textSignature?: unknown };
363-
if (record.type !== "text") {
364-
continue;
365-
}
366-
const signaturePhase = getAssistantTextSignaturePhase(record.textSignature);
367-
if (signaturePhase) {
368-
explicitStructuredPartialPhases.add(signaturePhase);
369-
}
370-
}
353+
const deliveryPhase = resolveAssistantDeliveryPhase(partialAssistant);
354+
if (deliveryPhase === "commentary" && !phaseAwareVisibleText) {
355+
return;
371356
}
372-
const structuredPartialPhase =
373-
explicitStructuredPartialPhases.size === 1
374-
? [...explicitStructuredPartialPhases][0]
375-
: undefined;
376-
const shouldUsePhaseAwareBlockReply = Boolean(partialVisiblePhase || structuredPartialPhase);
377-
const shouldDeferTextEndBlockReply =
378-
shouldUsePhaseAwareBlockReply &&
379-
(partialVisiblePhase ?? structuredPartialPhase) !== "final_answer";
357+
const shouldUsePhaseAwareBlockReply = Boolean(deliveryPhase);
380358

381359
if (chunk) {
382360
ctx.state.deltaBuffer += chunk;
@@ -438,20 +416,15 @@ export function handleMessageUpdate(
438416
ctx.blockChunker?.reset();
439417
}
440418
const blockReplyChunk = replace ? cleanedText : deltaText;
441-
if (!shouldDeferTextEndBlockReply && blockReplyChunk) {
419+
if (blockReplyChunk) {
442420
if (ctx.blockChunker) {
443421
ctx.blockChunker.append(blockReplyChunk);
444422
} else {
445423
ctx.state.blockBuffer += blockReplyChunk;
446424
}
447425
}
448426

449-
if (
450-
!shouldDeferTextEndBlockReply &&
451-
evtType === "text_end" &&
452-
!ctx.state.lastBlockReplyText &&
453-
cleanedText
454-
) {
427+
if (evtType === "text_end" && !ctx.state.lastBlockReplyText && cleanedText) {
455428
if (ctx.blockChunker) {
456429
ctx.blockChunker.reset();
457430
ctx.blockChunker.append(cleanedText);
@@ -493,7 +466,6 @@ export function handleMessageUpdate(
493466

494467
if (
495468
!ctx.params.silentExpected &&
496-
!shouldDeferTextEndBlockReply &&
497469
ctx.params.onBlockReply &&
498470
ctx.blockChunking &&
499471
ctx.state.blockReplyBreak === "text_end"
@@ -503,7 +475,6 @@ export function handleMessageUpdate(
503475

504476
if (
505477
!ctx.params.silentExpected &&
506-
!shouldDeferTextEndBlockReply &&
507478
evtType === "text_end" &&
508479
ctx.state.blockReplyBreak === "text_end"
509480
) {
@@ -546,7 +517,7 @@ export function handleMessageEnd(
546517
});
547518

548519
const text = resolveSilentReplyFallbackText({
549-
text: ctx.stripBlockTags(rawVisibleText || rawText, { thinking: false, final: false }),
520+
text: ctx.stripBlockTags(rawVisibleText, { thinking: false, final: false }),
550521
messagingToolSentTexts: ctx.state.messagingToolSentTexts,
551522
});
552523
const rawThinking =
@@ -577,17 +548,6 @@ export function handleMessageEnd(
577548
return;
578549
}
579550

580-
if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) {
581-
const rawTrimmed = coerceText(rawText).trim();
582-
const rawStrippedFinal = rawTrimmed.replace(/<\s*\/?\s*final\s*>/gi, "").trim();
583-
const rawCandidate = rawStrippedFinal || rawTrimmed;
584-
if (rawCandidate) {
585-
const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate));
586-
cleanedText = parsedFallback.text ?? rawCandidate;
587-
({ mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedFallback));
588-
}
589-
}
590-
591551
const previousStreamedText = ctx.state.lastStreamedAssistantCleaned ?? "";
592552
const shouldReplaceFinalStream = Boolean(
593553
previousStreamedText && cleanedText && !cleanedText.startsWith(previousStreamedText),

src/agents/pi-embedded-utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ describe("extractAssistantVisibleText", () => {
594594
expect(extractAssistantVisibleText(msg)).toBe("Done.");
595595
});
596596

597-
it("falls back to commentary when final_answer is empty", () => {
597+
it("does not fall back to commentary when final_answer is empty", () => {
598598
const msg = makeAssistantMessage({
599599
role: "assistant",
600600
content: [
@@ -612,7 +612,7 @@ describe("extractAssistantVisibleText", () => {
612612
timestamp: Date.now(),
613613
});
614614

615-
expect(extractAssistantVisibleText(msg)).toBe("Working...");
615+
expect(extractAssistantVisibleText(msg)).toBe("");
616616
});
617617

618618
it("falls back to legacy unphased text when phased text is absent", () => {

src/agents/pi-embedded-utils.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,11 +336,6 @@ export function extractAssistantVisibleText(msg: AssistantMessage): string {
336336
return finalAnswerText;
337337
}
338338

339-
const commentaryText = extractAssistantTextForPhase(msg, "commentary");
340-
if (commentaryText.trim()) {
341-
return commentaryText;
342-
}
343-
344339
return extractAssistantTextForPhase(msg);
345340
}
346341

0 commit comments

Comments
 (0)