Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ step-by-step checklist.
- `factory.py` - Provider instantiation

- **gates/**: Human-in-the-loop support
- `human.py` - Rich terminal UI for human gate interactions
- `human.py` - Rich terminal UI for human gate interactions. `read_multiline_lines(console, sentinel)` is the shared blocking multi-line stdin reader behind both the human gate's `.` sentinel (`MULTILINE_SENTINEL`) and the dialog gate's `/send` (`DIALOG_SUBMIT_SENTINEL`); it returns `(text, hit_eof)` because an EOF that yielded no text is a deliberate dismissal while the sentinel with no text is an empty submission. `sentinel` is keyword-only and required, since the two gates use different ones
- `dialog.py` - Dialog-mode gate. On a tty the main turn reads through `read_multiline_lines`, so a pasted block is one turn rather than one turn per line; off a tty it falls back to single-line `Prompt.ask`, where the sentinel has no effect — the `_reads_multiline_turn()` predicate states that rule once, and `_display_dialog_start` gates its sentinel hint on it so the banner never advertises a keystroke the reader ignores. Both submission guards compare against stripped text: the reader drops trailing newlines but keeps a whitespace-only line, so an exact `== ""` check would dispatch whitespace as a turn and turn Ctrl-D into a submission

- **interrupt/**: Interactive workflow interruption (Esc/Ctrl+G to pause)
- `listener.py` - Keyboard listener daemon thread for Esc/Ctrl+G detection
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
entirely. See
[`examples/claude-agent-sdk-setting-sources.yaml`](examples/claude-agent-sdk-setting-sources.yaml).

### Fixed

- **A multi-line reply to a terminal dialog is now one turn** (#509) —
dialog mode was the only free-text human-input surface that could not accept
a multi-line answer (`QuestionDef.multiline` defaults to `True` and
`GateOption.multiline` opts in, both served by one reader in `gates/human.py`).
The dialog gate read a reply with single-line `Prompt.ask`, so pasting a block
of text into an interactive terminal dispatched *each line* as its own turn: a
three-line paste became three separate questions to the model, each answered
against a fragment, and the paste's trailing newline added a fourth turn with
empty content. Terminal turns now read through the multi-line reader already
behind the human gate's `.` sentinel, submitted with `/send` on its own line,
so internal newlines survive and a paste is a single message. An empty or
whitespace-only submission is no longer dispatched as a turn. A dismiss
keyword is recognised only once a turn is submitted, so on a tty `done` now
needs `/send` after it, and both the opening banner and the failure-recovery
notice say so rather than naming a keystroke that does nothing there.

Ctrl-D at the start of a line (Ctrl-Z then Enter on Windows) also submits the
lines entered so far, or dismisses the dialog when there are none. Because a
terminal's EOF does not persist, abandoning a part-written reply that way now
sends what was already entered and a second Ctrl-D is needed to leave, where
one used to exit; on an empty prompt it still exits in one keystroke.

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 or CI —
replies are still read one line at a time and `/send` has 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.

## [0.1.36](https://github.com/microsoft/conductor/compare/v0.1.35...v0.1.36) - 2026-09-02

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,7 @@ After the conversation, the agent re-executes with the dialog transcript as addi

**Behavior notes:**
- Dialog is supported on regular `agent` type only (not `human_gate`, `questions`, `script`, `workflow`, or `wait`)
- In an interactive terminal, a reply may span multiple lines — paste or type freely and submit the turn with `/send` on its own line. A dismiss keyword ends the dialog the same way, so `done` needs `/send` after it there; off a tty every line is already a turn, so it does not. Ctrl-D at the start of a line (Ctrl-Z then Enter on Windows) also submits whatever lines have been entered so far, or dismisses the dialog when none have — so abandoning a part-written reply that way sends the lines already entered; press it on an empty prompt to leave without sending. An empty or whitespace-only submission is skipped rather than sent. Off a tty (a pipe or CI) replies are read one line at a time and `/send` does not apply, though a blank line is skipped rather than sent as an empty turn. The web dashboard is unaffected — its chat box takes a separate path that has always delivered each message whole, multi-line included
- In web dashboard mode, the dialog temporarily replaces the graph area with a chat interface
- When `--skip-gates` is set (e.g., CI/automation), dialogs are automatically skipped
- The evaluator prompt should describe *when* to trigger dialog, not *what* to ask — the evaluator generates the opening question from the agent's output context
Expand Down
99 changes: 93 additions & 6 deletions src/conductor/gates/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import asyncio
import json
import logging
import sys
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
Expand All @@ -22,6 +23,11 @@

from conductor.console import MarkupFreeConsole, make_console, styled
from conductor.executor.linkify import linkify_markdown
from conductor.gates.human import (
DIALOG_SUBMIT_SENTINEL,
read_multiline_lines,
read_on_daemon_thread,
)

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -81,6 +87,38 @@
_READY_MARKER = "[READY_TO_CONTINUE]"


def _reads_multiline_turn() -> bool:
"""Whether a conversational dialog turn is read multi-line.

Off a tty the turn falls back to a single-line ``Prompt.ask``, where the
submit sentinel has no effect. Both the reader in
``DialogHandler._get_user_input`` and the opening banner's sentinel hint
ask this, so the banner can never advertise a keystroke the reader ignores.
"""
return sys.stdin.isatty()


def _dismiss_instruction() -> Text:
"""How to actually leave the dialog, phrased for the active reader.

A dismiss keyword is only recognised once the turn is *submitted*, so on a
tty it needs the sentinel after it. Saying "type done" there describes a
keystroke that does nothing until the reply is sent -- the exit instruction
has to move with :func:`_reads_multiline_turn` or it contradicts the very
banner that advertises the sentinel.

Returns:
A pre-styled ``Text``, so a caller splices it through ``styled`` and
keeps its own template a literal (#406).
"""
if _reads_multiline_turn():
return styled(
"send [bold]done[/bold] or [bold]/done[/bold] with [bold]{}[/bold]",
DIALOG_SUBMIT_SENTINEL,
)
return Text.from_markup("say [bold]done[/bold] or [bold]/done[/bold]")


def _extract_ready_marker(response: str) -> tuple[bool, str]:
"""Return ``(proposed, cleaned)`` for an agent response.

Expand Down Expand Up @@ -273,6 +311,12 @@ async def handle_dialog(
result.user_dismissed = True
break

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

This line has no test coverage at all — --cov-report=term-missing flags it as missing, and deleting the whole if user_input == "": continue block leaves 138/138 tests passing across tests/test_gates, tests/test_engine/test_dialog_integration.py, tests/test_cli/test_markup_injection.py, and tests/test_engine/test_resume.py.

So nothing actually pins the behavior the CHANGELOG advertises. Without this guard, a bare /send with nothing typed above it appends an empty DialogMessage, emits a dialog_message event, and sends an empty turn to the provider — a real API call with no content. Neither half of what matters is verified: that the empty submission isn't dispatched, and that the loop keeps going so a real turn afterward still works.

@pytest.mark.asyncio
async def test_bare_sentinel_is_skipped_and_the_loop_continues(self) -> None:
    """An empty submission is neither a turn nor dismissal; the next turn still runs."""
    ...
    with (
        patch.object(handler, "_ask_engagement", new_callable=AsyncMock, return_value="engage"),
        patch("conductor.gates.dialog.sys.stdin.isatty", return_value=True),
        patch("builtins.input", side_effect=["/send", "real turn", "/send", "done", "/send"]),
    ):
        result = await handler.handle_dialog(...)
    user_msgs = [m.content for m in result.messages if m.role == "user"]
    assert user_msgs == ["real turn", "done"]        # no "" turn recorded
    provider.execute_dialog_turn.assert_awaited_once()  # and none dispatched
    assert result.user_dismissed is True                 # loop continued, then exited

This fails against the unguarded code (an empty message shows up first and the provider gets awaited twice). Worth pairing with a non-tty variant for the blank-piped-line case from the CHANGELOG finding above.


result.messages.append(DialogMessage(role="user", content=user_input))
self._emit_event(
"dialog_message",
Expand Down Expand Up @@ -309,9 +353,9 @@ async def handle_dialog(
exc_info=True,
)
self.console.print(
Text.from_markup(
"[dim red] (Agent response failed — you can continue "
"or type 'done')[/dim red]"
styled(
"[dim red] (Agent response failed — you can continue, or {})[/dim red]",
_dismiss_instruction(),
)
)
continue
Expand Down Expand Up @@ -600,15 +644,40 @@ def _display_dialog_start(
base_dir: Path | None = None,
) -> None:
"""Display the dialog opening with full agent context."""
# Advertised only where the sentinel applies -- see
# _reads_multiline_turn. Pre-rendered as a Text so the outer template
# has fixed arity: styled() splices a Text in with its own spans
# re-anchored, and raises IndexError on a template/argument mismatch
# that only the tty branch would reach. Off a tty the sentence keeps
# its original plural, since each line really is a separate response
# there.
instruction = (
styled(
"Type your response below. It can span multiple lines; send it"
" with [bold]{}[/bold] on its own line. A dismiss keyword ends"
" the dialog the same way: send [bold]done[/bold] or"
" [bold]/done[/bold] with [bold]{}[/bold].",
DIALOG_SUBMIT_SENTINEL,
DIALOG_SUBMIT_SENTINEL,
)
if _reads_multiline_turn()
# Byte-identical to upstream's sentence: off a tty every line is a
# turn already, so a dismiss keyword needs nothing after it.
else Text.from_markup(
"Type your responses below. Say [bold]done[/bold] or "
"[bold]/done[/bold] when finished."
)
)

self.console.print()
self.console.print(
Panel(
styled(
"[bold]Agent '{}'[/bold] would like to discuss "
"its output with you.\n"
"[dim]Type your responses below. Say [bold]done[/bold] or "
"[bold]/done[/bold] when finished.[/dim]",
"[dim]{}[/dim]",
agent.name,
instruction,
),
title=Text.from_markup("[bold magenta]Dialog Mode[/bold magenta]"),
border_style="magenta",
Expand Down Expand Up @@ -732,8 +801,26 @@ async def _get_user_input(
passing an interpolated f-string here.

Returns:
User input text, or None on EOF/error.
User input text, or None on EOF/error, which the caller treats as
dismissal. The main turn (``prompt_text is None`` on a tty) reads
multi-line, so an EOF that *terminates a paste* returns the
accumulated content rather than dismissing; an EOF with nothing
but whitespace accumulated is a deliberate Ctrl-D and still
returns None.
"""
if prompt_text is None and _reads_multiline_turn():
self.console.print(styled("[bold magenta]You[/bold magenta]"))
try:
text, hit_eof = await read_on_daemon_thread(
lambda: read_multiline_lines(self.console, sentinel=DIALOG_SUBMIT_SENTINEL)
)
except (EOFError, KeyboardInterrupt):
return None
if hit_eof and not text.strip():
# Ctrl-D at an empty prompt: the user is leaving, not pasting.
return None
return text

prompt = styled("[bold magenta]You[/bold magenta]") if prompt_text is None else prompt_text
try:

Expand Down
83 changes: 62 additions & 21 deletions src/conductor/gates/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,68 @@
MULTILINE_SENTINEL = "."
"""Line that terminates a multi-line answer when typed on its own."""

DIALOG_SUBMIT_SENTINEL = "/send"
"""Line that submits a multi-line dialog turn.

Distinct from :data:`MULTILINE_SENTINEL` because a lone ``.`` is likelier to
be an ordinary line of prose in a conversational reply than in a gate answer.
"""


def _eof_key_hint() -> str:
"""Return the platform-appropriate EOF keystroke for display."""
return "Ctrl-Z then Enter" if sys.platform == "win32" else "Ctrl-D"


def read_multiline_lines(console: MarkupFreeConsole, *, sentinel: str) -> tuple[str, bool]:
"""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 empty lines are dropped, but a
trailing line of whitespace is kept verbatim -- a real strip would eat the
meaningful indentation of a pasted code block.

Args:
console: Console to print the input hint to.
sentinel: Line that, typed alone, submits the accumulated text.
Keyword-only and required: the two gates use different sentinels,
so a caller states which one it means.

Returns:
``(text, hit_eof)`` -- the collected text and whether the read ended at
EOF rather than at the sentinel. Callers need the distinction because
an EOF that yielded no text is a deliberate dismissal (Ctrl-D at an
empty prompt), whereas the sentinel with no text is merely an empty
submission.
"""
console.print(
styled(
" [dim]Enter your answer. Finish with '{}' on its own line (or {}).[/dim]",
sentinel,
_eof_key_hint(),
)
)
lines: list[str] = []
hit_eof = False
while True:
try:
line = input()
except EOFError:
# The only end-of-input a real stdin produces here: an exhausted
# or non-tty stream raises EOFError, a closed one ValueError.
# StopIteration is deliberately *not* caught -- input() would only
# relay it from a contrived stdin replacement, and treating it as
# EOF would submit a truncated turn as if the user had pressed
# Ctrl-D. A test double that runs past what it supplied is a bug
# in the test, so it must surface rather than read as a dismissal.
hit_eof = True
break
if line.strip() == sentinel:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

break
lines.append(line)
return "\n".join(lines).rstrip("\n"), hit_eof


async def read_on_daemon_thread[T](fn: Callable[[], T]) -> T:
"""Run a blocking stdin read on a daemon thread and await its result.

Expand Down Expand Up @@ -477,30 +533,15 @@ def _ask_value() -> str:
def _read_multiline(self) -> str:
"""Read a multi-line answer from stdin (blocking; call in a thread).

Terminates on a line containing only ``.`` or on EOF. The sentinel is
listed first in the hint because Ctrl-D/Ctrl-Z differs by platform.
Delegates to the shared :func:`read_multiline_lines` with this gate's
historical ``.`` sentinel.

Returns:
The collected text with trailing blank lines stripped. Internal
newlines are preserved.
The collected text, with trailing empty lines dropped and internal
newlines preserved.
"""
self.console.print(
styled(
" [dim]Enter your answer. Finish with '{}' on its own line (or {}).[/dim]",
MULTILINE_SENTINEL,
_eof_key_hint(),
)
)
lines: list[str] = []
while True:
try:
line = input()
except EOFError:
break
if line.strip() == MULTILINE_SENTINEL:
break
lines.append(line)
return "\n".join(lines).rstrip("\n")
text, _ = read_multiline_lines(self.console, sentinel=MULTILINE_SENTINEL)
return text


@dataclass
Expand Down
Loading