fix(ai): assistant text blocks are run together on replay - #115743
Conversation
53dc9a7 to
f1bff00
Compare
|
Codex review: needs maintainer review before merge. Reviewed August 1, 2026, 3:57 AM ET / 07:57 UTC. ClawSweeper reviewWhat this changesThe PR preserves boundaries between multiple assistant text blocks during OpenAI-compatible Chat Completions transcript replay by joining them with a newline and testing the resulting request content. Merge readinessThis PR is still necessary: current Priority: P2 Review scores
Verification
How this fits togetherThe AI package converts stored conversation messages into OpenAI Chat Completions request messages. That serialized transcript becomes the prior context sent to OpenAI-compatible providers, so a flattened assistant turn affects later model responses. flowchart LR
A[Conversation history] --> B[Assistant content blocks]
B --> C[Chat Completions converter]
C --> D[Serialized request transcript]
D --> E[OpenAI-compatible provider]
E --> F[Next model response]
Before merge
Agent review detailsSecurityNone. PR surfaceSource +2, Tests +52. Total +54 across 2 files. View PR surface stats
Review metricsNone. Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Land the focused newline repair and its regression test once the current GitHub merge result remains clean; retain the existing special handling for thinking content and tool calls. Do we have a high-confidence way to reproduce the issue? Yes—current Is this the best way to solve the issue? Yes—the newline is the narrowest repair because it changes only string flattening for adjacent assistant text blocks and matches AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 8317e23c1c2b. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (7 earlier review cycles)
|
f1bff00 to
b97f49b
Compare
Chat Completions replay flattened every assistant text block with an empty separator, so two distinct blocks came back as one word-joined sentence. The same message shape survives distinctly on the Anthropic, Responses and Mistral lanes, and the string-content flattener for strict OpenAI-compatible servers already joins with a newline. Two blocks arise routinely: streaming opens a new text block after a tool call, and cross-model replay converts a thinking block into a text block.
b97f49b to
81bc4c5
Compare
|
Refreshed onto current main ( Exact-head CI on
Locally on the merged tree, the changed module's full test surface passes: |
|
The review above was taken while the exact-head checks were still running — they have since completed green (82 passed / 0 failed / 0 pending, detail in the comment above), so the "checks still in progress" half of the remaining item is resolved on this head. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Land-ready maintainer verification for the unchanged original contributor head
Best fix is the existing shared conversion owner: its newline matches |
|
Merged via squash.
|
* origin/main: fix(test/ui): prevent shared history pollution (openclaw#117348) fix(openrouter): apply image request transport policy (openclaw#117336) perf(gateway): skip clean transcript workers (openclaw#117342) test(testing): assert relay smoke readiness fix(testing): activate relay live smoke transport feat(channels): record account lifecycle facts and retire inferred health vocabularies (openclaw#117300) fix(canvas): remove unsupported snapshot delay hint (openclaw#117326) refactor(agents): fold cache TTL pruning into prompt projection (openclaw#117313) fix(canvas): honor paired-node invocation deadlines (openclaw#117316) fix(system-agent): make exit-and-run-a-terminal guidance surface-correct (openclaw#114633) fix(ai): assistant text blocks are run together on replay (openclaw#115743) refactor(agents): canonicalize subagent execution state (openclaw#117267)
…15743) Chat Completions replay flattened every assistant text block with an empty separator, so two distinct blocks came back as one word-joined sentence. The same message shape survives distinctly on the Anthropic, Responses and Mistral lanes, and the string-content flattener for strict OpenAI-compatible servers already joins with a newline. Two blocks arise routinely: streaming opens a new text block after a tool call, and cross-model replay converts a thinking block into a text block.
* origin/main: fix(test/ui): prevent shared history pollution (openclaw#117348) fix(openrouter): apply image request transport policy (openclaw#117336) perf(gateway): skip clean transcript workers (openclaw#117342) test(testing): assert relay smoke readiness fix(testing): activate relay live smoke transport feat(channels): record account lifecycle facts and retire inferred health vocabularies (openclaw#117300) fix(canvas): remove unsupported snapshot delay hint (openclaw#117326) refactor(agents): fold cache TTL pruning into prompt projection (openclaw#117313) fix(canvas): honor paired-node invocation deadlines (openclaw#117316) fix(system-agent): make exit-and-run-a-terminal guidance surface-correct (openclaw#114633) fix(ai): assistant text blocks are run together on replay (openclaw#115743) refactor(agents): canonicalize subagent execution state (openclaw#117267)
What Problem This Solves
Fixes an issue where users on any OpenAI-compatible provider would have the assistant's own prior turn replayed back to the model with sentences fused together, losing the boundary between them.
An assistant turn holding two text blocks,
"Let me check the file."and"The file contains X.", was replayed as:Nothing errors and nothing is logged. The corrupted text is what the model reads as its own previous turn on every subsequent request in the conversation, so the damage compounds as the transcript grows.
This affects the Chat Completions lane generally: OpenRouter, Groq, DeepSeek, Together, LM Studio, Ollama and other OpenAI-compatible endpoints.
Why This Change Was Made
Two text blocks in one assistant turn is a routine shape, not a corner case:
text → tool call → textturn ends with two separate text blocks.thinkingblock into atextblock, which is then adjacent to the real answer block.convertMessagesflattened them withjoin(""). Every neighbouring path disagrees with that choice:"\n\n", and the thinking signature joins with"\n"flattenCompletionMessagesToStringContent, the helper that performs this very operation for strict OpenAI-compatible servers, joins with"\n"This change joins with
"\n", matching the string-content flattener, which is the closest sibling: same input shape, same output shape, same purpose."\n\n"was the alternative, but it is used for thinking blocks rather than for flattening text parts, so the narrower precedent seemed the safer one to follow. Happy to switch if maintainers prefer the paragraph break.Nothing else changes: block filtering, surrogate sanitization, the thinking paths, and the content-part array shape are all untouched.
User Impact
Assistant turns that contain more than one text block are replayed with their boundaries intact, so the model sees what it actually said instead of two sentences fused mid-word. Turns with a single text block, which is the common case, serialize byte-for-byte as before.
Evidence
Real runtime proof: what an OpenAI-compatible server actually receives
Requested by review — this goes past Vitest output and captures the bytes on the wire.
A local
node:httpserver stands in for the provider endpoint:model.baseUrlpoints athttp://127.0.0.1:<port>/v1, the server parses the real request body, recordsmessages[], and answers with a real SSE stream that the transport parses normally. Nofetchstub, no mocked client — a real socket, the realstreamOpenAICompletionspath, the real serializer. It needs no provider account.The conversation contains one assistant turn with two text blocks. Only
openai-completions-messages.tsdiffers between the two runs.assistant.contentreceived by the servermain"Let me check the file.The file contains X.""Let me check the file.\nThe file contains X."Terminal output, unfixed source first:
and with the patch applied:
The script exits on a boolean computed from the captured payload rather than printing a fixed "PASS", so a regression cannot pass silently.
Harness (run with
tsx, not committed)Regression test
Added
packages/ai/src/openai-completions-messages.test.ts, which drivesconvertMessageswith a two-text-block assistant turn and asserts the replayed content. It is load-bearing — against the unfixed source it reproduces the exact corruption rather than an incidental error:The whole
packages/aisuite is green, including the compatibility suite that pins Chat Completions payload shapes across provider variants:oxfmt --checkis clean on both touched files. The branch is rebased on currentmain.AI-assisted: written and verified with an AI coding agent; the runtime proof above, the failing-test-first measurement, and the regression run were reviewed by me.