From 9171d93ef950cf4aac15c88e896688a31f23f290 Mon Sep 17 00:00:00 2001 From: Kimi Agent Date: Sat, 14 Mar 2026 19:43:52 -0400 Subject: [PATCH] fix: CLI chat accepts multi-word messages without quotes Changed message param from str to list[str] in chat() and route() commands. Words are joined with spaces, so 'timmy chat hello how are you' works without quoting. Single-word messages still work as before. - chat(): message: list[str], joined to full_message - route(): message: list[str], joined to full_message - 7 new tests in test_cli_multiword.py Closes #26 --- src/timmy/cli.py | 11 ++- tests/timmy/test_cli_multiword.py | 130 ++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/timmy/test_cli_multiword.py diff --git a/src/timmy/cli.py b/src/timmy/cli.py index 3f55c20..e3d163e 100644 --- a/src/timmy/cli.py +++ b/src/timmy/cli.py @@ -143,7 +143,7 @@ def think( @app.command() def chat( - message: str = typer.Argument(..., help="Message to send"), + message: list[str] = typer.Argument(..., help="Message to send to Timmy"), backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, new_session: bool = typer.Option( @@ -173,6 +173,8 @@ def chat( calls are checked against config/allowlist.yaml — allowlisted operations execute automatically, everything else is safely rejected. """ + full_message = " ".join(message) + import uuid if session_id is not None: @@ -184,7 +186,7 @@ def chat( timmy = create_timmy(backend=backend, model_size=model_size) # Use agent.run() so we can intercept paused runs for tool confirmation. - run_output = timmy.run(message, stream=False, session_id=session_id) + run_output = timmy.run(full_message, stream=False, session_id=session_id) # Handle paused runs — dangerous tools need user approval run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous) @@ -328,12 +330,13 @@ def voice( @app.command() def route( - message: str = typer.Argument(..., help="Message to route"), + message: list[str] = typer.Argument(..., help="Message to route"), ): """Show which agent would handle a message (debug routing).""" + full_message = " ".join(message) from timmy.agents.loader import route_request_with_match - agent_id, matched_pattern = route_request_with_match(message) + agent_id, matched_pattern = route_request_with_match(full_message) if agent_id: typer.echo(f"→ {agent_id} (matched: {matched_pattern})") else: diff --git a/tests/timmy/test_cli_multiword.py b/tests/timmy/test_cli_multiword.py new file mode 100644 index 0000000..28fc1c5 --- /dev/null +++ b/tests/timmy/test_cli_multiword.py @@ -0,0 +1,130 @@ +"""Tests for CLI multi-word message handling (issue #26). + +This module tests that the chat and route commands properly handle +multi-word messages without requiring quotes. +""" + +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from timmy.cli import _CLI_SESSION_ID, app + +runner = CliRunner() + + +# ----------------------------------------------------------------------------- +# chat command — multi-word message handling +# ----------------------------------------------------------------------------- + + +def test_chat_joins_multiple_args(): + """chat command must join multiple arguments into a single message.""" + mock_run_output = MagicMock() + mock_run_output.content = "Hello! I am doing well." + 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", "how", "are", "you"]) + + # Verify the message was joined with spaces + mock_timmy.run.assert_called_once_with( + "hello how are you", stream=False, session_id=_CLI_SESSION_ID + ) + assert result.exit_code == 0 + assert "Hello! I am doing well." in result.output + + +def test_chat_single_word_still_works(): + """chat command must still work with a single word argument.""" + mock_run_output = MagicMock() + mock_run_output.content = "Hello!" + 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"]) + + mock_timmy.run.assert_called_once_with("hello", stream=False, session_id=_CLI_SESSION_ID) + assert result.exit_code == 0 + assert "Hello!" in result.output + + +def test_chat_joins_complex_message(): + """chat command must handle complex multi-word messages with punctuation.""" + mock_run_output = MagicMock() + mock_run_output.content = "The file is located at /path/to/file." + 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", "what", "is", "the", "path", "to", "the", "config", "file?"], + ) + + mock_timmy.run.assert_called_once_with( + "what is the path to the config file?", stream=False, session_id=_CLI_SESSION_ID + ) + assert result.exit_code == 0 + + +# ----------------------------------------------------------------------------- +# route command — multi-word message handling +# ----------------------------------------------------------------------------- + + +def test_route_joins_multiple_args(): + """route command must join multiple arguments into a single message.""" + with patch("timmy.agents.loader.route_request_with_match") as mock_route: + mock_route.return_value = ("orchestrator", None) + result = runner.invoke(app, ["route", "hello", "how", "are", "you"]) + + # Verify the message was joined with spaces + mock_route.assert_called_once_with("hello how are you") + assert result.exit_code == 0 + assert "orchestrator" in result.output + + +def test_route_single_word_still_works(): + """route command must still work with a single word argument.""" + with patch("timmy.agents.loader.route_request_with_match") as mock_route: + mock_route.return_value = ("spark", "test pattern") + result = runner.invoke(app, ["route", "test"]) + + mock_route.assert_called_once_with("test") + assert result.exit_code == 0 + assert "spark" in result.output + + +def test_route_shows_match_info(): + """route command must display matched pattern when there is a match.""" + with patch("timmy.agents.loader.route_request_with_match") as mock_route: + mock_route.return_value = ("agent_1", "pattern:hello") + result = runner.invoke(app, ["route", "hello", "world"]) + + mock_route.assert_called_once_with("hello world") + assert result.exit_code == 0 + assert "agent_1" in result.output + assert "pattern:hello" in result.output + + +def test_route_no_match(): + """route command must show orchestrator when no pattern matches.""" + with patch("timmy.agents.loader.route_request_with_match") as mock_route: + mock_route.return_value = (None, None) + result = runner.invoke(app, ["route", "unknown", "command", "here"]) + + mock_route.assert_called_once_with("unknown command here") + assert result.exit_code == 0 + assert "orchestrator (no pattern match)" in result.output