feat: REPL mode, stdin support, multi-word fix for CLI (#26)

This commit is contained in:
2026-03-14 19:25:10 -04:00
parent 9134ce2f71
commit 65e5e7786f
2 changed files with 251 additions and 4 deletions

View File

@@ -141,6 +141,180 @@ def test_chat_cleans_response():
assert "The answer is 2." in result.output
# ---------------------------------------------------------------------------
# chat command — multi-word messages
# ---------------------------------------------------------------------------
def test_chat_multiword_without_quotes():
"""chat should accept multiple arguments without requiring quotes."""
mock_run_output = MagicMock()
mock_run_output.content = "Response"
mock_run_output.status = "COMPLETED"
mock_run_output.active_requirements = []
mock_timmy = MagicMock()
mock_timmy.run.return_value = mock_run_output
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
result = runner.invoke(app, ["chat", "hello", "world", "how", "are", "you"])
mock_timmy.run.assert_called_once_with(
"hello world how are you", stream=False, session_id=_CLI_SESSION_ID
)
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# chat command — stdin support
# ---------------------------------------------------------------------------
def test_chat_stdin_with_dash():
"""chat - should read message from stdin when "-" is passed."""
mock_run_output = MagicMock()
mock_run_output.content = "Stdin response"
mock_run_output.status = "COMPLETED"
mock_run_output.active_requirements = []
mock_timmy = MagicMock()
mock_timmy.run.return_value = mock_run_output
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
result = runner.invoke(app, ["chat", "-"], input="Hello from stdin")
mock_timmy.run.assert_called_once_with(
"Hello from stdin", stream=False, session_id=_CLI_SESSION_ID
)
assert result.exit_code == 0
assert "Stdin response" in result.output
def test_chat_stdin_piped():
"""chat should read from stdin when piped (non-tty)."""
mock_run_output = MagicMock()
mock_run_output.content = "Piped response"
mock_run_output.status = "COMPLETED"
mock_run_output.active_requirements = []
mock_timmy = MagicMock()
mock_timmy.run.return_value = mock_run_output
with (
patch("timmy.cli.create_timmy", return_value=mock_timmy),
patch("timmy.cli._is_interactive", return_value=False),
):
result = runner.invoke(app, ["chat", "ignored"], input="Piped message")
mock_timmy.run.assert_called_once_with(
"Piped message", stream=False, session_id=_CLI_SESSION_ID
)
assert result.exit_code == 0
def test_chat_stdin_empty_exits_with_error():
"""chat - should exit with error when stdin is empty."""
with patch("timmy.cli._is_interactive", return_value=False):
result = runner.invoke(app, ["chat", "-"], input="")
assert result.exit_code == 1
assert "No input provided" in result.output or "No input provided" in str(result.output)
# ---------------------------------------------------------------------------
# repl command
# ---------------------------------------------------------------------------
def test_repl_exits_on_ctrl_c():
"""repl should exit gracefully on Ctrl+C (KeyboardInterrupt)."""
with patch("builtins.input", side_effect=KeyboardInterrupt):
result = runner.invoke(app, ["repl"])
assert result.exit_code == 0
assert "Goodbye" in result.output
def test_repl_exits_on_ctrl_d():
"""repl should exit gracefully on Ctrl+D (EOFError)."""
with patch("builtins.input", side_effect=EOFError):
result = runner.invoke(app, ["repl"])
assert result.exit_code == 0
assert "Goodbye" in result.output
def test_repl_exits_on_quit_command():
"""repl should exit when user types 'quit'."""
with patch("builtins.input", return_value="quit"):
result = runner.invoke(app, ["repl"])
assert result.exit_code == 0
assert "Goodbye" in result.output
def test_repl_exits_on_exit_command():
"""repl should exit when user types 'exit'."""
with patch("builtins.input", return_value="exit"):
result = runner.invoke(app, ["repl"])
assert result.exit_code == 0
assert "Goodbye" in result.output
def test_repl_sends_message_to_chat():
"""repl should send user input to session.chat()."""
with (
patch("builtins.input", side_effect=["Hello Timmy", "exit"]),
patch("timmy.session.chat") as mock_chat,
):
mock_chat.return_value = "Hi there!"
result = runner.invoke(app, ["repl"])
mock_chat.assert_called()
assert result.exit_code == 0
def test_repl_skips_empty_input():
"""repl should skip empty input lines."""
with (
patch("builtins.input", side_effect=["", "", "hello", "exit"]),
patch("timmy.session.chat") as mock_chat,
):
mock_chat.return_value = "Response"
result = runner.invoke(app, ["repl"])
# chat should only be called once (for "hello"), empty lines are skipped, exit breaks
assert mock_chat.call_count == 1
def test_repl_uses_custom_session_id():
"""repl --session-id should use the provided session ID."""
with (
patch("builtins.input", side_effect=["hello", "exit"]),
patch("timmy.session.chat") as mock_chat,
):
mock_chat.return_value = "Response"
runner.invoke(app, ["repl", "--session-id", "custom-session"])
# Check that chat was called with the custom session_id
calls = mock_chat.call_args_list
assert any(call.kwargs.get("session_id") == "custom-session" for call in calls)
def test_repl_handles_chat_errors():
"""repl should handle errors from chat() gracefully."""
with (
patch("builtins.input", side_effect=["hello", "exit"]),
patch("timmy.session.chat") as mock_chat,
):
mock_chat.side_effect = Exception("Chat error")
result = runner.invoke(app, ["repl"])
assert result.exit_code == 0
assert "Error" in result.output
# ---------------------------------------------------------------------------
# Tool confirmation gate
# ---------------------------------------------------------------------------