Uh oh!
There was an error while loading. Please reload this page.
fix(cli): emit ASCII-safe JSON so results survive a legacy stdout codec - #381
Conversation
Closesmicrosoft#342. On Windows under a cp1252 locale, conductor run crashed with UnicodeEncodeError *after* the workflow had already completed successfully, while writing its final JSON result to stdout. The document was truncated mid-field so no caller could parse it, and the process exited non-zero, so automation concluded a successful run had failed. json.dumps already emits pure ASCII by default, so conductor hands off a safe string. What reintroduces the unencodable character is rich: Console.print_json defaults to ensure_ascii=False, re-parsing and re-serialising the payload with the literal glyph immediately before the write. The JSON channel is made unsafe by the pretty-printer, not by our serialisation. Passing ensure_ascii=True at all five JSON sinks fixes it losslessly: \\uXXXX escapes (surrogate pairs for non-BMP) are valid JSON and decode back to the original text. errors='replace' would also stop the crash but would silently turn review feedback into '?', and errors='backslashreplace' emits \\U0001f600 for non-BMP, which is not valid JSON at all. Tests simulate rather than detect. CI runs on ubuntu-latest, so anything gated on sys.platform would never execute; instead each test renders through a TextIOWrapper bound to strict cp1252 and asserts both that nothing raises and that the payload round-trips - the property a machine consumer depends on. A guard test asserts the unfixed path still raises, so a future change that drops ensure_ascii cannot leave the suite passing for the wrong reason. One fixture note worth recording: U+2014 EM DASH is excluded from the samples because cp1252 can encode it (0x97), so it would not exercise the failure despite being common in agent prose. That asymmetry is why this bug looked intermittent in the field - whether it fired depended on which glyph an agent happened to emit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
Addresses post-implementation review feedback on the microsoft#342 fix. The first version of these tests constructed their own Console and passed ensure_ascii=True from the test body. That characterised rich but protected nothing: reverting every production change would have left the whole suite green. A regression guard that cannot fail on the regression is worse than no guard, because it manufactures confidence. Added an AST assertion over conductor/cli/app.py and conductor/cli/doctor.py requiring every print_json call to pass ensure_ascii=True. Verified it fails on a simulated revert (offenders reported at app.py:553,566,1008,1014) and passes on the fixed tree. It also catches a NEW sink added without the keyword, which is the likelier future regression - rich defaults it to False. A source assertion is deliberate rather than a mock. Driving the real conductor run command to its JSON sink requires standing up a full workflow execution; that belongs in an end-to-end subprocess test invoked with PYTHONIOENCODING=cp1252, which is tracked as follow-up on the issue rather than faked here. Also removed two tests that invoked the CLI through CliRunner: the command exits before reaching the JSON sink without a fully valid execution context, so they asserted nothing while appearing to cover the production path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
Fixes the Lint job, and a latent import-order bug underneath it. Ruff flagged four dead imports left behind when the `CliRunner` tests were removed (they asserted nothing once `conductor run` exited before reaching the JSON sink). Removing them exposed the real problem: the module was only importable because something else in the suite had already imported `conductor.cli.app`. `conductor/cli/__init__.py` re-exports the Typer object as `app`, which shadows the submodule of the same name -- so `from conductor.cli import app` yields the Typer instance, and `.__file__` raises AttributeError. Run inside the full suite an earlier import had rebound the attribute to the module and it passed; run on its own, collection failed. Resolve each sink with `importlib.import_module` and read its `__file__` directly. That always returns the module regardless of collection order, and drops the `parents[2]` path arithmetic, so the guard no longer depends on the repository layout either. Verified the guard still fails on a simulated revert (reports app.py:553) and that the module now passes standalone as well as in the full suite.
Follow-up to the previous commit, which fixed the import-order bug but left two ruff findings behind. `cp1252_output_console` was a fixture left over from the removed CliRunner tests. No test referenced it, and it still called `monkeypatch.setattr` on the deleted `app_module` binding -- an F821 that would only have raised had anything used it. The `_Cp1252Console` helper it wrapped is still live in the negative-control test, so only the fixture is removed. Also applied ruff's import ordering. `ruff check src tests` and `ruff format --check src tests` -- the exact CI commands -- both clean. Module passes standalone (7) and in the full suite; guard still fails on a simulated revert.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@## main #381 +/- ##
=======================================
Coverage ? 91.80% =======================================
Files ? 112 Lines ? 18239 Branches ? 0 =======================================
Hits ? 16744 Misses ? 1495 Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Thanks for sending this, Frank. Verified the fix end-to-end before reading it. On main, PYTHONIOENCODING=cp1252 conductor --silent run with a non-cp1252 glyph in the output exits 1 with zero bytes on stdout. On this branch it exits 0 and round-trips losslessly, surrogate pairs included. The diagnosis is right and the five sinks are the right five. I also checked the rich>=13.0.0 floor, since ensure_ascii only reached print_json in 12.6.0, and an isolated 13.0.0 install accepts it.
Three things I would want before this lands.
conductor doctor still crashes on cp1252 in its default table mode, in a file this PR edits, because of hardcoded glyphs at doctor.py:36-39. Comment inline.
The module docstring on the new test file describes the version that had the CliRunner tests, and it is no longer true of anything in the file.
Nothing asserts that the fix works. I mutation-tested each of the five sinks against the full suite: every revert fails exactly one test, and it is always the AST source-text guard. The comment explaining why a behavioural test was infeasible is not accurate, since test_run.py:1034 already reaches one of these sinks with a mock, and a working version takes about ten lines and 1.5 seconds.
Smaller items are inline: a misattributed warning in the terminate handlers that survives this fix, a new ty error, and a cluster of now-inert test scaffolding left behind by the CliRunner removal.
Three that do not fit on a line.
The five repeated keywords would be better as one helper. This repo consistently puts that kind of invariant behind a single entry point, with normalize_agent_output and _run_set_step as the closest analogues, and cli/ already has print_error and the display_* family. One helper makes new sinks safe by construction, collapses the two mirrored try/except blocks, and lets the guard become a real test rather than an assertion about source text.
No CHANGELOG entry. The [Unreleased] / Fixed section is active, and this is visible on every platform rather than just Windows, since results now carry \uXXXX escapes everywhere.
Two worth filing rather than fixing here. conductor run | head on a successful workflow exits 1 with nothing on stderr, because rich's on_broken_pipe raises SystemExit and escapes both handlers. And under --silent the event-log path that actually holds the results is printed through _verbose_console, so it is suppressed in precisely the mode where someone needs it.
The fixture self-guard is the best thing in the PR. Pinning the em-dash exclusion so the reasoning cannot rot is a nice touch, and assert calls catching sink removal is a property most AST guards miss.
| if as_json: | ||
| console.print_json(data=report.to_dict()) | ||
| console.print_json(data=report.to_dict(), ensure_ascii=True) |
There was a problem hiding this comment.
This fixes --json, but the default table path still dies on the same stream. doctor.py:36-39 hardcodes \u2713, \u2717 and \u25cb, and cp1252 cannot encode any of them.
On this branch:
$ PYTHONIOENCODING=cp1252 conductor doctor
EXIT=1
stdout: 395 bytes, the Environment table and nothing after it
_render_env and _render_providers are separate console.print() calls, so stdout is left truncated mid-report. That is the same shape as #342, and it lands on the person who ran doctor because they hit #342 in the first place.
The AST guard lists conductor.cli.doctor, which reads as though the module is now covered. Either handle the glyphs here, or say in the description that table mode is out of scope and open a follow-up.
| # Output as JSON to stdout | ||
| output_console.print_json(json.dumps(result)) | ||
| output_console.print_json(json.dumps(result), ensure_ascii=True) |
There was a problem hiding this comment.
Nothing at the call site says why this keyword is here, and next to json.dumps it looks provably redundant, since that already escapes. The next person to read the line has a fair reason to delete it. The AST guard would catch that, but it lives in another tree, and the reasoning currently exists only in a test file and a commit message.
| # Output as JSON to stdout | |
| output_console.print_json(json.dumps(result)) | |
| output_console.print_json(json.dumps(result), ensure_ascii=True) | |
| # `ensure_ascii=True` is not redundant with `json.dumps`: rich re-parses | |
| # the string and re-serialises it with `ensure_ascii=False`, restoring | |
| # literal glyphs that a cp1252 stdout cannot encode (issue #342). | |
| output_console.print_json(json.dumps(result), ensure_ascii=True) |
The other four sinks can point back here rather than repeat it.
| try: | ||
| output_console.print_json(json.dumps(e.output, default=str)) | ||
| output_console.print_json(json.dumps(e.output, default=str), ensure_ascii=True) | ||
| except (TypeError, ValueError) as json_exc: |
There was a problem hiding this comment.
UnicodeEncodeError subclasses ValueError, so this clause was already catching the #342 crash on the two terminate paths. It reported it as a serialisation failure and exited 1, which a failed terminate does anyway, so the regression never showed up in an exit code at all.
Your change removes that trigger, but the handler still misreports what is left. With a closed stdout it prints could not serialise terminate output: I/O operation on closed file, when serialisation succeeded and the write failed. Given json.dumps defaults to ensure_ascii=True and everything here round-trips through _maybe_parse_json, the named TypeError case is close to unreachable while the reachable causes are all stream I/O.
Splitting by stage rather than by exception type fixes the message for good, and keeps the json.dumps guard the comment above asks for:
try:
payload=json.dumps(e.output, default=str)
except (TypeError, ValueError) asjson_exc:
logger.exception("Failed to serialise terminate output")
console.print(
f"[yellow]Warning:[/yellow] could not serialise terminate output: {json_exc}"
)
else:
try:
output_console.print_json(payload, ensure_ascii=True)
except (OSError, ValueError) aswrite_exc:
logger.exception("Failed to write terminate output to stdout")
console.print(
f"[yellow]Warning:[/yellow] terminate output could not be written: {write_exc}"
)Same applies at app.py:1015. Not blocking, but you are already editing the line above it.
| These tests drive the **production call sites** -- the module-level | ||
| ``output_console`` in :mod:`conductor.cli.app`, and the ``data=`` kwarg shape | ||
| used by ``conductor doctor`` -- through a stream bound to strict ``cp1252``. | ||
| Patching only the console, never the code under test, means reverting any of | ||
| the five ``ensure_ascii=True`` arguments makes the corresponding test fail. |
There was a problem hiding this comment.
This describes the version that had the CliRunner tests. As it stands nothing in the file imports output_console, patches a console, or exercises the data= shape.
I checked the load-bearing claim directly by reverting each sink one at a time and running the full suite. Every revert fails exactly one test, the AST guard, and never a corresponding behavioural one.
This docstring is what a maintainer reads when deciding whether the coverage here is enough, which is why I would not leave it. Either make it true, per my note further down, or rewrite it to describe what the file does now: a contract test, a negative control, and a source guard.
| # It is deliberately a source assertion rather than a mock: driving the real | ||
| # ``conductor run`` command to its JSON sink requires standing up a full | ||
| # workflow execution, which belongs in an end-to-end subprocess test (tracked | ||
| # as follow-up on #342) rather than a unit test. |
There was a problem hiding this comment.
This is why the file ended up with no behavioural coverage, and I do not think it holds. tests/test_cli/test_run.py:1034 already reaches app.py:566 by patching conductor.cli.run.run_workflow_async. No subprocess, no workflow execution. What the CliRunner attempt was missing was that mock, not the process boundary.
I wrote it out and it works:
withpatch("conductor.cli.run.run_workflow_async") asmock_run, \
patch("conductor.cli.app.output_console", con): # con writes to strict cp1252mock_run.return_value= {"summary": "done \u2192\u2705"}
result=runner.invoke(app, ["run", str(wf)])
assertresult.exit_code==0assertjson.loads(raw.getvalue().decode("cp1252"))["summary"] =="done \u2192\u2705"Two tests, 1.49s, and they discriminate per sink: reverting app.py:553 fails only the success test, reverting :566 fails only the terminate one. All five sinks already have CliRunner tests in the suite, they just run over a UTF-8 stream, so the scaffolding exists five times over.
doctor.py:94 is the one most worth covering, because data= goes through JSON.from_data rather than the string path the negative control exercises. I confirmed that path is independently vulnerable.
The subprocess test should not block. I measured about 5.5s per CLI invocation, and once the above is in, its remaining value is the Windows chunked-write truncation path and stdout pollution.
| def text(self) -> str: | ||
| self.console.file.flush() | ||
| return self._raw.getvalue().decode("cp1252") |
There was a problem hiding this comment.
text() has no caller now, which leaves self._raw write-only. The arguments around it went the same way: newline="" and force_terminal=False only mattered while something decoded the buffer, and width=200 never did anything, because print_json ends with soft_wrap=True.
I removed each in turn and the file stayed at 7 passed. Strip them and _Cp1252Console is two lines with one caller, at which point it may as well be inline. The pair to keep is encoding="cp1252" and errors="strict", which is what makes the assertion real.
The better direction is the opposite one: add the behavioural test and text() earns its place back, along with the non-BMP sample, whose surrogate-pair behaviour nothing currently exercises.
| for param in NON_CP1252_SAMPLES: | ||
| char = param.values[0] | ||
| with pytest.raises(UnicodeEncodeError): | ||
| char.encode("cp1252") |
There was a problem hiding this comment.
ParameterSet.values is typed Sequence[object], so this trips ty with Object of type object has no attribute encode. It also reaches into _pytest.mark.structures, which is not part of pytest's public surface. CI only gates src, so it will not turn the build red, but AGENTS.md asks for type hints that check clean.
Both problems go away if the samples start life as a mapping:
| forparaminNON_CP1252_SAMPLES: | |
| char=param.values[0] | |
| withpytest.raises(UnicodeEncodeError): | |
| char.encode("cp1252") | |
| forcharin_UNENCODABLE.values(): | |
| withpytest.raises(UnicodeEncodeError): | |
| char.encode("cp1252") |
with _UNENCODABLE: dict[str, str] holding the id-to-character pairs and NON_CP1252_SAMPLES = [pytest.param(c, id=i) for i, c in _UNENCODABLE.items()] above it.
| # workflow execution, which belongs in an end-to-end subprocess test (tracked | ||
| # as follow-up on #342) rather than a unit test. | ||
| _JSON_SINK_MODULES = ["conductor.cli.app", "conductor.cli.doctor"] |
There was a problem hiding this comment.
The comment above says this catches a new sink added without the keyword, which is the likelier future regression. It only does so inside these two modules. I added an unguarded print_json to cli/checkpoint.py, which already owns a stdout output_console, and all 7 tests stayed green. A --json flag on checkpoint list is a plausible next sink.
Discovering them is roughly the same amount of code:
_SRC=Path(conductor.__file__).parent_JSON_SINK_MODULES=sorted(
"conductor."+p.relative_to(_SRC).with_suffix("").as_posix().replace("/", ".")
forpin_SRC.rglob("*.py")
if"print_json"inp.read_text(encoding="utf-8")
)Two smaller gaps in the matcher itself. _print_json_calls requires ast.Attribute, so if these five sites ever move behind a shared helper called as a bare name, the guard silently stops matching. And {kw.arg: kw.value for kw in call.keywords} maps a **kwargs splat to None, so print_json(x, **opts) would report as missing the keyword.
It is also blind to a Console subclass that overrides print_json, which run.py:50-53 explicitly anticipates. A behavioural test is immune to all three.
| ``errors="strict"`` mirrors what CPython gives ``sys.stdout`` under a | ||
| non-UTF-8 locale. (``sys.stderr`` gets ``backslashreplace`` instead, which | ||
| is why the original crash only ever hit the stdout JSON write.) |
There was a problem hiding this comment.
Narrower than it reads. LC_ALL=C is also a non-UTF-8 locale and gets surrogateescape, so someone reproducing on Linux that way sees mojibake and no crash, and may conclude the harness is unrealistic. I measured both.
The rot is the part I would flag. PEP 686 makes UTF-8 mode the default in 3.15, which forces surrogateescape everywhere. Because the wrapper hardcodes strict, the tests keep passing while this sentence quietly stops being true, with nothing to catch it. Pinning it to Windows and a legacy code page would age better.
The sys.stderr parenthetical is correct and genuinely useful, so I would keep that half as is.
Addresses the review on microsoft#381. The load-bearing criticism was right: nothing asserted the fix worked. Reverting any of the five sinks failed only the source-level AST guard, never a behavioural test, because every behavioural test built its own Console. The comment claiming a behavioural test was infeasible was also wrong -- tests/test_cli/test_run.py already reaches app.py's success sink by patching conductor.cli.run.run_workflow_async. What the earlier attempt was missing was that mock, not a process boundary. TestRealSinksThroughTheCli adds that. Verified per sink: reverting app.py's success-sink ensure_ascii now fails two tests -- the guard and the behavioural one -- where before it failed only the guard. The doctor `data=` shape gets its own case because it goes through JSON.from_data rather than the string path the negative control exercises, so it is independently vulnerable. `text()` earns its place back as a result, along with the `_Cp1252Console` arguments that only mattered while something decoded the buffer. The module docstring described the version that had the CliRunner tests and was not true of the file as it stood. It now says what the file does, and says plainly that behavioural coverage was absent before this commit rather than quietly becoming true again. `ty` no longer errors on the samples: reading them back out of ParameterSet.values typed them as `object` and reached into _pytest.mark.structures, which is not public. They start life as a dict[str, str] now and the params are built from it. conductor doctor's table path is deliberately still out of scope, which is the option the review offered as an alternative to fixing it here. It hardcodes U+2713, U+2717 and U+25CB -- all unencodable in cp1252, and _render_env / _render_providers are separate print calls, so the report dies part-written. Fixing it means threading the console through ~18 call sites across six helpers that do not currently take one. Filed as microsoft#401, and both the module docstring and the glyph definitions now say so, so the AST guard's mention of conductor.cli.doctor reads as "its JSON sink is covered" rather than the whole module. CHANGELOG entry added; the PR had none. 535 passed in tests/test_cli. ruff check unchanged at 2 pre-existing findings, `ty check src` unchanged at 64, my files ruff-format and `ty` clean.
Frank Li (franklixuefei)
commented
Aug 10, 2026
Thanks — the "nothing asserts the fix works" finding was the important one, and it was correct. Addressed in The missing behavioural coverageYou were right on both counts: reverting any of the five sinks failed only the AST guard, and the comment claiming a behavioural test was infeasible was wrong.
The
|
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
Re-ran the per-sink mutation test against 7d5f135 rather than reading the description. The success sink is properly fixed, and that was the one that mattered.
Not ready to merge though, because the doctor case has the same problem the last round was about.
| reverted sink | what notices |
|---|---|
app.py:555 run success | AST guard and the new behavioural test |
app.py:568 run terminate | AST guard only |
app.py:1010 resume success | AST guard only |
app.py:1016 resume terminate | AST guard only |
doctor.py:102--json | AST guard only |
test_doctor_data_kwarg_survives_a_cp1252_stdout never reaches doctor.py. It builds a Console and passes ensure_ascii=True from the test body. I reverted doctor.py:102 and pointed _JSON_SINK_MODULES at conductor.cli.app alone so the guard could not cover for it, and all 8 tests passed.
Your own commit message on 45611f8 puts it better than I can: a regression guard that cannot fail on the regression is worse than no guard, because it manufactures confidence. What makes this worth fixing rather than deleting is that the reasoning above it is correct. JSON.from_data really is a separate path and really is independently vulnerable. The test just demonstrates that about rich instead of about us.
A real one runs to about 12 lines. run_doctor already takes console=output_console from app.py:1415, so the patch you used for the success sink works, plus the _gather_report patch test_doctor.py relies on. I wrote it to be sure rather than to be clever: passes as-is, fails on the revert, 0.12s. Left it inline.
Two smaller ones, also inline. The comment at 127 still says a behavioural test needs a full workflow execution while the class docstring at 182 says that claim was wrong, so the file now argues with itself about whether the thing you just did was possible. And the module docstring says reverting an ensure_ascii=True argument fails a test that exercised that sink, which holds for one sink out of five.
The three untested app.py sinks are cheap if you are in there anyway. :568 and :1016 want mock_run.side_effect = WorkflowTerminated(...), and :1010 wants the resume invocation.
Everything else I checked came back fine. ty is clean on the file with no _pytest internals left. text() and _raw are pulling their weight again, and keeping newline="" and force_terminal=False is right now that the tests decode the buffer. 535 passed, ruff and format clean.
Two things I want to call out properly rather than bury. The CHANGELOG entry says out loud that doctor's table path is still broken, which is the opposite of the instinct most people have when writing one. And the doctor deferral itself was the right call: I went and looked at the threading argument before accepting it, and it holds up. #401 has a repro someone can act on.
The rest can wait, as far as I'm concerned: the shared helper, the hardcoded module list, the misattributed warning in the terminate handlers, the PEP 686 note. Say the word on the two follow-ups and I will file them, since I am sitting on the reproductions.
| def test_doctor_data_kwarg_survives_a_cp1252_stdout(self) -> None: | ||
| """``doctor`` uses the ``data=`` shape, which takes a different path. | ||
| ``JSON.from_data`` rather than the string path the negative control | ||
| exercises, so it is independently vulnerable and worth its own case. | ||
| """ | ||
| holder = _Cp1252Console() | ||
| holder.console.print_json(data={"note": "arrow \u2192 check \u2705"}, ensure_ascii=True) | ||
| payload = json.loads(holder.text()) | ||
| assert payload["note"] == "arrow \u2192 check \u2705" |
There was a problem hiding this comment.
This never reaches doctor.py. It builds a Console and passes ensure_ascii=True itself, so what it asserts is that rich honours the keyword, which the negative control already covers from the other side.
I checked rather than assumed: reverted doctor.py:102, set _JSON_SINK_MODULES = ["conductor.cli.app"] so the guard could not cover for it, and got 8 passed.
The docstring reasoning is right, which is why this is worth fixing rather than dropping. run_doctor takes console=output_console from app.py:1415, so the patch from the test above works here too:
deftest_doctor_json_sink_survives_a_cp1252_stdout(
self, monkeypatch: pytest.MonkeyPatch
) ->None:
holder=_Cp1252Console()
report=DoctorReport(
env=EnvDiagnostic(
conductor_version="1.2.3 \u2192\u2705",
python_version="3.12.0",
platform="test",
update_checked=False,
update_available=None,
latest_version=None,
)
)
monkeypatch.setattr(doctor_mod, "_gather_report", lambda**kw: report)
withpatch("conductor.cli.app.output_console", holder.console):
result=CliRunner().invoke(app, ["doctor", "--json"])
assertresult.exit_code==0, result.outputpayload=json.loads(holder.text())
assertpayload["env"]["conductor_version"] =="1.2.3 \u2192\u2705"Verified both directions: passes on this head, fails with doctor.py:102 reverted. 0.12s.
| # It is deliberately a source assertion rather than a mock: driving the real | ||
| # ``conductor run`` command to its JSON sink requires standing up a full | ||
| # workflow execution, which belongs in an end-to-end subprocess test (tracked | ||
| # as follow-up on #342) rather than a unit test. |
There was a problem hiding this comment.
This is the claim the class docstring at 182 now says was wrong. Both are in the file, so it reads as though the author changed their mind halfway down.
The guard still earns its place, its job just got narrower once the behavioural tests landed:
| # It is deliberately a source assertion rather than a mock: driving the real | |
| # ``conductor run`` command to its JSON sink requires standing up a full | |
| # workflow execution, which belongs in an end-to-end subprocess test (tracked | |
| # as follow-up on #342) rather than a unit test. | |
| # It asserts the source rather than the behaviour, which is cheap and runs | |
| # anywhere. ``TestRealSinksThroughTheCli`` covers the behaviour, so this | |
| # guard's remaining job is the narrower one: catching a *new* sink added | |
| # without the keyword, which is what a source assertion is actually good at. |
| Patching only the console, never the code under test, means reverting an | ||
| ``ensure_ascii=True`` argument fails a test that exercised that sink. |
There was a problem hiding this comment.
True of the run success sink. Revert any of the other four and only the AST guard notices, so this is still promising a bit more than the file delivers.
Worth getting exactly right given the paragraph directly below makes a point of the file having overclaimed once already. If the doctor case gets its own behavioural test, this becomes true of two sinks and the data= mention above stays honest:
| Patchingonlytheconsole, neverthecodeundertest, meansrevertingan | |
| ``ensure_ascii=True``argumentfailsatestthatexercisedthatsink. | |
| Patchingonlytheconsole, neverthecodeundertest, meansrevertingthe | |
| ``ensure_ascii=True``ona*covered*sinkfailsatestthatexercisedit. The | |
| run-successand``doctor``sinksarecoveredthatway; theterminateandresume | |
| sinksstillrestontheASTguardalone. |
# Conflicts: # CHANGELOG.md
…ver the rest The doctor case had the same defect the last round was about. The test built its own Console and passed \�nsure_ascii=True\ from the test body, so it asserted that rich honours the keyword -- which the negative control already covers from the other side -- and never reached \doctor.py\. Reverting the sink there left it green. \ un_doctor\ takes \console=output_console\ from \�pp.py\, so the same patch the success sink uses works: patch only the console, plus \gather\, and drive the real function. While in here, cover the three remaining \�pp.py\ sinks -- \ un\'s terminate handler and \ esume\'s success/terminate pair. With the AST guard disabled so it cannot cover for them, reverting each of the five sinks now fails exactly one behavioural test; before this it was one of five. Docs corrected. The file argued with itself: a comment claimed a behavioural test needed a full workflow execution while the class docstring said that claim was wrong. The module docstring promised a revert fails a test that exercised the sink, which held for one sink. The guard's remaining job is the narrower one it is actually good at -- catching a *new* sink added without the keyword -- and its real blind spot, the hardcoded module list, is now named.
Frank Li (franklixuefei)
commented
Aug 11, 2026
Thanks -- the doctor finding was right, and it was the same defect as last round, which is the part worth sitting with. My own commit message said a guard that cannot fail on the regression manufactures confidence, and I then shipped exactly that one file down. Fixed in The doctor sink
monkeypatch.setattr("conductor.cli.doctor.gather", _fake_gather)
run_doctor(section="env", ..., as_json=True, console=holder.console, err_console=holder.console)
payload=json.loads(holder.text())I ran your check rather than assuming: reverted The other three sinksDone, since I was in there. Full per-sink matrix, run with
Each revert fails exactly one test, and it is the one that exercises that sink. The docsAll three were fair. The file argued with itself -- the comment at 127 claimed a behavioural test needed a full workflow execution while the class docstring said that claim was wrong. Took your wording for the guard comment; its job really is the narrower one now. I also named its actual blind spot, the hardcoded module list, since The module docstring said reverting an Class docstring said "the AST guard below" -- it is above. Follow-upsYes please, if you are still sitting on the reproductions: the shared cp1252 helper, the hardcoded module list, the misattributed warning in the terminate handlers, and the PEP 686 note. I would rather they were filed by whoever has the repro than described by me second-hand. 535 passed on |
# Conflicts: # CHANGELOG.md
The module docstring claimed behavioural coverage held for "all five sinks". That was true when written, but `app.py` now has twelve `print_json` calls: `status --json` (microsoft#389) and the `stop` payloads (microsoft#344, microsoft#399) landed while this PR was in review. All twelve carry `ensure_ascii=True` already, so the convention propagated on its own, but the claim had quietly become a count of the sinks the file happens to test rather than of the sinks that exist. Reworded to say what is actually true: `TestRealSinksThroughTheCli` covers the five sinks microsoft#342 was diagnosed against, and the source guard covers every sink in both modules, which is the check that catches an omission anyway. Also fixed "AST guard at the bottom", which stopped being the bottom when the behavioural tests were added below it. No behavioural change; docstrings and comments only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
LGTM. Approved!
Uh oh!
There was an error while loading. Please reload this page.
) conductor doctor's table output hardcodes the U+2713/U+2717/U+25CB glyphs for its Installed/Credentials/Connection/Models columns. None of those are encodable in cp1252, so a run on a legacy Windows console raises UnicodeEncodeError mid-table, after the Environment section has already printed: the report is truncated and the process exits non-zero for a run that actually succeeded. The --json path already guards this with ensure_ascii=True (microsoft#381); the table path did not. Fix: resolve a Unicode-or-ASCII glyph set once per run_doctor() call, from the output console's actual stream encoding, and thread it through every cell helper (_tier_cell, _credentials_cell, _connection_cell, _models_cell, _format_tokens, _default_effort_cell, _rate_cell, _render_registries) instead of reaching for a module-level _CHECK/_CROSS/_DASH/_OPTIONAL_MARK constant. A console with no encoding attribute (e.g. an in-memory buffer) is treated as capable, matching the existing "nothing to protect against" default. Fixesmicrosoft#401
* fix(cli): resolve doctor's glyph set per console encoding (#401) conductor doctor's table output hardcodes the U+2713/U+2717/U+25CB glyphs for its Installed/Credentials/Connection/Models columns. None of those are encodable in cp1252, so a run on a legacy Windows console raises UnicodeEncodeError mid-table, after the Environment section has already printed: the report is truncated and the process exits non-zero for a run that actually succeeded. The --json path already guards this with ensure_ascii=True (#381); the table path did not. Fix: resolve a Unicode-or-ASCII glyph set once per run_doctor() call, from the output console's actual stream encoding, and thread it through every cell helper (_tier_cell, _credentials_cell, _connection_cell, _models_cell, _format_tokens, _default_effort_cell, _rate_cell, _render_registries) instead of reaching for a module-level _CHECK/_CROSS/_DASH/_OPTIONAL_MARK constant. A console with no encoding attribute (e.g. an in-memory buffer) is treated as capable, matching the existing "nothing to protect against" default. Fixes#401 * fix(cli): address doctor review, add warn glyph and fix Models title dash jrob5756's review on #469 found the fix left one path open: the connection-note branch of _connection_cell still printed a bare warning glyph outside the resolved set, so conductor doctor --check still crashed on cp1252 for any provider whose probe came back inconclusive. Added a warn entry to _Glyphs (falls back to "!") and routed that branch through it. Also fixed the Models detail table title, which embedded a bare em dash and crashed on ascii/latin-1/cp437 independent of #401. Uses the already-resolved dash glyph's plain text instead. Corrected the _encodable and _resolve_glyphs docstrings per the review (StringIO.encoding is None, not absent; switched to MarkupFreeConsole.encoding instead of reaching through console.file). Reworded a docstring near _PRICING_SOURCE_CELLS that implied ASCII literals in general need no fallback rather than these four specifically. Test changes: added connection_note to the _prov helper and parametrized the cp1252 crash test over two diagnostic shapes crossed with no flags, --check and --models, since --check/--models are the only paths that render the Connection column at all. Fixed a misattributed comment and a tautological assertion in the same test. Filled in the Credentials and Notes cells in the missing-tier test so the dash assertion can only pass because of the branch under test.
Closes#342.
The bug
On Windows under a
cp1252locale,conductor runcrashed withUnicodeEncodeErrorafter the workflow had already completed successfully, while writing its final JSON result to stdout. The document was truncated mid-field so no caller could parse it, and the process exited non-zero, so automation concluded a successful run had failed. When it hit me, a 55-minute, $6.17 run looked like total data loss — the outputs were safe on disk, but nothing in the exit status or the dashboard said so.Root cause
json.dumpsalready emits pure ASCII by default, so conductor hands off a safe string. What re-introduces the unencodable character is rich:Console.print_jsondefaults toensure_ascii=Falseand re-parses/re-serialises the payload with the literal glyph immediately before the write. The JSON channel is made unsafe by the pretty-printer, not by our serialisation — that diagnosis is Jason Robert (@jrob5756)'s, and it moved the fix somewhere I would not have looked.Two details explain the artefact's shape:
_write_bufferchunks writes at 8192 chars onlyif WINDOWS(a CPython #82052 workaround). The ASCII chunks land; the chunk containing the glyph dies.sys.stderrerrors='backslashreplace'(never raises) whilesys.stdoutgetsstrictunder a non-UTF-8 locale, so 27,411 lines of progress output got through and only the final stdout write died.The change
ensure_ascii=Trueat all five JSON sinks —cli/app.py×4 andcli/doctor.py(thedata=kwarg form).Lossless by construction:
\uXXXXescapes (surrogate pairs for non-BMP) are valid JSON and decode back to the original text.Two alternatives were considered and rejected:
errors="replace"stops the crash but silently turns agent review feedback into?.errors="backslashreplace"emits\U0001f600for non-BMP, which is not valid JSON — JSON requires surrogate pairs.Tests
CI runs on
ubuntu-latest, so these simulate the platform rather than detect it — anything gated onsys.platformwould silently never run. Each behavioural test renders through aTextIOWrapperbound to strictcp1252and asserts both that nothing raises and that the payload round-trips throughjson.loads, which is the property a machine consumer actually depends on.Three things worth calling out, because the first version of these tests was weaker and a review caught it:
A production-source guard. The behavioural tests construct their own
Console, so on their own they would still pass if someone deletedensure_ascii=Truefrom the call sites — a regression guard that cannot fail on the regression. An AST assertion overcli/app.pyandcli/doctor.pynow requires everyprint_jsoncall to pass it. Verified against a simulated revert (fails, reportingapp.py:553,566,1008,1014) and against the fixed tree (passes). It also catches the likelier future regression: a new sink added without the keyword.A fixture guard. U+2014 EM DASH is deliberately excluded from the samples because cp1252 can encode it (
0x97), so it would make its cases pass vacuously.test_samples_are_genuinely_unencodableasserts each retained sample really does raise, and pins the EM DASH exclusion so the reasoning cannot rot. That asymmetry is also why this looked intermittent in the field — whether it fired depended on which glyph an agent happened to emit.A negative control, so a future rich release that changed its own default cannot leave the suite passing for a reason unrelated to conductor's code.
Deliberately not in this PR
Agreed with Jason Robert (@jrob5756) on the issue:
__main__.py+ theapp()callback rather thanconductor/__init__.py— that module is an advertised library entry point, so reconfiguring there would mutate process-global streams for any embedding host (pytest, ipykernel). Separate PR.PYTHONUTF8=1in_build_bg_env. Once the JSON sink is ASCII this is no longer load-bearing, and it is broader than it looks:cli/pid.pyreads and writes PID files with no explicit encoding, so making the child UTF-8 whileconductor stopstays on the locale codec would split-brain them — andUnicodeDecodeErroris a sibling ofJSONDecodeErrorunderValueError, so the existingexcept (json.JSONDecodeError, OSError)would not catch it. Wants its own change withpid.pyencodings pinned in the same commit.gates/human.py,gates/dialog.py) still render literal Unicode to stdout. That is out of scope here — it needs stream-level hardening, not JSON escaping — and I am not claiming universal Unicode safety.Known gap
These unit tests do not exercise rich's Windows-only 8192-char chunked-write path, so they cannot catch a truncation regression specifically. The right instrument is an end-to-end subprocess test running the CLI with
PYTHONIOENCODING=cp1252and assertingjson.loads(proc.stdout), which would run green on Linux CI. I have not added it here — happy to fold it in if you would rather it landed together.Verification
Ran on Windows with the affected interpreter. Without the change, all four samples raise
UnicodeEncodeErroron a strict cp1252 stream; with it, all four emit pure-ASCII documents that round-trip losslessly, including thedata=kwarg form used bydoctor.One correction to the issue's environment table, since it sent you looking: the workflow ran on the uv-managed Python 3.14.5 (so
requires-python >=3.12is satisfied and there is no hidden install bug). The3.10.11I quoted was the ambient interpreter I used for the standalone repro. Both givecp1252, so the repro stands.