Skip to content
Merged
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
22 changes: 19 additions & 3 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading