Skip to content

Delivery layer concatenates multiple text content items — pick-one policy needed (split from #69737) #74674

@alexisperumal

Description

@alexisperumal

Summary

Splitting Bug 2 out of #69737 per @martingarramon's recommendation in the source-trace comment (different blast radius from Bug 1, which stays scoped to #69737).

The shared visible-text extractor concatenates all matching text content items in an assistant message with joinWith: "\n". When a turn produces multiple text blocks (e.g., one per branch after parallel tool calls), the user-facing delivery layer posts the concatenation — visible as a near-duplicate paragraph with slight wording variation. The extractor is also used by non-delivery surfaces, so the fix is a policy call about scope.

Environment

  • OpenClaw: 2026.4.14 (originally observed); upstream/main confirmed by community trace at cfda375bb6 (2026-04-22) and 7ddd815e469e (2026-04-28)
  • Provider: openai-codex (api: openai-codex-responses)
  • Model: gpt-5.4
  • Delivery channel verified: Slack

Evidence

An interactive session, preceded by an assistant turn with two parallel tool calls, produced one assistant message containing two text content items:

content:
  - type: text
    text: "<single paragraph answer to the user's question, variant 1>"  (~120 chars)
  - type: text
    text: "<same paragraph, slightly different wording>"                  (~136 chars)
stopReason: stop

Slack delivery concatenated both items into one post, visible to the end user as a near-duplicate paragraph with slight wording variation between the two halves. Treat the model's multi-text emission as something we can't rely on not happening.

Source trace (per #69737 community trace, 2026-04-22)

Delivery path:

  • src/agents/pi-embedded-subscribe.handlers.messages.ts:546:
    const rawVisibleText = coerceChatContentText(extractAssistantVisibleText(assistantMessage));
  • extractAssistantVisibleText at src/agents/pi-embedded-utils.ts:116
  • extractAssistantTextForPhase(msg) at src/agents/pi-embedded-utils.ts:47-115, which concatenates all matching-phase text blocks with joinWith: "\n" (line ~106)
  • Parallel function (same shape) at src/shared/chat-message-content.ts:155
  • No "pick-one" logic in either.

Codex re-check on 2026-04-28 against 7ddd815e469e confirmed handleMessageEnd at src/agents/pi-embedded-subscribe.handlers.messages.ts:675 still emits joined text via the same path.

Cross-surface blast radius

The shared extractor has at least four callers beyond Slack/Telegram delivery:

  • TUI: src/tui/tui-formatters.ts:300
  • Gateway session dump: src/gateway/session-utils.fs.ts:637
  • Chat-history tool: src/agents/tools/chat-history-text.ts
  • Slack/Telegram delivery: src/agents/pi-embedded-subscribe.handlers.messages.ts:546 / :675

Changing the default from joinWith: "\n" to pick-last would flip behavior across all four surfaces. TUI / gateway dumps / chat-history may legitimately want concatenated blocks (full transcript fidelity); only the delivery boundary is unambiguously "post one thing to the user."

Proposed fix — two viable shapes

Both scope cleanly; preference is a maintainer call.

Option A — delivery-only override. Keep extractAssistantTextForPhase joining (preserves TUI / gateway / history). Apply a delivery-boundary selector in pi-embedded-subscribe.handlers.messages.ts that picks one text item (last non-empty by default).

Option B — config flag on the shared extractor. Add a selection: 'concat' | 'pick-last' | 'pick-longest' (or similar) parameter, with concat remaining the default for non-delivery callers and pick-last set on delivery sites.

Option A has a tighter blast radius. Option B is more flexible but adds config surface that other callers may need to reason about.

Acceptance / regression

  • Repro: send the agent a request that triggers parallel tool calls; confirm the user-facing channel receives a single text block rather than the concatenation.
  • Regression check: TUI rendering, gateway session dump, and chat-history tool output unchanged for messages with multiple text items (or behavior change is explicit / documented if Option B is chosen).

Relationship to #69737

Bug 1 in #69737 (raw errorMessage surfacing — single-file fallback chain in lifecycle.ts) stays scoped to that issue. This issue is Bug 2 only. Per @martingarramon's recommendation: different blast radius, different reviewer pool, likely different timelines.

Offer

Happy to test a PR against our production deployment and provide additional evidence (scrubbed of user data) if useful.

Drafted by Claude Code (my coding agent) and reviewed by me before posting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleMarked as stale due to inactivity

    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