Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
49 changes: 46 additions & 3 deletions src/conductor/gates/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,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 @@ -283,6 +288,11 @@ async def handle_dialog(
result.user_dismissed = True
break

if user_input == "":
# User submitted nothing on a tty (e.g. an accidental bare
# sentinel line) -- not a turn, and not dismissal either.
continue

result.messages.append(DialogMessage(role="user", content=user_input))
self._emit_event(
"dialog_message",
Expand Down Expand Up @@ -629,15 +639,31 @@ def _display_dialog_start(
base_dir: Path | None = None,
) -> None:
"""Display the dialog opening with full agent context."""
# Gated on the same condition as the multi-line reader in
# _get_user_input: off a tty, that turn falls back to a single-line
# Prompt.ask and the sentinel does nothing, so advertising it would
# 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 = (
" It can span multiple lines; send it with [bold]{}[/bold] on its own line."
)
hint_args: tuple[object, ...] = (DIALOG_SUBMIT_SENTINEL,)
else:
multiline_hint = ""
hint_args = ()

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]Type your response below." + multiline_hint + " "
"Say [bold]done[/bold] or [bold]/done[/bold] when "
"finished.[/dim]",
agent.name,
*hint_args,
),
title=Text.from_markup("[bold magenta]Dialog Mode[/bold magenta]"),
border_style="magenta",
Expand Down Expand Up @@ -761,8 +787,25 @@ 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
accumulated is a deliberate Ctrl-D and still returns None.
"""
if prompt_text is None and sys.stdin.isatty():
self.console.print(styled("[bold magenta]You[/bold magenta]"))
try:
text, hit_eof = await read_on_daemon_thread(
lambda: read_multiline_lines(self.console, DIALOG_SUBMIT_SENTINEL)
)
except (EOFError, KeyboardInterrupt):
return None
if hit_eof and not text:
# 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
69 changes: 50 additions & 19 deletions src/conductor/gates/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,58 @@
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; prose-safe vs a lone '.'."""


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 = MULTILINE_SENTINEL
) -> 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 blank lines are stripped.

Args:
console: Console to print the input hint to.
sentinel: Line that, typed alone, submits the accumulated text.

Returns:
``(text, hit_eof)`` -- the collected text with trailing blank lines
stripped, 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, StopIteration):
# StopIteration only ever arises from a test double's exhausted
# ``side_effect`` list (real ``input()`` never raises it) --
# treated the same as EOF: submit what has been accumulated.
hit_eof = True
break
if line.strip() == sentinel:
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 +523,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, so behavior is unchanged by the extraction.

Returns:
The collected text with trailing blank lines stripped. Internal
newlines are 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, MULTILINE_SENTINEL)
return text


@dataclass
Expand Down
163 changes: 162 additions & 1 deletion tests/test_gates/test_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from conductor.config.schema import AgentDef, DialogConfig
from conductor.gates.dialog import DialogHandler, DialogResult
from conductor.gates.human import DIALOG_SUBMIT_SENTINEL


class TestDialogHandlerSkip:
Expand Down Expand Up @@ -477,7 +478,7 @@ async def test_question_with_marker_is_not_a_proposal(self) -> None:

@pytest.mark.asyncio
async def test_dismiss_keyword_exits_at_approval_prompt(self) -> None:
""""done" ends a genuine proposal, not just yes/y/empty."""
""" "done" ends a genuine proposal, not just yes/y/empty."""
handler, _ = self._make_handler(
[
{"type": "dialog_message", "content": "answer one"},
Expand Down Expand Up @@ -842,3 +843,163 @@ def test_console_panel_renders_non_ascii_unescaped(self) -> None:
assert any("你好 мир" in body for body in markdown_bodies)
assert all("\\u4f60" not in body for body in markdown_bodies)
assert all("\\u043f" not in body for body in markdown_bodies)


class TestDialogMultilineInput:
"""Terminal dialog turns must accept pasted multi-line blocks (AC1/AC3/AC4/AC5)."""

@pytest.mark.asyncio
async def test_pasted_block_is_one_user_prompt_with_newlines(self) -> None:
"""A pasted block is ingested as ONE prompt with internal newlines intact."""
handler = DialogHandler(console=MagicMock())
agent = AgentDef(name="t", prompt="p", dialog=DialogConfig(trigger_prompt="t"))
provider = MagicMock()
provider.execute_dialog_turn = AsyncMock(return_value="ack")
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=["line one", "line two", "line three", "/send", "done"],
),
):
result = await handler.handle_dialog(
agent=agent,
agent_output={"result": "x"},
opening_question="Q?",
provider=provider,
)
user_msgs = [m for m in result.messages if m.role == "user"]
# Exactly one paste ingested as a single prompt, both newlines intact:
assert user_msgs[0].content == "line one\nline two\nline three"
provider.execute_dialog_turn.assert_awaited_once()

@pytest.mark.asyncio
async def test_eof_mid_paste_submits_content_not_dismissal(self) -> None:
"""EOF (Ctrl-D) mid-paste dispatches accumulated content, not dismissal."""
handler = DialogHandler(console=MagicMock())
agent = AgentDef(name="t", prompt="p", dialog=DialogConfig(trigger_prompt="t"))
provider = MagicMock()
provider.execute_dialog_turn = AsyncMock(return_value="ack")
with (
patch.object(handler, "_ask_engagement", new_callable=AsyncMock, return_value="engage"),
patch("conductor.gates.dialog.sys.stdin.isatty", return_value=True),
# Paste, then Ctrl-D (EOF) instead of /send; then a real dismissal.
patch("builtins.input", side_effect=["ticket text", EOFError(), "done"]),
):
result = await handler.handle_dialog(
agent=agent,
agent_output={"result": "x"},
opening_question="Q?",
provider=provider,
)
user_msgs = [m for m in result.messages if m.role == "user"]
assert user_msgs[0].content == "ticket text" # not dropped as None
provider.execute_dialog_turn.assert_awaited() # the turn WAS dispatched

@pytest.mark.parametrize("isatty", [True, False])
def test_opening_banner_advertises_the_sentinel_only_on_a_tty(self, isatty: bool) -> None:
"""The banner names the sentinel exactly when a turn requires it.

Off a tty the turn falls back to the single-line ``Prompt.ask`` branch,
where the sentinel does nothing -- so advertising it there would tell
the user to type something with no effect.

Rendered for real, and asserted against the constant rather than a
literal, so the banner cannot drift from DIALOG_SUBMIT_SENTINEL.
"""
import io

from conductor.console import make_console

buf = io.StringIO()
handler = DialogHandler(console=make_console(file=buf, width=300, no_color=True))
agent = AgentDef(name="t", prompt="p", dialog=DialogConfig(trigger_prompt="t"))

with patch("conductor.gates.dialog.sys.stdin.isatty", return_value=isatty):
handler._display_dialog_start(agent, {"out": 1}, "question?")

rendered = "".join(buf.getvalue().split())
needle = "".join(DIALOG_SUBMIT_SENTINEL.split())
assert (needle in rendered) is isatty, rendered
# The markup must be parsed, not inserted verbatim as a value.
assert "[bold]" not in rendered, rendered

@pytest.mark.asyncio
async def test_ctrl_d_at_empty_prompt_dismisses(self) -> None:
"""A deliberate Ctrl-D with nothing typed ends the dialog.

Regression: the multi-line reader converts EOF into a returned string,
so an empty read must not be fed back round the loop -- otherwise the
dismissal branch is unreachable and the grill cannot be exited.
"""
handler = DialogHandler(console=MagicMock())
agent = AgentDef(name="t", prompt="p", dialog=DialogConfig(trigger_prompt="t"))
provider = MagicMock()
provider.execute_dialog_turn = AsyncMock(return_value="ack")
# A bounded list: if the loop spins, input() raises StopIteration
# rather than hanging the suite.
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=EOFError()),
):
result = await handler.handle_dialog(
agent=agent,
agent_output={"result": "x"},
opening_question="Q?",
provider=provider,
)
assert result.user_dismissed is True
assert [m for m in result.messages if m.role == "user"] == []
provider.execute_dialog_turn.assert_not_awaited()

@pytest.mark.asyncio
async def test_ctrl_c_dismisses_rather_than_propagating(self) -> None:
"""KeyboardInterrupt on the tty turn dismisses, as on the single-line path."""
handler = DialogHandler(console=MagicMock())
agent = AgentDef(name="t", prompt="p", dialog=DialogConfig(trigger_prompt="t"))
provider = MagicMock()
provider.execute_dialog_turn = AsyncMock(return_value="ack")
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=KeyboardInterrupt()),
):
result = await handler.handle_dialog(
agent=agent,
agent_output={"result": "x"},
opening_question="Q?",
provider=provider,
)
assert result.user_dismissed is True

@pytest.mark.asyncio
async def test_web_path_unaffected_by_multiline(self) -> None:
"""The web seam still passes whole messages through, never via the new reader."""
dashboard = MagicMock()
dashboard.wait_for_dialog_message = AsyncMock(
side_effect=[
{"type": "dialog_message", "agent_name": "test", "content": "a\nb\nc"},
{"type": "dialog_decline", "agent_name": "test"},
]
)
handler = DialogHandler(console=MagicMock(), web_dashboard=dashboard)
agent = AgentDef(name="test", prompt="p", dialog=DialogConfig(trigger_prompt="t"))
provider = MagicMock()
provider.execute_dialog_turn = AsyncMock(return_value="ack")

with patch(
"conductor.gates.dialog.read_multiline_lines",
side_effect=AssertionError("must not be called on the web path"),
):
result = await handler.handle_dialog(
agent=agent,
agent_output={"result": "x"},
opening_question="Q?",
provider=provider,
)

user_msgs = [m for m in result.messages if m.role == "user"]
assert user_msgs[0].content == "a\nb\nc"
assert result.user_dismissed is True
Loading