Skip to content

fix(models): keep escaped newlines inside fences in MindIE native stream - #6478

Merged
WillemJiang merged 1 commit into
bytedance:mainfrom
BlueX888:fix/prep-mindie-native-stream-decodes-escaped-newlines-inside-fences
Oct 8, 2026
Merged

WillemJiang merged 1 commit into
bytedance:mainfrom
BlueX888:fix/prep-mindie-native-stream-decodes-escaped-newlines-inside-fences

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Why

MindIE's no-tool native streaming decodes escaped newlines per chunk, so it rewrites code inside fenced blocks. MindIEChatModel._astream (backend/packages/harness/deerflow/models/mindie_provider.py:282 on current main) applies _decode_escaped_newlines_outside_fences to each streamed chunk independently, but that helper pairs ``` delimiters over its input, and a token-sized chunk almost never contains both delimiters of a fenced block. The pairing therefore never sees the fence, and every literal \n in the chunk — including code inside a fence in the full reply — becomes a real newline. A streamed answer containing

print("a\nb")

reaches the user and the persisted message content with the string escape split across lines, while the non-streaming paths (_generate/_agenerate via _patch_result_with_tools, which run the helper once over the full content) and the tool-enabled simulated stream preserve it. backend/docs/CONFIGURATION.md documents "Escapes inside fenced code remain unchanged" for synchronous generation, asynchronous generation and tool-enabled simulated streaming; #6334 explicitly left the no-tool native-stream decoder outside its fix, and this closes that gap.

What changed

  • The no-tool branch of MindIEChatModel._astream now decodes with a fence-stateful decoder instead of independent per-chunk calls: it carries whether the stream is inside a ``` fence across the chunks of one completion and reuses _decode_escaped_newlines_outside_fences for the text outside fences, so the streaming and non-streaming paths share one implementation and cannot drift again.
  • A trailing partial ``` or \ (fence marker or escape sequence split across a chunk boundary) is held back until the next chunk decides; text still pending when the stream ends is yielded as one final text chunk, so nothing is dropped.
  • When every fence in a reply closes, the joined stream is identical to the one-shot decode of the same reply. A reply that ends inside an unclosed fence keeps its escapes: bounded lookahead cannot retroactively decode already-yielded chunks, and an unterminated fence still renders as code.
  • backend/docs/CONFIGURATION.md now lists native no-tool streaming in the MindIE decode contract.

Surface area

  • Default behavior change — MindIE native no-tool streaming now preserves literal \n inside fenced code blocks, matching the non-streaming and tool-enabled-simulated-stream paths.

Bug fix verification

  • Test path: backend/tests/test_mindie_provider.py — TestAStream.test_no_tools_stream_keeps_escaped_newlines_inside_fences, test_no_tools_stream_decode_matches_non_streaming (parametrized over five chunk layouts) and test_no_tools_stream_flushes_trailing_partial_fences.
  • Did it go red on main and green on this branch? Yes. On unpatched main (a4427625c): 4 failed, 4 passed, 96 deselected. On this branch: 104 passed in the same file.
  • The parametrized test asserts the joined stream equals _decode_escaped_newlines_outside_fences over the concatenated raw chunks, covering a complete fence pair in one chunk, fences opening/closing across chunk boundaries, an escape sequence split across a boundary, the ``` delimiter itself split across a boundary, and two fences with prose between them.

Validation

All offline with mocked model responses, macOS arm64 / Python 3.12, uv run --locked:

  • Red on unpatched main: PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run --locked pytest tests/test_mindie_provider.py -q --tb=short -k "fence" → 4 failed, 4 passed, 96 deselected.
  • New + existing module tests on this branch, same command without -k → 104 passed.
  • Related suites that exercise MindIEChatModel (model factory, input-sanitization and tool-output-budget middlewares, setup wizard, provider) → 627 passed.
  • Full backend suite make test (four parallel shards) → 25787 passed, 0 failed. An earlier full-suite run hit 17 failures in tests/test_web_fetch_relative_links.py caused by a broken readabilipy node_modules inside this checkout's local .venv (parallel shards raced readabilipy's lazy npm install); the same file passes 48/48 on unpatched main and on this branch after repairing the venv, so it is a local environment artifact, not a regression.
  • make lint → ruff check "All checks passed!", ruff format --check "1906 files already formatted".
  • uv lock --check → in sync. python scripts/check_agent_guidance.py --base-ref origin/main --head-ref HEAD → 39 AGENTS.md, 0 errors, 0 warnings. git diff --check → clean.
  • Extra check: a local fuzz loop compared the stateful decoder against the one-shot helper over 20,000 random contents with random chunk splittings — all 11,362 closed-fence cases matched byte-for-byte (odd fence counts are the documented unclosed-fence case).

AI assistance

Tool(s) used: Zcode (Claude-powered autonomous coding agent, submitting as BlueX888).

How you used it: The agent identified the divergence from the #6334 contract, wrote the regression tests and the fix, and ran the validation above. The change is AI-authored; the account owner reviews the diff before merge.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

The no-tool native stream applied _decode_escaped_newlines_outside_fences
to each streamed chunk independently, but a fenced code block almost never
opens and closes within one token-sized chunk, so the per-chunk fence regex
never saw a complete ``` pair and decoded every literal \n inside the fence.
Code like print("a\nb") reached the streamed answer and the persisted
message content with a real newline, while the non-streaming path and the
tool-mode simulated stream preserved it.

Decode the native stream with a fence-stateful decoder instead: it carries
whether the stream is inside a fence across chunks and holds back a
trailing partial ```/`\n` sequence, so the joined stream equals the one-shot
decode of the same reply. A fence left unclosed by a truncated reply keeps
its escapes, matching how an unterminated fence still renders as code.

Document native no-tool streaming in the MindIE decode contract.
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only risk:medium Medium risk: regular code changes size/M PR changes 100-300 lines labels Oct 7, 2026
@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 7, 2026
@WillemJiang
WillemJiang merged commit 3772f7c into bytedance:main Oct 8, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only risk:medium Medium risk: regular code changes size/M PR changes 100-300 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants