Summary
In Discord streaming.mode: "progress", reasoning/thinking trace updates can render one token or word at a time, but each next reasoning chunk overwrites the previous chunk in the progress draft. The visible result can degrade to a single bullet with only the final tiny chunk, for example:
This looks like a progress-draft rendering bug, not a model/runtime reasoning failure. The Codex app-server path receives reasoning deltas correctly, but Discord appears to format each delta as a complete Reasoning: snapshot before the draft-preview merge step.
Environment where observed
- OpenClaw:
2026.5.18 (50a2481)
- Channel: Discord guild text channel
- Discord streaming mode:
progress
- Progress config includes tool progress enabled and
maxLines > 1
- Runtime/model: native Codex app-server /
gpt-5.5
- Reasoning visibility: streaming reasoning enabled
User-visible symptom
During a Discord turn, the reasoning trace appears to stream one word at a time. Instead of accumulating into a readable thinking/progress line, the next word immediately replaces the last word. At the end, the progress draft can contain only a bullet and the final chunk, such as !.
Expected behavior:
- Reasoning deltas should accumulate or coalesce into a stable readable reasoning/progress line.
- The final visible progress line should not be only the last delta.
- Display formatting such as
Reasoning: should not be used to infer whether a payload is a full snapshot versus a delta.
Source-level repro / suspected root cause
On current upstream main at b86435f0b597e58cd2eb904fe7fa12f881ca4dba:
-
Codex app-server accumulates reasoning internally, but forwards only the current delta to the reply callback:
extensions/codex/src/app-server/event-projector.ts:428-436
|
private async handleReasoningDelta(params: JsonObject): Promise<void> { |
|
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "reasoning"; |
|
const delta = readString(params, "delta") ?? ""; |
|
if (!delta) { |
|
return; |
|
} |
|
this.reasoningStarted = true; |
|
this.reasoningTextByItem.set(itemId, `${this.reasoningTextByItem.get(itemId) ?? ""}${delta}`); |
|
await this.params.onReasoningStream?.({ text: delta }); |
const delta = readString(params, "delta") ?? "";
...
this.reasoningTextByItem.set(itemId, `${this.reasoningTextByItem.get(itemId) ?? ""}${delta}`);
await this.params.onReasoningStream?.({ text: delta });
-
Discord formats each callback payload before passing it into the draft-preview reasoning merge:
extensions/discord/src/monitor/message-handler.process.ts:825-830
|
onReasoningStream: async (payload) => { |
|
await statusReactions.setThinking(); |
|
const formattedText = payload?.text |
|
? formatReasoningMessage(payload.text) |
|
: undefined; |
|
await draftPreview.pushReasoningProgress(formattedText); |
const formattedText = payload?.text
? formatReasoningMessage(payload.text)
: undefined;
await draftPreview.pushReasoningProgress(formattedText);
-
formatReasoningMessage() wraps every non-empty chunk with a Reasoning: prefix:
src/agents/pi-embedded-utils.ts:164-177
|
export function formatReasoningMessage(text: string): string { |
|
const trimmed = text.trim(); |
|
if (!trimmed) { |
|
return ""; |
|
} |
|
// Show reasoning in italics (cursive) for markdown-friendly surfaces (Discord, etc.). |
|
// Keep the plain "Reasoning:" prefix so existing parsing/detection keeps working. |
|
// Note: Underscore markdown cannot span multiple lines on Telegram, so we wrap |
|
// each non-empty line separately. |
|
const italicLines = trimmed |
|
.split("\n") |
|
.map((line) => (line ? `_${line}_` : line)) |
|
.join("\n"); |
|
return `Reasoning:\n${italicLines}`; |
return `Reasoning:\n${italicLines}`;
-
The Discord draft-preview merge treats any incoming text that starts with Reasoning: as a full snapshot, so a formatted delta is mistaken for a complete replacement:
extensions/discord/src/monitor/message-handler.draft-preview.ts:396-412
|
function mergeReasoningProgressText(current: string, incoming: string): string { |
|
if (!current) { |
|
return incoming; |
|
} |
|
const normalizedCurrent = normalizeReasoningProgressLine(current); |
|
const normalizedIncoming = normalizeReasoningProgressLine(incoming); |
|
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) { |
|
return current; |
|
} |
|
if (isReasoningSnapshotText(incoming) || normalizedIncoming.startsWith(normalizedCurrent)) { |
|
return incoming; |
|
} |
|
return `${current}${incoming}`; |
|
} |
|
|
|
function isReasoningSnapshotText(text: string): boolean { |
|
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text); |
if (isReasoningSnapshotText(incoming) || normalizedIncoming.startsWith(normalizedCurrent)) {
return incoming;
}
...
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text);
}
-
The reasoning progress renderer then replaces the previous reasoning line with the newly merged line:
extensions/discord/src/monitor/message-handler.draft-preview.ts:229-256
|
async pushReasoningProgress(text?: string) { |
|
if (!draftStream || discordStreamMode !== "progress" || !text) { |
|
return; |
|
} |
|
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text); |
|
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText); |
|
if (!normalized) { |
|
return; |
|
} |
|
if (previewToolProgressEnabled && !previewToolProgressSuppressed) { |
|
const priorIndex = |
|
lastReasoningProgressLine === undefined |
|
? -1 |
|
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine); |
|
if (priorIndex >= 0) { |
|
previewToolProgressLines = [...previewToolProgressLines]; |
|
previewToolProgressLines[priorIndex] = normalized; |
|
} else { |
|
previewToolProgressLines = [...previewToolProgressLines, normalized].slice( |
|
-resolveChannelProgressDraftMaxLines(params.discordConfig), |
|
); |
|
} |
|
lastReasoningProgressLine = normalized; |
|
} |
|
const alreadyStarted = progressDraftGate.hasStarted; |
|
await progressDraftGate.noteWork(); |
|
if (alreadyStarted && progressDraftGate.hasStarted) { |
|
await renderProgressDraft(); |
Because each delta is formatted as Reasoning:\n_<delta>_, every delta satisfies isReasoningSnapshotText(incoming), causing the previous accumulated reasoning line to be replaced.
A minimal reproduction of the merge logic shape:
function formatReasoningMessage(text) {
const trimmed = text.trim();
if (!trimmed) return "";
return `Reasoning:\n_${trimmed}_`;
}
function normalizeReasoningProgressLine(text) {
return text.replace(/^\s*(?:>\s*)?Reasoning:\s*/i, "").replace(/\s+/g, " ").trim();
}
function isReasoningSnapshotText(text) {
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text);
}
function mergeReasoningProgressText(current, incoming) {
if (!current) return incoming;
const normalizedCurrent = normalizeReasoningProgressLine(current);
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) return current;
if (isReasoningSnapshotText(incoming) || normalizedIncoming.startsWith(normalizedCurrent)) return incoming;
return `${current}${incoming}`;
}
let current = "";
for (const delta of ["Considering", " plugin", " installation", "!"]) {
current = mergeReasoningProgressText(current, formatReasoningMessage(delta));
console.log(current);
}
The displayed reasoning candidate ends up as the latest formatted chunk instead of an accumulated sentence.
Suggested fix direction
The robust fix is to separate reasoning payload semantics from display formatting:
- Merge raw reasoning text first, then apply
formatReasoningMessage() once for display.
- Or pass explicit payload metadata such as
{ text, delta, mode: "delta" | "snapshot", itemId }, so Discord does not infer delta-vs-snapshot from the Reasoning: display prefix.
- Avoid feeding already display-formatted
Reasoning: text into mergeReasoningProgressText() unless the payload is actually a full snapshot.
A narrow fix could be in the Discord path:
- pass raw
payload.text into pushReasoningProgress,
- let
pushReasoningProgress merge raw text,
- format the normalized/merged reasoning only when inserting it into
previewToolProgressLines.
Acceptance criteria
- In Discord
streaming.mode: "progress", simulated reasoning deltas such as "Considering", " plugin", " installation", "!" render as one accumulated/coalesced reasoning line, not only !.
- A true full reasoning snapshot still replaces the previous reasoning line when appropriate.
- Existing final-answer delivery behavior is unchanged.
- Tool progress rows continue to merge/update as before.
- Regression coverage exists for delta-style reasoning and snapshot-style reasoning in
message-handler.draft-preview or adjacent Discord process tests.
Related issues
What was not tested
I do not have a live Discord recording attached here. The symptom was observed in a downstream Discord deployment, and the likely cause is source-reproducible from the current delta formatting/merge path above.
Summary
In Discord
streaming.mode: "progress", reasoning/thinking trace updates can render one token or word at a time, but each next reasoning chunk overwrites the previous chunk in the progress draft. The visible result can degrade to a single bullet with only the final tiny chunk, for example:This looks like a progress-draft rendering bug, not a model/runtime reasoning failure. The Codex app-server path receives reasoning deltas correctly, but Discord appears to format each delta as a complete
Reasoning:snapshot before the draft-preview merge step.Environment where observed
2026.5.18 (50a2481)progressmaxLines> 1gpt-5.5User-visible symptom
During a Discord turn, the reasoning trace appears to stream one word at a time. Instead of accumulating into a readable thinking/progress line, the next word immediately replaces the last word. At the end, the progress draft can contain only a bullet and the final chunk, such as
!.Expected behavior:
Reasoning:should not be used to infer whether a payload is a full snapshot versus a delta.Source-level repro / suspected root cause
On current upstream
mainatb86435f0b597e58cd2eb904fe7fa12f881ca4dba:Codex app-server accumulates reasoning internally, but forwards only the current delta to the reply callback:
extensions/codex/src/app-server/event-projector.ts:428-436openclaw/extensions/codex/src/app-server/event-projector.ts
Lines 428 to 436 in b86435f
Discord formats each callback payload before passing it into the draft-preview reasoning merge:
extensions/discord/src/monitor/message-handler.process.ts:825-830openclaw/extensions/discord/src/monitor/message-handler.process.ts
Lines 825 to 830 in b86435f
formatReasoningMessage()wraps every non-empty chunk with aReasoning:prefix:src/agents/pi-embedded-utils.ts:164-177openclaw/src/agents/pi-embedded-utils.ts
Lines 164 to 177 in b86435f
The Discord draft-preview merge treats any incoming text that starts with
Reasoning:as a full snapshot, so a formatted delta is mistaken for a complete replacement:extensions/discord/src/monitor/message-handler.draft-preview.ts:396-412openclaw/extensions/discord/src/monitor/message-handler.draft-preview.ts
Lines 396 to 412 in b86435f
The reasoning progress renderer then replaces the previous reasoning line with the newly merged line:
extensions/discord/src/monitor/message-handler.draft-preview.ts:229-256openclaw/extensions/discord/src/monitor/message-handler.draft-preview.ts
Lines 229 to 256 in b86435f
Because each delta is formatted as
Reasoning:\n_<delta>_, every delta satisfiesisReasoningSnapshotText(incoming), causing the previous accumulated reasoning line to be replaced.A minimal reproduction of the merge logic shape:
The displayed reasoning candidate ends up as the latest formatted chunk instead of an accumulated sentence.
Suggested fix direction
The robust fix is to separate reasoning payload semantics from display formatting:
formatReasoningMessage()once for display.{ text, delta, mode: "delta" | "snapshot", itemId }, so Discord does not infer delta-vs-snapshot from theReasoning:display prefix.Reasoning:text intomergeReasoningProgressText()unless the payload is actually a full snapshot.A narrow fix could be in the Discord path:
payload.textintopushReasoningProgress,pushReasoningProgressmerge raw text,previewToolProgressLines.Acceptance criteria
streaming.mode: "progress", simulated reasoning deltas such as"Considering"," plugin"," installation","!"render as one accumulated/coalesced reasoning line, not only!.message-handler.draft-previewor adjacent Discord process tests.Related issues
What was not tested
I do not have a live Discord recording attached here. The symptom was observed in a downstream Discord deployment, and the likely cause is source-reproducible from the current delta formatting/merge path above.