diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index c052056d15..281993382b 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -7588,6 +7588,16 @@ async def _prompt_launch_dependencies_then_model( self.push_screen(dependency_screen) return await result_future + @staticmethod + def _is_exit_keyword(value: str, mode: InputMode) -> bool: + """Return whether `value` is the bare `exit` keyword in normal mode. + + Matches case-insensitively and ignores surrounding whitespace. Only + `normal` mode qualifies, so `exit` typed in shell or command mode is + routed normally rather than quitting the app. + """ + return mode == "normal" and value.lower().strip() == "exit" + def _can_bypass_queue(self, value: str) -> bool: """Check if a slash command can skip the message queue. @@ -7663,10 +7673,9 @@ async def _submit_input( # COMMANDS and so carry no bypass tier. Both must run even when the # app is busy or wedged, so neither sits behind the queue. always_bypass = ALWAYS_IMMEDIATE | HIDDEN_COMMANDS + normalized = value.lower().strip() - if force_bypass or ( - mode == "command" and value.lower().strip() in always_bypass - ): + if force_bypass or (mode == "command" and normalized in always_bypass): await self._process_message(value, mode) return @@ -7717,6 +7726,13 @@ async def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None: await dispatch_hook("user.prompt", {}) + # A bare `exit` quits the app (REPL convention), mirroring `/quit`. + # Gated to this interactive path only, so external/scripted callers + # (on_external_input) can still send the literal "exit" to the agent. + if self._is_exit_keyword(value, mode): + self.exit() + return + await self._submit_input(value, mode) async def _dismiss_startup_tip(self) -> None: diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index ad3bf22e8e..a9260ba74e 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -11884,6 +11884,76 @@ async def test_q_alias_bypasses_queue(self) -> None: exit_mock.assert_called_once() assert len(app._pending_messages) == 0 + async def test_exit_keyword_exits_from_normal_mode(self) -> None: + """Plain exit quits from normal mode, case-insensitive and whitespace-stripped. + + The ` EXIT ` literal is the only coverage of the `.lower().strip()` + normalization; keep the padding and casing when editing this test. + """ + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + + with patch.object(app, "exit") as exit_mock: + app.post_message(ChatInput.Submitted(" EXIT ", "normal")) + await pilot.pause() + + exit_mock.assert_called_once() + assert len(app._pending_messages) == 0 + + async def test_exit_keyword_bypasses_queue_when_agent_running(self) -> None: + """Plain exit should quit immediately even when the agent is running.""" + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + app._agent_running = True + + with patch.object(app, "exit") as exit_mock: + app.post_message(ChatInput.Submitted("exit", "normal")) + await pilot.pause() + + exit_mock.assert_called_once() + assert len(app._pending_messages) == 0 + + async def test_exit_keyword_bypasses_thread_switching(self) -> None: + """Plain exit should quit even during a thread switch.""" + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + app._thread_switching = True + + with patch.object(app, "exit") as exit_mock: + app.post_message(ChatInput.Submitted("exit", "normal")) + await pilot.pause() + + exit_mock.assert_called_once() + assert len(app._pending_messages) == 0 + + async def test_exit_keyword_requires_exact_match(self) -> None: + """Other messages containing exit should still go to the agent.""" + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + + with ( + patch.object(app, "exit") as exit_mock, + patch.object( + app, "_handle_user_message", new_callable=AsyncMock + ) as handler, + ): + app.post_message(ChatInput.Submitted("exit now", "normal")) + await pilot.pause() + + exit_mock.assert_not_called() + handler.assert_awaited_once_with("exit now") + + def test_exit_keyword_only_matches_normal_mode(self) -> None: + """`exit` quits only in normal mode; shell/command input is untouched.""" + assert DeepAgentsApp._is_exit_keyword("exit", "normal") is True + assert DeepAgentsApp._is_exit_keyword("exit", "shell") is False + assert DeepAgentsApp._is_exit_keyword("exit", "shell_incognito") is False + assert DeepAgentsApp._is_exit_keyword("exit", "command") is False + async def test_force_clear_bypasses_queue_when_agent_running(self) -> None: """/force-clear should process immediately when agent is running.""" app = DeepAgentsApp() @@ -11957,6 +12027,28 @@ async def test_external_prompt_queues_when_agent_running(self) -> None: QueuedMessage(text="next task", mode="normal") ] + async def test_external_prompt_exit_is_forwarded(self) -> None: + """An external `exit` prompt should be sent to the agent, not quit.""" + app = DeepAgentsApp() + async with app.run_test() as pilot: + await pilot.pause() + + with ( + patch.object(app, "exit") as exit_mock, + patch.object( + app, "_handle_user_message", new_callable=AsyncMock + ) as handler, + ): + app.post_message( + ExternalInput( + ExternalEvent(kind="prompt", payload="exit", source="test") + ) + ) + await pilot.pause() + + exit_mock.assert_not_called() + handler.assert_awaited_once_with("exit") + async def test_version_executes_during_connecting(self) -> None: """/version should process immediately when only connecting.""" app = DeepAgentsApp()