Skip to content

fix: pin UTF-8 stdout in resolve_config so Windows cp1252 doesn't crash - #2693

Open
armelhbobdad wants to merge 3 commits into
bmad-code-org:mainfrom
armelhbobdad:fix/resolve-config-utf8-stdout
Open

fix: pin UTF-8 stdout in resolve_config so Windows cp1252 doesn't crash#2693
armelhbobdad wants to merge 3 commits into
bmad-code-org:mainfrom
armelhbobdad:fix/resolve-config-utf8-stdout

Conversation

@armelhbobdad

Copy link
Copy Markdown
Contributor

What

Pins resolve_config.py's stdout to UTF-8 before the JSON dump, and adds the stdout regression test the suite was missing.

Why

The full-config dump writes ensure_ascii=False JSON to a stdout still bound to the platform default. On Windows that is cp1252, which cannot encode the emoji icons in the shipped agent configs, so the script raises UnicodeEncodeError and exits having produced no output.

Fixes #2682

That issue was auto-closed by #2687, but #2687 changed only the consumer side (encoding="utf-8" on party mode's subprocess.run). That governs how the parent decodes the child's bytes; it does not change the child's stdout encoding, so the producer still raises before writing anything and _run_json still sees a non-zero exit. Verified still reproducible on main.

How

  • Adopt write_json_stdout() verbatim from the sibling resolve_customization.py, which already guards the identical write (fixed in fix: write customization JSON as UTF-8 #2414).
  • Add test_writes_emoji_json_when_stdout_encoding_is_cp1252, mirroring the existing test of the same name in test_resolve_customization.py. The issue noted the suite never exercised the stdout path — it does now.

Testing

npm run test:renderer → 11 tests, OK. The new test fails against the unpatched script (confirmed by reverting the fix and re-running), so it is a real tripwire.

Note on #2578

@aranellaeth's #2578 proposed this same resolve_config.py guard back on 2026-07-12 and deserves the credit. Its consumer hunks have since been merged piecemeal (#2687 party mode, #2688 forge-idea), leaving the producer fix — the part that actually closes #2682 — outstanding. This PR is deliberately scoped to just that piece plus its test, so it can land independently; close it in favour of #2578 if you would rather merge that whole.

One review note for whichever lands: reconfigure(encoding="utf-8") without errors= also resets the handler to strict. Harmless for stdout (this PR), but #2578 also reconfigures stderr in brain.py, which silently downgrades POSIX's default backslashreplace — a surrogateescaped path in an error message would turn a clean diagnostic into a traceback. Passing errors=stream.errors preserves it.

🤖 Generated with Claude Code

The full-config dump writes ensure_ascii=False JSON to a stdout still
bound to the platform default. On Windows that is cp1252, which cannot
encode the emoji icons carried by the shipped agent configs, so the
script raises UnicodeEncodeError and exits having produced no output.

Adopts write_json_stdout() from the sibling resolve_customization.py,
and adds the stdout regression test the suite was missing - it fails
against the unpatched script.

Fixes bmad-code-org#2682

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR makes the configuration resolver’s JSON output and its context-script consumers consistently use UTF-8, preventing Windows locale encoding and decoding failures.

  • Adds a UTF-8 stdout writer to resolve_config.py.
  • Pins both context resolver consumers to UTF-8 decoding.
  • Adds producer and end-to-end consumer regression coverage for emoji-bearing configuration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/scripts/resolve_config.py Adds a guarded UTF-8 stdout configuration before emitting non-ASCII JSON.
src/scripts/context.py Explicitly decodes resolver subprocess output as UTF-8, completing the producer-consumer encoding contract.
src/bmm-skills/plan/bmad-project-context/scripts/context.py Mirrors the explicit UTF-8 subprocess decoding fix in the distributed skill copy.
src/scripts/tests/test_resolve_config.py Adds regression tests for cp1252 producer output and the complete UTF-8 consumer round trip.

Sequence Diagram

sequenceDiagram
    participant Context as context.py
    participant Resolver as resolve_config.py
    Context->>Resolver: Run with project root
    Resolver->>Resolver: Reconfigure stdout as UTF-8
    Resolver-->>Context: UTF-8 JSON bytes
    Context->>Context: Decode explicitly as UTF-8
    Context->>Context: Parse JSON configuration
Loading

Reviews (3): Last reviewed commit: "test: cover the context.py consumer of r..." | Re-trigger Greptile

"""Pin stdout to UTF-8 — a Windows cp1252 default cannot encode emoji icons."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Locale decoding breaks UTF-8 output

When either context.py resolver runs on Windows with a cp1252 locale, this change emits UTF-8 while the parent still uses subprocess.run(..., text=True) without an explicit encoding, causing emoji output to raise UnicodeDecodeError during capture instead of loading the configuration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/scripts/resolve_config.py
Line: 41

Comment:
**Locale decoding breaks UTF-8 output**

When either `context.py` resolver runs on Windows with a cp1252 locale, this change emits UTF-8 while the parent still uses `subprocess.run(..., text=True)` without an explicit encoding, causing emoji output to raise `UnicodeDecodeError` during capture instead of loading the configuration.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

resolve_config.py now configures supported stdout streams for UTF-8 before writing JSON. A regression test verifies that emoji-containing configuration remains valid when PYTHONIOENCODING is set to cp1252.

Changes

Config stdout encoding

Layer / File(s) Summary
UTF-8 JSON output writer
src/scripts/resolve_config.py
The resolver adds write_json_stdout, configures supported stdout streams for UTF-8, and uses the helper for indented JSON output without ASCII escaping.
Stdout encoding regression test
src/scripts/tests/test_resolve_config.py
The test runs the resolver with cp1252 stdout settings and verifies valid JSON output with the original emoji preserved.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: alexeyv, bmadcode

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the UTF-8 stdout fix for the Windows cp1252 failure in resolve_config.py.
Description check ✅ Passed The description explains the stdout encoding defect, the regression test, the rationale, and the limited scope of the change.
Linked Issues check ✅ Passed The changes implement issue #2682 by reconfiguring stdout to UTF-8 and adding coverage for emoji output on cp1252 streams.
Out of Scope Changes check ✅ Passed The changes are limited to the producer-side stdout fix and its regression test, matching the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/scripts/resolve_config.py`:
- Around line 37-42: Update the relevant documentation for the resolve_config
command to state that JSON output uses UTF-8 encoding and preserves non-ASCII
characters rather than escaping them. Keep the implementation in
write_json_stdout unchanged.
- Around line 37-42: Document the UTF-8 stdout contract for resolve_config.py in
the BMad documentation, including that output is JSON with raw emoji and
external consumers must decode it as UTF-8. Find the existing consumer that
invokes resolve_config with text=True but omits encoding="utf-8", and update
that invocation to specify UTF-8 while preserving its existing behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2795eae5-737a-490c-b375-1c808e647c69

📥 Commits

Reviewing files that changed from the base of the PR and between cbb69e6 and 7aed04c.

📒 Files selected for processing (2)
  • src/scripts/resolve_config.py
  • src/scripts/tests/test_resolve_config.py

Comment on lines +37 to +42
def write_json_stdout(output) -> None:
"""Pin stdout to UTF-8 — a Windows cp1252 default cannot encode emoji icons."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add documentation for the changed JSON output contract.

This change makes resolve_config.py emit UTF-8 bytes with raw non-ASCII characters. The PR includes no corresponding change under docs/. Update the relevant command or output documentation to state this behavior.

As per path instructions, “src/**: Source file changed. Check whether documentation under docs/ needs a corresponding update — new features, changed behavior, renamed concepts, altered CLI flags, or modified configuration options should all be reflected in the relevant doc pages.”

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 41-41: use jsonify instead of json.dumps for JSON output
Context: json.dumps(output, indent=2, ensure_ascii=False)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/scripts/resolve_config.py` around lines 37 - 42, Update the relevant
documentation for the resolve_config command to state that JSON output uses
UTF-8 encoding and preserves non-ASCII characters rather than escaping them.
Keep the implementation in write_json_stdout unchanged.

Source: Path instructions


🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 --glob '!src/scripts/resolve_config.py' \
  'resolve_config(?:\.py)?|subprocess\.(run|Popen|check_output)|spawnSync|encoding\s*=|decode\(' .

Repository: bmad-code-org/BMAD-METHOD

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== resolve_config.py =="
cat -n src/scripts/resolve_config.py

echo
echo "== resolve_config references (source/docs/tests only, max 200 lines) =="
rg -n -C 4 'resolve_config(?:\.py)?|resolve_customization\.py|uv run .*resolve_config|python .*resolve_config' --glob 'docs/**' --glob 'src/**' --glob 'tests/**' --glob '!*.png' . | head -n 240

Repository: bmad-code-org/BMAD-METHOD

Length of output: 35519


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== context.py config resolver section =="
sed -n '70,90p' src/scripts/context.py | cat -n

echo
echo "== persona/resolver helpers =="
sed -n '1,90p' src/core-skills/bmad-forge-idea/scripts/resolve_personas.py | cat -n
sed -n '48,72p' src/core-skills/bmad-party-mode/scripts/resolve_party.py | cat -n

echo
echo "== tests around resolve_config stdout =="
sed -n '1,180p' src/scripts/tests/test_resolve_config.py | cat -n

echo
echo "== exact resolve_config docs references =="
rg -n 'resolve_config\.py|resolver (returns|emits|outputs|output)|UTF-8|utf.?8|text=True|encoding|subprocess' src/core-skills src/scripts src/core-skills --glob '!**/*.png' --glob '!**/__pycache__/**' | head -n 120

Repository: bmad-code-org/BMAD-METHOD

Length of output: 24276


Document the resolve_config stdout contract.

ResolveConfig now pins resolve_config.py output to UTF-8 JSON with raw emoji. Update BMad docs that call the resolved config so external consumers know they must decode stdout as UTF-8; one existing consumer still omits encoding="utf-8" when using text=True.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 41-41: use jsonify instead of json.dumps for JSON output
Context: json.dumps(output, indent=2, ensure_ascii=False)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/scripts/resolve_config.py` around lines 37 - 42, Document the UTF-8
stdout contract for resolve_config.py in the BMad documentation, including that
output is JSON with raw emoji and external consumers must decode it as UTF-8.
Find the existing consumer that invokes resolve_config with text=True but omits
encoding="utf-8", and update that invocation to specify UTF-8 while preserving
its existing behavior.

Both copies of context.py capture the resolver with text=True and no
encoding, so the parent decodes with the platform locale. Now that the
child reliably emits UTF-8, a cp1252 parent raises UnicodeDecodeError
on the reader thread - which does not propagate: stdout comes back
None with returncode 0, slipping past the returncode guard and dying
as an uncaught TypeError in json.loads(None).

Matches the same one-line fix already merged for resolve_party.py
(bmad-code-org#2687) and resolve_personas.py (bmad-code-org#2688).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@armelhbobdad

Copy link
Copy Markdown
Contributor Author

Good catch from Greptile — valid, and worse than described. Fixed in 62f2250.

I reproduced it before changing anything. The failure is not a propagating UnicodeDecodeError: it is raised on subprocess's reader thread, so subprocess.run returns normally with stdout=None and returncode=0. That slips straight past _installed_resolver_config's if proc.returncode != 0 guard and dies in json.loads(None) as a TypeError, which the surrounding except json.JSONDecodeError does not catch.

Three-way repro on Windows 11 / Python 3.11, config carrying project_name = "Café 📍" (U+1F4CD's UTF-8 contains 0x8D, undefined in cp1252):

producer consumer result
main main None — child crashed, clean fallback
this PR (before 62f2250) main UNCAUGHT: TypeError
this PR (now) patched {'project_name': 'Café 📍'}

So the middle row was a genuine regression this PR introduced: it turned a silent-but-graceful fallback into a crash. Worth stating plainly.

The bot said "two context.py consumers" and that is literally true — src/scripts/context.py and src/bmm-skills/plan/bmad-project-context/scripts/context.py are byte-identical copies (same sha256), each with its own call site. Both are patched and still byte-identical after the change. They are also the only remaining locale-decoding consumers of resolve_config.py; notably #2578 does not cover them either.

The fix is the same one-liner already merged for the sibling consumers — encoding="utf-8" on the capture, matching #2687 and #2688 (both +3/-1, no test). I used encoding="utf-8" alone rather than adding errors="replace": the producer now guarantees UTF-8, so the decode is exact, and errors="replace" would only convert genuine corruption into silent mojibake.

No test added for the consumer hop, matching the precedent of those two merged PRs — test_context.py is pytest-style, has no npm script wiring it into CI, and covers none of the resolver path. Happy to add one if you would rather have it.

npm run test:renderer → 11 tests, OK.

🤖 Generated with Claude Code

Locks the encoding= on context.py's capture. Omitting it makes the
decode fall back to the locale, which on Windows raises on the reader
thread and surfaces as stdout=None with returncode 0 - past the
returncode guard, then an uncaught TypeError in json.loads(None).

Runs the real _installed_resolver_config under warn_default_encoding
with EncodingWarning escalated to an error, so the omission fails on
UTF-8 platforms too rather than only on a cp1252 console. Lives in
test_resolve_config.py because test:renderer runs it; test_context.py
is wired into no npm script and already fails on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@armelhbobdad

Copy link
Copy Markdown
Contributor Author

Added the consumer test in 3d769a0.

Where it lives. Not in test_context.py, which would have been the obvious home. That file is wired into no npm script — CI's Python coverage is test:renderer, test:retrospective and test:sprint-planning — and it has rotted since: on a clean main checkout it is 26 failed / 20 passed. A test added there would never run, and wiring it in would mean importing 26 pre-existing failures into CI, which is out of scope here. Flagging it as a separate cleanup worth doing. The new test therefore sits in test_resolve_config.py, which test:renderer already runs.

Why it is not a cp1252 test. The existing precedent (PYTHONIOENCODING=cp1252) works for the producer because that variable governs the script's own stdout. It is the wrong lever for the consumer: the parent's capture decodes with locale.getpreferredencoding(False), which PYTHONIOENCODING does not touch. A locale-based test would bite on Windows and pass vacuously on Linux CI.

Instead the driver runs under -X warn_default_encoding -W error::EncodingWarning. subprocess calls io.text_encoding(None) when encoding= is omitted, so the omission becomes a hard error on every platform, Linux CI included. Verified context.py is encoding-clean first (19 explicit encoding=, its only bare open is "rb"), so there are no false positives.

It exercises the real _installed_resolver_config — imported from context.py, not a copy — against a temp project with an installed resolver and project_name = "Café 📍".

Verified as a tripwire in both directions:

state result
this PR OK
consumer encoding= removed FAILED
producer guard reverted FAILED (errors=1)

npm run test:renderer → 12 tests, OK. Both context.py copies remain byte-identical.

🤖 Generated with Claude Code

aranellaeth added a commit to aranellaeth/BMAD-METHOD that referenced this pull request Aug 7, 2026
brain.py prints two kinds of arbitrary user text: --extra overlay
techniques (customize.toml additional_techniques) and, on stderr, the
technique name echoed back by `show NAME` when it is not found. Either
can carry a character the platform default cannot encode, and print()
then raises UnicodeEncodeError. The shipped catalog is cp1252-safe (its
only non-ASCII is U+2014), so this is an overlay/argv path, not a
default-catalog crash.

pin_utf8() passes errors= through rather than letting it default:
reconfigure(encoding=...) alone resets the handler to strict, which
would silently downgrade stderr's POSIX default of backslashreplace
and turn a diagnostic about an undecodable path into a traceback.
Thanks to @armelhbobdad for catching that on bmad-code-org#2693.

Four regression tests, all four red against the unpatched script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aranellaeth

Copy link
Copy Markdown

Author of #2578 here. Thanks for the flag on my PR, and for scoping this one to the producer — that is the right call, and I'd rather this land than mine.

I've rebased #2578 onto current main and narrowed it to brain.py, the one file neither #2687, #2688 nor this PR touches and the only one still unfixed. The two PRs no longer overlap, so nothing needs closing in favour of anything.

Two things I reproduced on Windows 11 while doing that, both of which back up your review notes.

Your errors= note is correct, and it applied to my patch. reconfigure(encoding=...) with no errors= silently resets the handler to strict:

>>> s = io.TextIOWrapper(io.BytesIO(), encoding="ascii", errors="backslashreplace")
>>> s.reconfigure(encoding="utf-8");                    (s.encoding, s.errors)
('utf-8', 'strict')
>>> t.reconfigure(encoding="utf-8", errors=t.errors);   (t.encoding, t.errors)
('utf-8', 'backslashreplace')

My brain.py hunk reconfigured stderr, so it would have downgraded POSIX's default exactly as you described. Fixed in the rescoped PR by passing errors= through.

Your reader-thread finding reproduces — and it also lands on the two consumers that already merged. #2687 and #2688 took the encoding="utf-8" half of the fix but not the errors="replace" / out.stdout or "" half. Against a child emitting invalid UTF-8, on Python 3.13:

encoding="utf-8", strict          ->  returncode=0   stdout=None
    out.stdout.strip()  AttributeError: 'NoneType' object has no attribute 'strip'
    (uncaught — the handler is `except json.JSONDecodeError`)

encoding="utf-8", errors="replace" ->  returncode=0   stdout='{"agents": ...}'

Same failure you found in _installed_resolver_config, one exception type off because _run_json calls .strip() before json.loads. So resolve_party.py and resolve_personas.py are currently half-fixed on main. Worth a small follow-up; I'll open it if nobody beats me to it.

And thank you for the credit note in the description — genuinely appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resolve_config.py crashes on Windows (UnicodeEncodeError) — party mode silently resolves zero agents

2 participants