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.
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
textcontent items in an assistant message withjoinWith: "\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
2026.4.14(originally observed); upstream/main confirmed by community trace atcfda375bb6(2026-04-22) and7ddd815e469e(2026-04-28)openai-codex(api: openai-codex-responses)gpt-5.4Evidence
An interactive session, preceded by an assistant turn with two parallel tool calls, produced one assistant message containing two
textcontent items: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:extractAssistantVisibleTextatsrc/agents/pi-embedded-utils.ts:116extractAssistantTextForPhase(msg)atsrc/agents/pi-embedded-utils.ts:47-115, which concatenates all matching-phase text blocks withjoinWith: "\n"(line ~106)src/shared/chat-message-content.ts:155Codex re-check on 2026-04-28 against
7ddd815e469econfirmedhandleMessageEndatsrc/agents/pi-embedded-subscribe.handlers.messages.ts:675still emits joined text via the same path.Cross-surface blast radius
The shared extractor has at least four callers beyond Slack/Telegram delivery:
src/tui/tui-formatters.ts:300src/gateway/session-utils.fs.ts:637src/agents/tools/chat-history-text.tssrc/agents/pi-embedded-subscribe.handlers.messages.ts:546/:675Changing 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
extractAssistantTextForPhasejoining (preserves TUI / gateway / history). Apply a delivery-boundary selector inpi-embedded-subscribe.handlers.messages.tsthat 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, withconcatremaining the default for non-delivery callers andpick-lastset 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
textitems (or behavior change is explicit / documented if Option B is chosen).Relationship to #69737
Bug 1 in #69737 (raw
errorMessagesurfacing — single-file fallback chain inlifecycle.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.