Skip to content

Commit 840fd9d

Browse files
committed
version bump, interrupt exception handling/suppress
1 parent d05df10 commit 840fd9d

File tree

10 files changed

+150
-12
lines changed

10 files changed

+150
-12
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fast-agent-mcp"
3-
version = "0.6.6"
3+
version = "0.6.7"
44
description = "Define, Prompt and Test MCP enabled Agents and Workflows"
55
readme = "README.md"
66
license = { file = "LICENSE" }

src/fast_agent/cli/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ def main():
125125
# Auto-route to go command
126126
sys.argv.insert(insert_pos, "go")
127127

128-
app()
128+
try:
129+
app()
130+
except KeyboardInterrupt:
131+
raise SystemExit(130) from None
129132

130133

131134
if __name__ == "__main__":

src/fast_agent/cli/runtime/runner.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import sys
7+
from contextlib import suppress
78
from typing import TYPE_CHECKING
89

910
from fast_agent.cli.asyncio_utils import set_asyncio_exception_handler
@@ -63,21 +64,27 @@ def run_request(request: AgentRunRequest) -> None:
6364
write_interactive_trace("cli.runner.system_exit", code=exc.code)
6465
exit_code = exc.code if isinstance(exc.code, int) else None
6566
finally:
66-
try:
67+
with suppress(BaseException):
6768
if not main_task.done():
6869
loop.run_until_complete(asyncio.gather(main_task, return_exceptions=True))
6970

71+
tasks = set()
72+
with suppress(BaseException):
7073
tasks = {task for task in asyncio.all_tasks(loop) if task is not main_task}
71-
write_interactive_trace("cli.runner.finally", task_count=len(tasks))
72-
for task in tasks:
74+
write_interactive_trace("cli.runner.finally", task_count=len(tasks))
75+
76+
for task in tasks:
77+
with suppress(BaseException):
7378
task.cancel()
7479

75-
if sys.version_info >= (3, 7):
80+
if sys.version_info >= (3, 7) and tasks:
81+
with suppress(BaseException):
7682
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
83+
84+
with suppress(BaseException):
7785
loop.run_until_complete(loop.shutdown_asyncgens())
86+
with suppress(BaseException):
7887
loop.close()
79-
except Exception:
80-
pass
8188

8289
if exit_code not in (None, 0):
8390
raise SystemExit(exit_code)

src/fast_agent/ui/prompt/input_runtime.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from rich import print as rich_print
1515

1616
from fast_agent.ui.command_payloads import CommandPayload, InterruptCommand
17+
from fast_agent.ui.prompt.keybindings import PromptInputInterrupt
1718
from fast_agent.ui.prompt_marks import emit_prompt_mark
1819
from fast_agent.utils.async_utils import suppress_known_runtime_warnings
1920

@@ -156,7 +157,7 @@ def _track_accept(buffer_obj) -> bool:
156157
rich_print(f"[dim]{prompt_prefix} {stripped.splitlines()[0]}[/dim]")
157158

158159
return parse_special_input(result)
159-
except KeyboardInterrupt:
160+
except (KeyboardInterrupt, PromptInputInterrupt):
160161
if prompt_mark_started:
161162
emit_prompt_mark("B")
162163
return InterruptCommand()

src/fast_agent/ui/prompt/keybindings.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class AgentKeyBindings(KeyBindings):
4242
current_agent_name: str | None = None
4343

4444

45+
class PromptInputInterrupt(Exception):
46+
"""Internal prompt-toolkit interrupt used instead of raw KeyboardInterrupt."""
47+
48+
4549
def _cycle_completion(buffer: Buffer, *, backwards: bool) -> bool:
4650
"""Cycle through current completion menu items.
4751
@@ -243,7 +247,7 @@ def _(event) -> None:
243247
@kb.add("c-c")
244248
def _(event) -> None:
245249
"""Ctrl+C: interrupt prompt input (handled by caller policy)."""
246-
event.app.exit(exception=KeyboardInterrupt())
250+
event.app.exit(exception=PromptInputInterrupt())
247251

248252
@kb.add("c-d")
249253
def _(event) -> None:

tests/unit/fast_agent/commands/test_cli_main_routing.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77
from click.utils import strip_ansi
88

9+
from fast_agent.cli import __main__ as cli_main
910
from fast_agent.cli.__main__ import _first_positional_argument
1011

1112

@@ -78,3 +79,12 @@ def test_demo_subcommand_still_detected_after_env_option_value() -> None:
7879
assert result.returncode == 0, result.stderr
7980
assert "demo [OPTIONS] COMMAND" in output
8081
assert "Demo commands for UI features." in output
82+
83+
84+
def test_main_converts_keyboard_interrupt_to_clean_exit(monkeypatch: pytest.MonkeyPatch) -> None:
85+
monkeypatch.setattr(cli_main, "app", lambda: (_ for _ in ()).throw(KeyboardInterrupt()))
86+
87+
with pytest.raises(SystemExit) as exc_info:
88+
cli_main.main()
89+
90+
assert exc_info.value.code == 130

tests/unit/fast_agent/commands/test_runtime_result_export.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mcp.types import CallToolRequestParams, TextContent
1313

1414
from fast_agent.agents.agent_types import AgentConfig
15+
from fast_agent.cli.runtime import runner
1516
from fast_agent.cli.runtime.agent_setup import (
1617
_apply_shell_cwd_policy_preflight,
1718
_build_fan_out_result_paths,
@@ -130,6 +131,69 @@ def test_should_convert_keyboard_interrupt_to_task_cancel_only_for_interactive_r
130131
assert _should_convert_keyboard_interrupt_to_task_cancel(one_shot_request) is False
131132

132133

134+
def test_run_request_closes_loop_when_cleanup_is_interrupted(
135+
monkeypatch: pytest.MonkeyPatch,
136+
) -> None:
137+
class _FakeTask:
138+
def __init__(self) -> None:
139+
self.cancelled = False
140+
141+
def done(self) -> bool:
142+
return False
143+
144+
def cancel(self) -> None:
145+
self.cancelled = True
146+
147+
class _FakeLoop:
148+
def __init__(self) -> None:
149+
self.task = _FakeTask()
150+
self.main_gather = object()
151+
self.shutdown_marker = object()
152+
self.closed = False
153+
154+
def is_running(self) -> bool:
155+
return False
156+
157+
def create_task(self, coro: Any) -> _FakeTask:
158+
coro.close()
159+
return self.task
160+
161+
def run_until_complete(self, awaitable: object) -> None:
162+
if awaitable is self.task:
163+
raise KeyboardInterrupt()
164+
if awaitable is self.main_gather:
165+
raise KeyboardInterrupt()
166+
167+
def shutdown_asyncgens(self) -> object:
168+
return self.shutdown_marker
169+
170+
def close(self) -> None:
171+
self.closed = True
172+
173+
fake_loop = _FakeLoop()
174+
175+
async def _fake_run_agent_request(_request: AgentRunRequest) -> None:
176+
return None
177+
178+
def _fake_gather(*aws: object, return_exceptions: bool = False) -> object:
179+
del return_exceptions
180+
if aws == (fake_loop.task,):
181+
return fake_loop.main_gather
182+
return object()
183+
184+
monkeypatch.setattr(runner, "configure_uvloop", lambda: (False, False))
185+
monkeypatch.setattr(runner, "ensure_event_loop", lambda: fake_loop)
186+
monkeypatch.setattr(runner, "set_asyncio_exception_handler", lambda _loop: None)
187+
monkeypatch.setattr(runner, "run_agent_request", _fake_run_agent_request)
188+
monkeypatch.setattr(asyncio, "all_tasks", lambda _loop: set())
189+
monkeypatch.setattr(asyncio, "gather", _fake_gather)
190+
191+
with pytest.raises(KeyboardInterrupt):
192+
runner.run_request(_make_request(result_file=None, message="hello"))
193+
194+
assert fake_loop.closed is True
195+
196+
133197
def test_sanitize_result_suffix() -> None:
134198
assert _sanitize_result_suffix("openai/gpt-4o") == "openai_gpt-4o"
135199
assert _sanitize_result_suffix(" model name ") == "model_name"

tests/unit/fast_agent/ui/test_prompt_input_runtime.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from __future__ import annotations
22

3-
from typing import TextIO, cast
3+
from typing import TYPE_CHECKING, TextIO, cast
4+
5+
import pytest
46

57
from fast_agent.ui.prompt import input_runtime
8+
from fast_agent.ui.prompt.keybindings import PromptInputInterrupt
9+
10+
if TYPE_CHECKING:
11+
from prompt_toolkit import PromptSession
612

713

814
class _FakeStream:
@@ -57,3 +63,28 @@ def test_format_prompt_prefix_omits_default_agent_name() -> None:
5763
assert input_runtime._format_prompt_prefix("dev", default_agent_name="dev") == "❯"
5864
assert input_runtime._format_prompt_prefix("default") == "default ❯"
5965
assert input_runtime._format_prompt_prefix("dev") == "dev ❯"
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_run_prompt_once_converts_prompt_input_interrupt_to_interrupt_command() -> None:
70+
class _Buffer:
71+
def __init__(self) -> None:
72+
self.accept_handler = None
73+
74+
class _Session:
75+
def __init__(self) -> None:
76+
self.default_buffer = _Buffer()
77+
78+
async def prompt_async(self, *_args, **_kwargs):
79+
raise PromptInputInterrupt()
80+
81+
result = await input_runtime.run_prompt_once(
82+
session=cast("PromptSession", _Session()),
83+
agent_name="agent",
84+
default_agent_name="agent",
85+
default_buffer="",
86+
resolve_prompt_text=lambda: "❯ ",
87+
parse_special_input=lambda value: value,
88+
)
89+
90+
assert type(result).__name__ == "InterruptCommand"

tests/unit/fast_agent/ui/test_prompt_keybindings.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from prompt_toolkit.keys import Keys
99

1010
from fast_agent.ui.prompt.keybindings import (
11+
PromptInputInterrupt,
1112
_accept_completion,
1213
_cycle_completion,
1314
create_keybindings,
@@ -137,3 +138,20 @@ def invalidate(self) -> None:
137138
binding = _binding_for(kb, key)
138139
binding.handler(SimpleNamespace(current_buffer=Buffer(), app=_App()))
139140
assert label in events
141+
142+
143+
def test_ctrl_c_binding_exits_with_prompt_input_interrupt() -> None:
144+
class _App:
145+
def __init__(self) -> None:
146+
self.exception: BaseException | None = None
147+
148+
def exit(self, *, exception: BaseException | None = None) -> None:
149+
self.exception = exception
150+
151+
app = _App()
152+
kb = create_keybindings()
153+
binding = _binding_for(kb, Keys.ControlC)
154+
155+
binding.handler(SimpleNamespace(current_buffer=Buffer(), app=app))
156+
157+
assert isinstance(app.exception, PromptInputInterrupt)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)