Skip to content

[Bug][Codex Runtime]: Discord progress reasoning stream overwrites prior reasoning chunks #83983

@xueqingli1

Description

@xueqingli1

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:

  1. 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 });
  2. 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);
  3. 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}`;
  4. 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);
    }
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions