fix(output): force UTF-8 for machine output on non-UTF-8 consoles (#546) - #547
fix(output): force UTF-8 for machine output on non-UTF-8 consoles (#546)#547padak wants to merge 1 commit into
Conversation
`kbagent --json <anything>` crashed on Windows with UnicodeEncodeError as soon as the payload carried a non-ASCII character -- an arrow in a flow name, an accented config name, an emoji. sys.stdout inherits the console codepage (cp1250 on Czech/Polish/Hungarian Windows 11), and pydantic's model_dump_json() emits raw UTF-8 rather than \uXXXX escapes, so the write hit the cp1250 codec and aborted the command instead of printing JSON. --json exists for machine consumption -- piping to a file or another program -- so it must not depend on the attached terminal. Add write_machine_output() in output.py and route every machine-output writer through it: OutputFormatter.output/error/success, `kbagent http`, and the agent --stream NDJSON events (the latter also serialized with ensure_ascii=False, so it shared the crash). Two layers: force_utf8_stdout() reconfigures the stream to UTF-8, which covers every real TextIOWrapper; a UnicodeEncodeError fallback then writes UTF-8 bytes to sys.stdout.buffer for streams that cannot be reconfigured. A stream with neither surfaces the original error -- for a machine consumer, a mangled payload would be worse than a crash. Unlike the serve startup banner (#522), transliterating to ASCII is not an option here: the banner is decoration, this is data. Verified end to end under PYTHONIOENCODING=cp1250: the raw write raises, the formatter now emits valid UTF-8 JSON that round-trips through json.load. Modern UTF-8 terminals are byte-identical to before.
|
Closing — superseded, and worth recording why rather than just deleting it. The bug this fixes is fixed. #546 landed via #549 on 2 Aug and shipped in 0.78.0. One piece of it was deliberately not taken, though, and I want that on the record so it does not get revived by accident. This PR also adds #567 (just merged, in 0.80.1) solves the same remaining problem — human/Rich output crashing on Windows — but only when the stream is not a terminal. That distinction is the whole fix, and measuring it on a real Windows 11 box is what settled it:
Since PEP 528, CPython writes to a real Windows console through the console API, so an interactive session already reports UTF-8 and was never affected. Forcing UTF-8 unconditionally therefore fixes nothing on the console side and actively breaks it: a console whose codepage is cp852 would start receiving UTF-8 bytes and render mojibake where it previously rendered correctly. Trading a crash nobody sees for garbled output everybody sees is a bad trade. So Anyone arriving here later: see #549 for the |
What
kbagent --json <anything>crashed withUnicodeEncodeErroron Windows whenever the payload contained a non-ASCII character (the reporter hit an arrow inside a flow name viakbagent --json flow list).Machine output is now written as UTF-8 regardless of the console codepage:
write_machine_output()inoutput.py, used byOutputFormatter.output/error/success,kbagent http, and theagent --streamNDJSON events.force_utf8_stdout()reconfiguressys.stdoutto UTF-8 (covers every realTextIOWrapper).UnicodeEncodeErrorthe payload is written as UTF-8 bytes tosys.stdout.buffer, which bypasses the text layer's codec. A stream with neitherreconfigurenorbufferre-raises — for a machine consumer, a silently mangled payload is worse than a crash.Why
sys.stdoutinherits the console codepage — cp1250 on Czech/Polish/Hungarian Windows 11. pydantic'smodel_dump_json()emits raw UTF-8 rather than\uXXXXescapes, so the write hits the cp1250 codec and aborts the command.--jsonexists specifically for machine consumption (piping to a file or another program), so it must not depend on which terminal happens to be attached.agent.py's stream events serialize withensure_ascii=Falseand shared the same exposure;kbagent httpused thejson.dumpsdefault (ensure_ascii=True) so it never crashed, but it is routed through the same helper for consistency.Unlike the
kbagent servestartup banner (#522, fixed in #526), transliterating to ASCII is not an option here: the banner is decoration, this is data.How it was tested
tests/test_json_output_encoding.py(24 tests): a cp1250 stdout double that raises exactly like a real Windows console, plus a fidelity guard proving the double reproduces the crash (so the passing tests are not vacuous). Covers the reconfigure path, the byte-level fallback, the no-fallback re-raise, andOutputFormatter.output/error/successround-tripping a→throughjson.loads.PYTHONIOENCODING=cp1250: a rawsys.stdout.write("extract → load")raisesUnicodeEncodeError, whileOutputFormatter(json_mode=True)now emits valid UTF-8 JSON that parses back withjson.load, both to a terminal and through a pipe.make check: lint + format + changelog-check + full suite (4707 passed, 8 skipped).Modern UTF-8 terminals are byte-identical to before — no
\uXXXXescaping is introduced.Fixes #546