fix(gates): read a terminal dialog reply as one multi-line turn - #510
Jason Robert (jrob5756) merged 6 commits into
Conversation
The dialog gate read each reply with single-line `Prompt.ask`, so pasting a block of text into an interactive terminal dispatched every line as its own turn. A three-line paste became three separate questions to the model, each answered against a fragment of the intended message, and the paste's trailing newline added a fourth turn with empty content. The human gate already had a multi-line reader for exactly this shape, so the loop is extracted to `read_multiline_lines(console, sentinel)` in human.py and the dialog's main turn reads through it. `HumanGateHandler._read_multiline` delegates with its historical `.` sentinel, so that gate's behaviour is unchanged. The reader returns `(text, hit_eof)` rather than a bare string because the two EOF cases are different answers: an EOF that terminates a paste should submit the accumulated content, while an EOF with nothing accumulated is a deliberate Ctrl-D and dismisses. Collapsing them would make the dismissal branch unreachable, since the reader converts EOF into a returned string. The dialog's sentinel is `/send` rather than the human gate's `.` because a lone `.` is a plausible line of prose in a free-form reply. Because that makes `/send` load-bearing for submitting a turn, `_display_dialog_start` advertises it — gated on the same `sys.stdin.isatty()` condition as the reader, since off a tty the turn falls back to `Prompt.ask` and the sentinel does nothing. Off a tty (a pipe, CI, or the web dashboard, which returns via `_web_handle_dialog` and never renders this banner) the single-line path is untouched. Verified: tests/test_gates 69 passed; full suite 8308 passed with three failures that reproduce identically on an unpatched checkout (chmod 0o000 and case-sensitivity tests that do not hold on this filesystem). Each new test fails against the unpatched gate. ruff check, ruff format --check and ty are clean.
@microsoft-github-policy-service agree company="Too Good To Go" |
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
Two blocking issues here, both in the same file and both about the same root cause: the new guards check user_input == "" but the reader only strips trailing newlines, not trailing whitespace. That lets a whitespace-only reply slip through the empty-submission guard and flips the intent of Ctrl-D after whitespace from "dismiss" to "submit." The second blocking issue is that the isatty() branch deciding which reader runs has no test in either direction, so either half of that condition can be deleted with the full suite still green. Neither is a large fix, but both need to land before merge.
Blocking
src/conductor/gates/dialog.py:282— whitespace bypasses the empty-submission guard; Ctrl-D after whitespace submits instead of dismissingsrc/conductor/gates/dialog.py:768— theisatty()gate that picks the reader has no coverage on either branch
The rest are recommended: a CHANGELOG/docs accuracy pass (Ctrl-C doesn't actually dismiss, the non-tty path did change, the dashboard is mischaracterized), a StopIteration handler in production code that exists only to satisfy a mock, missing tests for the empty-guard and the sentinel's whitespace tolerance, a sentinel default that contradicts the PR's own documentation, a docstring that promises stronger normalization than the code does, a banner template held together by an unenforced arity match between a hint fragment and its args, and three new tests that pass against the unpatched code despite the PR description's claim that all of them were confirmed to fail first.
| result.user_dismissed = True | ||
| break | ||
|
|
||
| if user_input == "": |
There was a problem hiding this comment.
BLOCKING
The new guard is an exact user_input == "" check, but human.py:88 only strips trailing newlines ("\n".join(lines).rstrip("\n")), not trailing whitespace. A buffer of spaces or tabs survives as a truthy string and slips past both new guards.
Traced end-to-end through handle_dialog on the tty path with only input() mocked:
- Typing
" "then/send→execute_dialog_turn(user_message=" ")gets awaited and aDialogMessage(role='user', content=' ')is recorded — exactly the case the CHANGELOG claims is fixed. - Typing
" "then Ctrl-D →hit_eof=Truebuttextis still truthy, so theif hit_eof and not textbranch at line 776 ("the user is leaving, not pasting") never fires. The whitespace gets dispatched as a turn and then the dialog dismisses. The user pressed Ctrl-D to leave and got a paste submission instead.
This isn't cosmetic — engine/workflow.py splices result.messages verbatim into the agent's re-execution guidance, so the agent gets re-run believing the user replied with whitespace. Nothing is logged on either path. _is_dismiss already normalizes with .strip().lower() elsewhere in this file, so these two guards are the outliers.
| if user_input == "": | |
| if not user_input.strip(): | |
| continue |
Same fix needed at line 776 (if hit_eof and not text.strip():), the docstring at 765-766 should say "nothing but whitespace accumulated" instead of "nothing accumulated," and it's worth a regression test with side_effect=[" ", EOFError()] asserting execute_dialog_turn.assert_not_awaited().
| accumulated content rather than dismissing; an EOF with nothing | ||
| accumulated is a deliberate Ctrl-D and still returns None. | ||
| """ | ||
| if prompt_text is None and sys.stdin.isatty(): |
There was a problem hiding this comment.
BLOCKING
if prompt_text is None and sys.stdin.isatty(): is the single condition choosing between two very different readers, and mutation testing shows the suite doesn't care which half you delete. Ran tests/test_gates plus tests/test_engine/test_dialog_integration.py, tests/test_cli/test_markup_injection.py, and tests/test_engine/test_resume.py (138 tests):
- Drop the
isatty()half → 138 passed. The multi-line reader would now activate under a pipe or in CI, which is the exact failure this gate exists to prevent. - Drop the
prompt_text is Nonehalf → also 138 passed. The yes/no confirmation prompt at line 351 would start requiring/sendafter "yes" on every interactive run.
The cause: lines 781-789 (the Prompt.ask fallback) never execute in this repo's test suite. Every pre-existing dialog test patches handler._get_user_input wholesale, and every new test runs with isatty=True. Coverage confirms it — Missing … 781-789. The human gate already has the mirror test for its own equivalent condition at tests/test_gates/test_human.py:679; this needs the same pair here.
@pytest.mark.asyncio
async def test_non_tty_main_turn_uses_the_single_line_prompt(self) -> None:
"""Off a tty the conversational turn must stay on Prompt.ask, never raw input()."""
with (
patch("conductor.gates.dialog.sys.stdin.isatty", return_value=False),
patch("conductor.gates.dialog.Prompt.ask", side_effect=["piped answer", "done"]) as ask,
patch("builtins.input", side_effect=AssertionError("must not read raw stdin")),
):
...
assert ask.call_count == 2
@pytest.mark.asyncio
async def test_confirmation_prompt_stays_single_line_on_a_tty(self) -> None:
"""`prompt_text` is a yes/no question -- it must not require the /send sentinel."""
with (
patch("conductor.gates.dialog.sys.stdin.isatty", return_value=True),
patch("conductor.gates.dialog.Prompt.ask", return_value="yes") as ask,
patch("builtins.input", side_effect=AssertionError("must not read multi-line")),
):
answer = await handler._get_user_input(prompt_text=styled("[bold]Continue?[/bold]"))
assert answer == "yes"
ask.assert_called_once()| so internal newlines survive and a paste is a single message; an empty | ||
| submission is no longer dispatched as a turn. Ctrl-D (Ctrl-Z then Enter on | ||
| Windows) submits what has been typed and dismisses the dialog when nothing | ||
| has, and Ctrl-C dismisses rather than propagating. The dialog uses `/send` |
There was a problem hiding this comment.
RECOMMENDED
"Ctrl-C dismisses rather than propagating" isn't true, and I don't think the test can fail even if it were wrong. A real Ctrl-C can't reach the except (EOFError, KeyboardInterrupt) at dialog.py:774: CPython only runs signal handlers on the main thread, PEP 475 silently retries the interrupted read, and the daemon thread blocked in input() never sees the signal. asyncio.Runner on 3.12+ handles SIGINT by cancelling the main task instead, so what actually surfaces at the await is CancelledError — which isn't in the catch tuple, correctly.
Verified this by sending a real SIGINT to a child process blocked in read_multiline_lines on a pty: the await raised CancelledError and KeyboardInterrupt escaped asyncio.run. There's no SIGINT handler anywhere in src/conductor, so Ctrl-C still tears the run down exactly like before.
tests/test_gates/test_dialog.py:844 (test_ctrl_c_dismisses_rather_than_propagating) only passes because patch("builtins.input", side_effect=KeyboardInterrupt()) raises inside the worker thread — the one route a genuine SIGINT can't take. That's the kind of test that gets cited later as proof this works when it doesn't.
Anyone who reads this changelog line and presses Ctrl-C expecting to close the dialog and keep the run going will instead kill the run and lose the session.
Drop "and Ctrl-C dismisses rather than propagating" from this entry, and fix the same claim in docs/workflow-syntax.md:1201 — Ctrl-D submits or dismisses, Ctrl-C aborts the run like it does everywhere else. Keep the except clause itself (it's correct for exceptions the reader raises), just rename the test to something like test_reader_exception_dismisses_rather_than_crashing_the_dialog and note in its docstring that a real SIGINT goes to the main thread and never exercises this path.
| Windows) submits what has been typed and dismisses the dialog when nothing | ||
| has, and Ctrl-C dismisses rather than propagating. The dialog uses `/send` | ||
| where the human gate keeps `.`, since a lone `.` is likelier to be prose in | ||
| a conversational reply. Off a tty — a pipe, CI, or the web dashboard — the |
There was a problem hiding this comment.
RECOMMENDED
Two things wrong in this one sentence.
First, the non-tty path did change. The new if user_input == "": continue at dialog.py:282 sits above the tty branch in the shared loop, and Prompt.ask returns "" for a blank line with no default set — confirmed by reading PromptBase.process_response and running it directly. Piping ["", "done"] with isatty() false now gives turns dispatched: 0; on origin/main that blank line was recorded as a DialogMessage, emitted as a dialog_message event, and dispatched to provider.execute_dialog_turn with empty content. The new behavior is fine — the problem is this changelog is the contract for anyone driving a dialog from a pipe or CI, and it tells them nothing changed when their turn count and event stream just did.
Second, the web dashboard isn't "off a tty" in the sense this implies. handle_dialog returns via _web_handle_dialog at line 207, before _display_dialog_start and before the loop — it reaches neither reader, and it's always received whole multi-line messages over the WebSocket. Grouping it with "a pipe, CI" reads as if it hits the Prompt.ask fallback, and someone reading docs/workflow-syntax.md:1201 will conclude the dashboard chat box can't take a multi-line message.
The inline comment at dialog.py:283-284 ("User submitted nothing on a tty") repeats the same mistake and will mislead the next person who touches this code.
Suggested rewording for both the CHANGELOG and the docs bullet:
Off a tty — a pipe or CI — replies are still read one line at a time and
/sendhas no effect; the one change on that path is that a blank line is now skipped instead of dispatched as an empty turn. The web dashboard is unaffected: it takes a separate path that already delivered each message whole.
And widen the code comment:
if not user_input.strip():
# Empty submission -- not a turn, and not dismissal either. On a
# tty this is a bare sentinel line; off a tty it is a blank line
# from the pipe, which Prompt.ask returns as "".
continue| while True: | ||
| try: | ||
| line = input() | ||
| except (EOFError, StopIteration): |
There was a problem hiding this comment.
RECOMMENDED
The comment here is honest that this except StopIteration exists only to satisfy exhausted unittest.mock side_effect lists — which is exactly why I don't think it belongs in shipped code. It's load-bearing: removing it breaks test_pasted_block_is_one_user_prompt_with_newlines and test_eof_mid_paste_submits_content_not_dismissal with RuntimeError: StopIteration interacts badly with generators and cannot be raised into a Future.
Two problems with leaving it:
- The comment's justification is narrower than what the code actually does.
input()doesn't originateStopIteration, but it does relay whateversys.stdin.readline()raises — confirmed with an iterator-backedsys.stdinwhere a plaininput()call propagated it.read_multiline_linesis a plain function, so PEP 479 doesn't help here. Nothing insrc/orplugins/swapssys.stdin/inputtoday, so there's no live trigger, but if one ever appears, a broken stdin source would get silently reinterpreted as "the user pressed Ctrl-D" and a truncated turn would go to the model with nothing logged. - It weakens the tests that exist. "The reader read past what the test supplied" — the exact bug class this PR is fixing — now passes as a clean submission instead of failing.
It also makes the new _read_multiline docstring's claim ("behavior is unchanged by the extraction") not quite accurate, since the pre-PR loop only caught EOFError.
# src/conductor/gates/human.py:77-81
try:
line = input()
except EOFError:
hit_eof = True
breakAnd terminate the test doubles explicitly instead of relying on the catch:
# tests/test_gates/test_dialog.py:749
side_effect=["line one", "line two", "line three", "/send", "done", EOFError()],
# tests/test_gates/test_dialog.py:774
side_effect=["ticket text", EOFError(), "done", EOFError()],Both produce identical assertions against a strict except EOFError:.
|
|
||
|
|
||
| def read_multiline_lines( | ||
| console: MarkupFreeConsole, sentinel: str = MULTILINE_SENTINEL |
There was a problem hiding this comment.
RECOMMENDED
AGENTS.md:161, added by this PR, says: "The two gates use different sentinels, so a caller passes the one it wants rather than relying on the default." A default parameter whose own documentation tells callers not to use it is compensating for a signature that shouldn't allow the wrong call in the first place.
Both production call sites already pass it explicitly (human.py:537, dialog.py:772). The default's only live users are four test call sites in tests/test_gates/test_human.py (766, 782, 794, 798), where it implicitly means "the human gate's sentinel" — exactly the ambiguity splitting the constant was supposed to remove.
The risk isn't hypothetical: read_multiline_lines is public, lives in a module dialog.py already imports from, and read_multiline_lines(console) is silently valid with no signal at compile time. It would quietly truncate a user's prose at any lone . line — data loss, not a crash.
def read_multiline_lines(
console: MarkupFreeConsole, *, sentinel: str
) -> tuple[str, bool]:Making it keyword-only also rules out a future (sentinel, console) transposition. The cost is two keyword additions at the production call sites and four in test_human.py, and AGENTS.md can then say "…so sentinel is required rather than defaulted" and mean it.
| """Read a multi-line answer from stdin (blocking; call on a thread). | ||
|
|
||
| Terminates on a line whose stripped text equals ``sentinel`` or on EOF. | ||
| Internal newlines are preserved; trailing blank lines are stripped. |
There was a problem hiding this comment.
RECOMMENDED
Lines 54 and 61-62 both say "trailing blank lines are stripped," but the implementation is "\n".join(lines).rstrip("\n") (line 88), which strips trailing newline characters, not trailing blank lines. Checked directly:
['a', '', ''] -> 'a' # empty trailing lines: stripped
['a', ' ', ''] -> 'a\n ' # whitespace-only trailing line: survives
"Blank line" ordinarily includes whitespace-only lines, so this overstates the guarantee — and it's the same gap behind the blocking whitespace issue above. A maintainer who trusts this docstring won't think to add .strip() at a call site that needs it. _read_multiline's docstring at line 534 inherits the same wording.
Either fix the wording in both places:
Internal newlines are preserved; trailing empty lines are dropped, but a trailing line of whitespace is kept verbatim.
or leave the code as-is and just note that changing it to a real .strip()-style rstrip would also eat meaningful trailing indentation from a pasted code block — which is probably the right call, so the docstring should give way instead.
| # treated the same as EOF: submit what has been accumulated. | ||
| hit_eof = True | ||
| break | ||
| if line.strip() == sentinel: |
There was a problem hiding this comment.
RECOMMENDED
Mutating if line.strip() == sentinel: to if line == sentinel: still leaves 138/138 tests passing — every existing test types the sentinel byte-exactly ("." at test_human.py:766, "/send" at :774 and test_dialog.py:749).
A trailing space after /send is a normal keystroke, and terminals and paste buffers add them routinely. Without .strip() here, that line would get silently swallowed into the message body and the user would sit at a prompt that never submits. .strip() predates this PR, but this PR is what makes a five-character typed sentinel the primary way to send a dialog turn, so it's carrying a lot more weight now than before.
Worth flagging the flip side while you're adding coverage: .strip() also means a /send line inside a pasted block (a chat log, a shell transcript) truncates the paste with no warning, and the rest gets eaten as the next turn. /send is a much safer choice than the human gate's ., so this isn't a design objection — but a one-line echo after submission ((sent 3 lines)) would let the user catch it immediately.
@pytest.mark.parametrize("typed", ["/send", " /send", "/send ", "\t/send "])
def test_sentinel_tolerates_surrounding_whitespace(self, typed: str) -> None:
with patch("builtins.input", side_effect=["body", typed, "unreachable"]):
text, hit_eof = read_multiline_lines(MagicMock(), sentinel="/send")
assert (text, hit_eof) == ("body", False)The "unreachable" entry also proves the reader stopped at the sentinel instead of just running out of mock values.
| # instruct the user to type something with no effect. The markup stays | ||
| # in the template because styled() inserts *values* verbatim. | ||
| if sys.stdin.isatty(): | ||
| multiline_hint = ( |
There was a problem hiding this comment.
RECOMMENDED
The banner template mixes + concatenation with adjacent-literal concatenation, and multiline_hint has to stay in sync with hint_args — one {} in the fragment per element in the tuple — with nothing enforcing that.
To be clear about what isn't broken: I rendered both branches and the output is correct today. The trailing " " glues cleanly to "Say ..." via adjacent-literal concatenation, so there's no double or missing space, and the placeholder counts line up.
The concern is the next edit. styled() raises IndexError on a missing argument and silently drops an extra one — both confirmed — and ty can't check a template assembled with +. Add a second {} to the hint without extending the tuple and you get an IndexError that only fires on the tty branch, which never runs in CI unless isatty is patched. Confirming the sentence is even correct today requires the same manual trace I just did, which is a lot of reader effort for not much information.
styled() splices a Text value in with its own spans re-anchored, so the hint can be pre-rendered and dropped into a fixed-arity template instead. Rendered and diffed against current output — byte-identical on both branches, ANSI spans included:
multiline_hint = (
styled(
" It can span multiple lines; send it with [bold]{}[/bold] on its own line.",
DIALOG_SUBMIT_SENTINEL,
)
if sys.stdin.isatty()
else Text("")
)
self.console.print(
Panel(
styled(
"[bold]Agent '{}'[/bold] would like to discuss its output with you.\n"
"[dim]Type your response below.{} Say [bold]done[/bold] or "
"[bold]/done[/bold] when finished.[/dim]",
agent.name,
multiline_hint,
),
title=Text.from_markup("[bold magenta]Dialog Mode[/bold magenta]"),
border_style="magenta",
)
)Separately, sys.stdin.isatty() now appears both here and at line 768 and has to stay in agreement, or the banner advertises a keystroke the reader ignores — right now that's held together by a comment alone. A shared module-level _reads_multiline_turn() predicate would state the rule once, and the existing patch("conductor.gates.dialog.sys.stdin.isatty") tests keep working unchanged.
| provider.execute_dialog_turn.assert_awaited_once() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_eof_mid_paste_submits_content_not_dismissal(self) -> None: |
There was a problem hiding this comment.
RECOMMENDED
The PR description says each new dialog test "was confirmed to fail against the unpatched gate, so they guard the behavior rather than merely describing it." I checked that by restoring origin/main's _get_user_input body onto DialogHandler via an autouse fixture (no repo files touched) and running TestDialogMultilineInput unmodified:
test_pasted_block_is_one_user_prompt_with_newlines FAILED <- genuine
test_eof_mid_paste_submits_content_not_dismissal PASSED
test_ctrl_d_at_empty_prompt_dismisses PASSED
test_ctrl_c_dismisses_rather_than_propagating PASSED
test_web_path_unaffected_by_multiline PASSED
(Excluding the two banner cases — that harness only reverts _get_user_input, and the [True] banner case does genuinely fail if the banner change is reverted separately.)
Why each one passes on old code: on origin/main the turn went through Prompt.ask → input(), so EOFError/KeyboardInterrupt already hit the pre-existing except (EOFError, KeyboardInterrupt): return None and produced the same dismissal. test_eof_mid_paste_submits_content_not_dismissal asserts user_msgs[0].content == "ticket text" and assert_awaited(), both true on old code since Prompt.ask reads exactly that one line. test_web_path_unaffected_by_multiline guards a branch that returns at line 207, before the loop containing read_multiline_lines is ever reached.
Only test_pasted_block_is_one_user_prompt_with_newlines currently holds the line on the actual fix. No production risk here — the risk is to the review record, since three of these are presented as proof and don't actually provide it.
For test_eof_mid_paste_submits_content_not_dismissal, the missing assertion is that the EOF after the text didn't dismiss:
assert result.user_dismissed is False
assert [m.content for m in result.messages if m.role == "user"] == ["ticket text", "done"]For test_ctrl_d_at_empty_prompt_dismisses, assert that the new reader was actually entered (e.g. that read_multiline_lines was called), so it pins this reader specifically rather than any reader. For the two structural guards, reword the docstrings to say "behavior preserved by the extraction" instead of implying regression coverage, and adjust the claim in the PR description to match.
The multi-line reader drops trailing newlines but deliberately keeps a whitespace-only line, since a real strip would eat the closing indentation of a pasted code block. Both new guards compared exactly against "", so a buffer of spaces or tabs survived as a truthy string and slipped past both. Two consequences, the second worse than the first. A whitespace-only submission was dispatched to the model as a turn and spliced into the agent's re-execution guidance. And an EOF with only whitespace typed did not reach the "user is leaving, not pasting" branch, so Ctrl-D submitted the whitespace as a turn and *then* dismissed -- the user asked to leave and sent a message instead. Nothing was logged on either path. Both guards now test stripped text, matching _is_dismiss, which already normalises this way and was the outlier's neighbour in the same file. Addresses a blocking review finding.
The condition choosing between the multi-line and single-line readers had no
test in either direction: deleting the isatty() half, or the
`prompt_text is None` half, left the whole suite green. The first would
activate the multi-line reader under a pipe or in CI, waiting for a sentinel
nobody can type; the second would make the yes/no confirmation demand /send
after "yes" on every interactive run. Both halves now have a test, and the
Prompt.ask fallback -- previously never executed by any test -- is exercised.
The rule itself moves into a `_reads_multiline_turn()` predicate so the reader
and the banner's sentinel hint consult one source, rather than two isatty()
calls kept in agreement by a comment. The banner's hint is pre-rendered as a
Text and spliced into a fixed-arity template: styled() raises IndexError on a
mismatch and silently drops an extra argument, and the arity was previously
maintained by hand across a `+`-concatenated fragment and its args tuple, on a
branch no test reached. On a tty the rendered output is byte-identical to
before, ANSI spans included. Off a tty the sentence regains the plural it has
upstream ("Type your responses below."), which the previous revision had
silently made singular on the one path this series leaves alone; that banner
is now byte-identical to upstream, and a test pins the wording, since a byte
comparison was otherwise the only thing that would catch it drifting again.
Requiring the sentinel to submit also made the terminal UI's own exit
instruction inert: the banner said "Say done or /done when finished", but on a
tty a dismiss keyword is only seen once the turn is submitted, so `done` alone
left the user at a prompt that never responded -- driving the real handler with
input() returning "done" every time, the dialog never exited in 30 reads. The
failure-recovery notice repeated the same premise, on the one screen where the
user most needs a reliable way out. `_dismiss_instruction()` now states that
rule once beside `_reads_multiline_turn()`, both sites render it, and both
directions are pinned. Off a tty the sentence is unchanged from upstream, since
every line is already a turn there.
Also tightens the extracted reader:
- `sentinel` is keyword-only and required. It is public, the two gates use
different sentinels, and `read_multiline_lines(console)` was silently valid
-- it would truncate a user's prose at any lone "." with no signal.
- Only `except EOFError` remains. The extraction had also caught
StopIteration, which a real stdin never produces here -- an exhausted or
non-tty stream raises EOFError and a closed one ValueError -- so the catch
was wider than any reachable input, and it let a test double read past what
it supplied and still pass as a clean submission. The reason it is not
caught is recorded at the clause, and a test pins it, so it is not
reinstated as an oversight.
- The sentinel's whitespace tolerance and the reader's trailing-line handling
are pinned; both survived mutation before. So is the keyword-only signature
itself, since restoring one default silently re-opens the hazard and nothing
else would fail.
`test_ctrl_d_at_empty_prompt_dismisses` now uses a bounded EOF source. A bare
`side_effect=EOFError()` re-raises forever, so deleting the dismissal branch
hung the suite instead of failing it, and there is no pytest-timeout
configured -- a hung CI job rather than a readable failure. That test also
pins `read_on_daemon_thread` as the dispatch: a cancelled asyncio.to_thread
leaves its worker blocked in input() holding a slot in the shared default
executor, which that function's own docstring explains at length.
A pasted block's indentation is pinned end to end, leading edge included: the
guards strip only to decide whether anything was submitted, while the text
itself must reach the provider verbatim, and adding a strip to the returned
turn text previously passed the whole suite while silently reindenting a
pasted code block.
Every guard in this series is mutation-tested: reverting any of them fails at
least one test.
Addresses a blocking review finding plus seven recommended ones.
All three were verified wrong by execution, having been asserted as verified. "Ctrl-C dismisses rather than propagating" is removed. CPython runs signal handlers on the main thread only and the read happens on a daemon thread, so a real SIGINT never reaches that except clause: sending one to a child blocked in the reader on a pty raises CancelledError at the await and KeyboardInterrupt escapes asyncio.run. Ctrl-C tears the run down as it does everywhere else, and a reader who trusted this line would lose their session expecting to keep it. The except clause is correct for exceptions the reader itself raises and stays; its test is renamed and its docstring now says explicitly that it does not cover a real SIGINT. "Off a tty the single-line path is unchanged" is corrected. Prompt.ask returns "" for a blank line and the empty-submission guard sits above the tty branch, so a blank piped line is now skipped where it was previously dispatched as a turn with empty content. The new behaviour is right, but that path is the contract for anyone driving a dialog from CI, and it said nothing had changed. Grouping the web dashboard with "a pipe, CI" is corrected. It returns before either reader and has always received whole messages, so the old wording implied its chat box could not take a multi-line reply. Two things the prose did not say at all are now stated. A terminal accepts an EOF keystroke only at the start of a line, and its EOF does not persist -- the read returns and the terminal is readable again -- so Ctrl-D after entering a line now submits it and a second Ctrl-D is needed to leave, where one used to exit. On an empty prompt it still exits in one keystroke, so the habitual exit only changes once something has been entered, and that text is now sent rather than discarded. And the "trailing blank lines are stripped" overstatement is dropped: a whitespace-only trailing line is kept verbatim, which is deliberate -- stripping it would eat a pasted code block's closing indentation -- and is now pinned by a test. Addresses three recommended review findings.
|
Thank you — this was an unusually careful review, and it caught a real bug plus three false claims in my own write-up. I reproduced every finding before acting on it; all twelve stood up, including your mutation-test counts (138/138 against either half of the reader gate). Everything is addressed. BlockingWhitespace bypassing the empty-submission guard. Confirmed exactly as described, including the worse half: The untested The three false claimsThese are the ones I'm most grateful for, since I had asserted them as verified. Ctrl-C. You're right, and I reproduced it your way — real SIGINT to a child blocked in the reader on a pty gave The non-tty path did change. Measured both sides: The three tests that pass against unpatched code. Reproduced your table — 1 of 5. Worth recording how I got this wrong, since my description presented it as evidence: my original check reverted the whole Recommended — all taken
A bug of my own, found while re-checking thisRequiring This was the worse half of the change: I added the
Two smaller things, also mineThe banner's non-tty wording had silently changed from your original "Type your responses below." (plural) to the singular, on the one path this PR describes as untouched. Restored — the non-tty banner now renders byte-identically to Ctrl-D after typing something now takes two presses, and the first one sends your draft. A terminal's EOF does not persist, so on
Two more guards pinned. The non-tty banner wording and the choice of A pasted block containing a lone One I'd rather leave to you
Still open from the original descriptionThe |
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
LGTM, thanks for contributing!
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #510 +/- ##
=======================================
Coverage ? 91.93%
=======================================
Files ? 164
Lines ? 26735
Branches ? 0
=======================================
Hits ? 24580
Misses ? 2155
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…terminal-input # Conflicts: # CHANGELOG.md
…terminal-input # Conflicts: # CHANGELOG.md
PDA-115 Sync the fork with upstream after microsoft#510 and microsoft#514
Fixes #509.
What changed
Dialog mode was the only free-text human-input surface that could not accept a multi-line answer, so pasting a block into a terminal dialog dispatched every line as its own turn. The human gate's reader is extracted to
read_multiline_lines(console, *, sentinel)inhuman.pyand the dialog's terminal turn reads through it, so a paste arrives as one turn with its newlines intact and there is one reader rather than two.HumanGateHandler._read_multilinedelegates with its existing.sentinel, leaving that gate behaviourally unchanged.Off a tty (a pipe or CI) the single-line path stays, with one deliberate change: a blank line is now skipped instead of dispatched as an empty turn. The web dashboard is untouched — it returns at
dialog.py:207, before either reader, and has always received whole messages.Design decisions worth a reviewer's attention
Both submission guards compare stripped text. The reader drops trailing newlines but deliberately keeps a whitespace-only line (stripping it would eat the closing indentation of a pasted code block), so an exact
== ""check would dispatch" "as a turn and turn Ctrl-D into a submission.not user_input.strip()/not text.strip()match_is_dismiss, which already normalises this way._reads_multiline_turn()states the tty rule once. The reader and the banner's sentinel hint both consult it, so the banner cannot advertise a keystroke the reader ignores. Both branches of the reader gate now have a test.A dismiss keyword now needs the sentinel too. Requiring
/sendto submit made the banner's own "Saydoneor/donewhen finished" inert on a tty —doneis content until the turn is sent, so the dialog never exited._dismiss_instruction()states that rule once beside_reads_multiline_turn(); the banner and the failure-recovery notice both render it, and both directions are pinned. Off a tty the sentence is unchanged frommain.Ctrl-D now costs two keystrokes if you have typed something. A terminal's EOF does not persist, so on
maina single Ctrl-D raised out ofPrompt.askand ended the dialog. Now it submits what is typed and returns to a fresh prompt, so leaving a half-written reply takes a second Ctrl-D — and the first one sends that reply. Ctrl-D on an empty prompt still exits in one keystroke. This is the price of "an EOF that terminates a paste submits it", which is the behaviour the fix is for; it is now stated in the CHANGELOG and the docs rather than left for a user to discover.sentinelis keyword-only and required.read_multiline_linesis public and the two gates use different sentinels; a positional default meantread_multiline_lines(console)was silently valid and would truncate prose at any lone../sendfor the dialog,.kept for the human gate. A lone.is likelier to be prose in a conversational reply — but.already terminates multi-paragraph free text inquestionswithout complaint, so consistency is a fair counter-argument. Alternatives if you prefer:.for both (a reply containing a lone.truncates),/sendfor both (a behaviour change for existing gate users), or configurable (disproportionate). It is a single constant that the banner and every test reference rather than a literal, so switching is a one-line edit — say which you want.The change is three commits: the guard fix, the test coverage plus the reader's tightened contract, and the docs correction. Each builds and tests green on its own.
Verification
upstream/maincheckout (chmod 0o000permission tests and a case-insensitive-filesystem test that do not hold on macOS APFS); confirmed pre-existing rather than assumed.ruff check,ruff format --check,ty check,test_markup_guards.py— clean.Mutation-test evidence for each new guard
Run over
tests/test_gates tests/test_engine/test_dialog_integration.py tests/test_cli/test_markup_injection.py tests/test_engine/test_resume.py— the set used in review, which previously passed 138/138 against every one of these mutations:_reads_multiline_turn()half of the reader gateprompt_text is Nonehalf of the reader gateuser_input == ""not textsentinelloses its.strip()read_on_daemon_threadswapped forasyncio.to_threaddonewithout the sentinel.strip()added to the returned turn textsentinelreverts to a positional defaultStopIterationre-added to the reader'sexceptThe last row is the bounded-EOF change:
side_effect=EOFError()re-raises forever, so a spinning loop used to hang the suite instead of failing it. The replacement raises after 10 reads with a named assertion.Behaviour before and after, driving the real
DialogHandlerwith only the input source mocked:" "then/sendDialogMessage(content=' ')" "then Ctrl-D"a","b"then/send'a\nb'Corrections to the previous revision's description and docs
Three claims in the earlier description and docs were wrong, and are corrected here rather than restated:
"Ctrl-C dismisses rather than propagating" was false and is removed from the CHANGELOG and docs. CPython runs signal handlers on the main thread only and the read is on a daemon thread, so a real SIGINT never reaches that
except. Verified by sending an actual SIGINT to a child blocked in the reader on a pty: the await raisedCancelledErrorandKeyboardInterruptescapedasyncio.run. Theexceptclause is correct for exceptions the reader itself raises, so it stays; the test is renamed totest_reader_exception_dismisses_rather_than_crashing_the_dialogwith a docstring saying explicitly that it does not cover Ctrl-C."Off a tty the single-line path is unchanged" was false.
Prompt.askreturns""for a blank line and the empty guard sits above the tty branch. Measured:upstream/maindispatched that blank line as a turn with empty content; this skips it. Now stated in the CHANGELOG, since that path is the contract for anyone driving a dialog from CI.Grouping the web dashboard with "a pipe, CI" was wrong — it implies the dashboard hits the
Prompt.askfallback and cannot take a multi-line message. It returns before either reader. Fixed in both the CHANGELOG and the docs bullet.The claim that every new test was confirmed to fail against the unpatched gate was wrong, and the correction is the substantive one. Re-checked with the reviewer's method (reverting only
_get_user_input): only 1 of 5 genuinely failed. The original check reverted the wholedialog.py, which also removed itsimport sys, so three tests failed onpatch(...sys.stdin.isatty)raisingAttributeError— a harness failure that looked like a behavioural one. Those tests are now either strengthened until they do fail against the unpatched code, or their docstrings say plainly that they pin behaviour preserved by the extraction rather than guarding the fix.