diff --git a/cecli/args.py b/cecli/args.py index 317523944fe..6d6b4ff3619 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -598,6 +598,12 @@ def get_parser(default_config_files, git_root): default=True, help="Enable/disable streaming responses (default: True)", ) + group.add_argument( + "--spinner", + action=argparse.BooleanOptionalAction, + default=True, + help="Enable/disable the spinner while waiting for LLM responses (default: True)", + ) group.add_argument( "--user-input-color", default="#00cc00", diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 943f6b1c538..9b28f5c02f0 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -305,6 +305,23 @@ async def _exec_async(): call_result = await litellm.experimental_mcp_client.call_openai_tool( session=session, openai_tool=tool_call_dict ) + except Exception as e: + if server.is_session_expired_error(e): + try: + session = await server.reconnect() + call_result = await litellm.experimental_mcp_client.call_openai_tool( + session=session, openai_tool=tool_call_dict + ) + except Exception as retry_exc: + self.io.tool_warning( + f"Executing {tool_name} on {server.name} failed after reconnect:\n" + f"Error: {retry_exc}" + ) + return f"Error executing tool call {tool_name}: {retry_exc}" + else: + self.io.tool_warning(f"Executing {tool_name} on {server.name} failed:\nError: {e}") + return f"Error executing tool call {tool_name}: {e}" + try: content_parts = [] if call_result.content: for item in call_result.content: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 2633bc319e1..a1b2dc1dd36 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -206,6 +206,7 @@ def total_cached_tokens(self, value): yield_stream = False temperature = None auto_lint = True + _deferred_cost_text = None auto_test = False test_cmd = None lint_outcome = None @@ -575,6 +576,7 @@ def __init__( fnames, None, models=main_model.commit_message_models(), + show_spinner=nested.getter(self.args, "spinner", True), ) except FileNotFoundError: pass @@ -1269,7 +1271,8 @@ def get_repo_map(self, force_refresh=False): if not self.repo_map or not self.repo: return - self.io.update_spinner("Updating repo map") + if nested.getter(self.args, "spinner", True): + self.io.update_spinner("Updating repo map") cur_msg_text = self.get_cur_message_text() try: @@ -1371,7 +1374,8 @@ def _include_in_map(abs_path): combined_dict = repo_result.get("combined_dict", {}) new_dict = repo_result.get("new_dict", {}) - self.io.update_spinner(self.io.last_spinner_text) + if nested.getter(self.args, "spinner", True): + self.io.update_spinner(self.io.last_spinner_text) # Build the return dict for backward compatibility if combined_dict or new_dict: @@ -1497,6 +1501,10 @@ async def _run_linear(self, with_message=None, preproc=True): self.show_announcements() self.suppress_announcements_for_next_prompt = True + if self._deferred_cost_text: + self.io.tool_output(self._deferred_cost_text) + self._deferred_cost_text = None + await self.io.recreate_input() await self.io.input_task user_message = self.io.input_task.result() @@ -1645,6 +1653,10 @@ async def input_task(self, preproc): self.show_announcements() self.suppress_announcements_for_next_prompt = True + if self._deferred_cost_text: + self.io.tool_output(self._deferred_cost_text) + self._deferred_cost_text = None + # Stop spinner before showing announcements or getting input self.io.stop_spinner() self.copy_context() @@ -2034,7 +2046,8 @@ async def compact_context_if_needed(self, force=False, message=""): else: self.io.tool_output("Compacting chat history to make room for new messages...") - self.io.update_spinner("Compacting...") + if nested.getter(self.args, "spinner", True): + self.io.update_spinner("Compacting...") try: compaction_prompt = self.gpt_prompts.compaction_prompt @@ -2124,7 +2137,8 @@ async def summarize_and_update(messages, tag): await summarize_and_update(cur_messages, MessageTag.CUR) self.io.tool_output("...chat history compacted.") - self.io.update_spinner(self.io.last_spinner_text) + if nested.getter(self.args, "spinner", True): + self.io.update_spinner(self.io.last_spinner_text) manager.clear_tag(MessageTag.DIFFS) manager.clear_tag(MessageTag.FILE_CONTEXTS) @@ -2512,7 +2526,11 @@ async def format_in_executor(): if not self.tui: spinner_text += f" • ${self.format_cost(self.total_cost)} session" - self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None)) + if nested.getter(self.args, "spinner", True): + self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None)) + else: + self._deferred_cost_text = spinner_text + if self.stream: self.mdstream = True else: @@ -2634,7 +2652,11 @@ async def format_in_executor(): self.mdstream = None # Ensure any waiting spinner is stopped - self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None)) + if nested.getter(self.args, "spinner", True): + self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None)) + + self.partial_response_content = self.get_multi_response_content_in_progress(True) + self.remove_reasoning_content() self.multi_response_content = "" @@ -2980,12 +3002,22 @@ async def _execute_mcp_tools(self, server, tool_calls): continue async def do_tool_call(): + nonlocal session from litellm import experimental_mcp_client - return await experimental_mcp_client.call_openai_tool( - session=session, - openai_tool=new_tool_call, - ) + try: + return await experimental_mcp_client.call_openai_tool( + session=session, + openai_tool=new_tool_call, + ) + except Exception as e: + if server.is_session_expired_error(e): + session = await server.reconnect() + return await experimental_mcp_client.call_openai_tool( + session=session, + openai_tool=new_tool_call, + ) + raise call_result, interrupted = await coroutines.interruptible( do_tool_call(), self.interrupt_event @@ -3627,17 +3659,18 @@ async def show_send_output_stream(self, completion): for tool_call_chunk in chunk.choices[0].delta.tool_calls: self.tool_reflection = True - if tool_call_chunk.type: - self.io.update_spinner_suffix(tool_call_chunk.type) + if nested.getter(self.args, "spinner", True): + if tool_call_chunk.type: + self.io.update_spinner_suffix(tool_call_chunk.type) - if tool_call_chunk.function: - if tool_call_chunk.function.name: - self.io.update_spinner_suffix(tool_call_chunk.function.name) + if tool_call_chunk.function: + if tool_call_chunk.function.name: + self.io.update_spinner_suffix(tool_call_chunk.function.name) - if tool_call_chunk.function.arguments: - self.io.update_spinner_suffix( - tool_call_chunk.function.arguments - ) + if tool_call_chunk.function.arguments: + self.io.update_spinner_suffix( + tool_call_chunk.function.arguments + ) except (AttributeError, IndexError): # Handle cases where the response structure doesn't match expectations @@ -3649,7 +3682,8 @@ async def show_send_output_stream(self, completion): if func: for k, v in func.items(): self.tool_reflection = True - self.io.update_spinner_suffix(v) + if nested.getter(self.args, "spinner", True): + self.io.update_spinner_suffix(v) received_content = True self.token_profiler.on_token() @@ -3676,7 +3710,8 @@ async def show_send_output_stream(self, completion): text += content received_content = True self.token_profiler.on_token() - self.io.update_spinner_suffix(content) + if nested.getter(self.args, "spinner", True): + self.io.update_spinner_suffix(content) except AttributeError: pass diff --git a/cecli/interruptible_input.py b/cecli/interruptible_input.py index e93eb96fa24..52abfb86c84 100644 --- a/cecli/interruptible_input.py +++ b/cecli/interruptible_input.py @@ -17,7 +17,14 @@ def __init__(self): raise RuntimeError("InterruptibleInput is Unix-only (requires selectable stdin).") self._cancel = threading.Event() - self._sel = selectors.DefaultSelector() + + # The default selector (Kqueue on macOS, Epoll on Linux) cannot + # handle pipe-based stdin (e.g. when running inside Emacs comint-mode). + # Fall back to SelectSelector which works with any fd that supports select(). + if not sys.stdin.isatty(): + self._sel = selectors.SelectSelector() + else: + self._sel = selectors.DefaultSelector() # self-pipe to wake up select() from interrupt() self._r, self._w = os.pipe() diff --git a/cecli/io.py b/cecli/io.py index 04438faaad9..b11209eddea 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -369,6 +369,7 @@ def __init__( notifications_command=None, notification_bell=False, verbose=False, + show_spinner=True, ): self.console = Console() self.pretty = pretty @@ -507,7 +508,7 @@ def __init__( self.spinner_last_frame_index = 0 self.unicode_palette = "░█" self.fallback_spinner = None - self.fallback_spinner_enabled = True + self.fallback_spinner_enabled = show_spinner self.interruptible_input = None @@ -570,6 +571,8 @@ def start_spinner(self, text, update_last_text=True, **kwargs): self.stop_spinner() if self.prompt_session: + if not self.fallback_spinner_enabled: + return self.spinner_running = True self.spinner_text = text self.spinner_frame_index = self.spinner_last_frame_index diff --git a/cecli/main.py b/cecli/main.py index aea0c6b8690..d155295cc94 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -40,6 +40,20 @@ if sys.platform == "win32": if hasattr(asyncio, "set_event_loop_policy"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +elif sys.platform == "darwin": + # The default KqueueSelector cannot handle pipe-based stdin + # (e.g. when running inside Emacs comint-mode). Fall back to + # SelectSelector which works with any file descriptor that supports select(). + import selectors + + if not sys.stdin.isatty(): + _original_event_loop_policy = asyncio.DefaultEventLoopPolicy + + class _SelectSelectorPolicy(asyncio.DefaultEventLoopPolicy): + def new_event_loop(self): + return asyncio.SelectorEventLoop(selectors.SelectSelector()) + + asyncio.set_event_loop_policy(_SelectSelectorPolicy()) from prompt_toolkit.enums import EditingMode from .dump import dump # noqa @@ -708,6 +722,7 @@ def get_io(pretty): notifications_command=args.notifications_command, notification_bell=args.notification_bell, verbose=args.verbose, + show_spinner=args.spinner, ) validate_tui_args(args) @@ -1042,6 +1057,7 @@ def get_io(pretty): subtree_only=args.subtree_only, git_commit_verify=args.git_commit_verify, attribute_co_authored_by=args.attribute_co_authored_by, + show_spinner=args.spinner, ) except FileNotFoundError: pass diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index bb92c1473dd..6f97655cd38 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -130,6 +130,49 @@ async def disconnect(self): finally: self.session = None + async def reconnect(self): + """Disconnect and reconnect, establishing a fresh session. + + Used when the server has invalidated the current session (e.g., after + a server restart), as indicated by an HTTP 404 response per the MCP + protocol specification. + + Returns: + ClientSession: The new active session + """ + if self.io: + self.io.tool_warning( + f"MCP session expired for {self.name}, reconnecting..." + ) + await self.disconnect() + self.exit_stack = AsyncExitStack() + return await self.connect() + + @staticmethod + def is_session_expired_error(exc): + """Check if an exception indicates an expired MCP session (HTTP 404). + + Per the MCP specification, when a server terminates a session it + responds with HTTP 404 Not Found. The client MUST then start a new + session by sending a new InitializeRequest. + + Args: + exc: The exception to check + + Returns: + bool: True if the error indicates a 404 session expiry + """ + import httpx + + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 404: + return True + + # Some transports wrap the status in the exception message + exc_str = str(exc).lower() + if "404" in exc_str and ("session" in exc_str or "not found" in exc_str): + return True + + return False class HttpBasedMcpServer(McpServer): """Base class for HTTP-based MCP servers (HTTP streaming and SSE).""" diff --git a/cecli/repo.py b/cecli/repo.py index 4b42387257e..33a2091199d 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -80,9 +80,11 @@ def __init__( subtree_only=False, git_commit_verify=True, attribute_co_authored_by=False, # Added parameter + show_spinner=True, ): self.io = io self.models = models + self.show_spinner = show_spinner self.normalized_path = {} # Single-entry file cache: (commit_sha, interned_set_of_paths) @@ -486,7 +488,8 @@ async def get_commit_message(self, diffs, context, user_language=None): commit_message = None for model in self.models: spinner_text = f"Generating commit message with {model.name}\n" - self.io.start_spinner(spinner_text, update_last_text=False) + if self.show_spinner: + self.io.start_spinner(spinner_text, update_last_text=False) if model.system_prompt_prefix: current_system_content = model.system_prompt_prefix + "\n" + system_content @@ -523,7 +526,8 @@ async def get_commit_message(self, diffs, context, user_language=None): if commit_message and commit_message[0] == '"' and commit_message[-1] == '"': commit_message = commit_message[1:-1].strip() - self.io.start_spinner(self.io.last_spinner_text, update_last_text=False) + if self.show_spinner: + self.io.start_spinner(self.io.last_spinner_text, update_last_text=False) return commit_message def get_diffs(self, fnames=None): diff --git a/cecli/website/docs/config/conf.md b/cecli/website/docs/config/conf.md index efcb199eb1e..ad3c1a8578c 100644 --- a/cecli/website/docs/config/conf.md +++ b/cecli/website/docs/config/conf.md @@ -236,6 +236,9 @@ cog.outl("```") ## Enable/disable streaming responses (default: True) #stream: true +## Enable/disable the spinner while waiting for LLM responses (default: True) +#spinner: true + ## Set the color for user input (default: #00cc00) #user-input-color: "#00cc00" diff --git a/cecli/website/docs/config/options.md b/cecli/website/docs/config/options.md index f462120ede7..d196c44adcb 100644 --- a/cecli/website/docs/config/options.md +++ b/cecli/website/docs/config/options.md @@ -351,6 +351,14 @@ Aliases: - `--stream` - `--no-stream` +### `--spinner` +Enable/disable the spinner while waiting for LLM responses (default: True) +Default: True +Environment variable: `CECLI_SPINNER` +Aliases: + - `--spinner` + - `--no-spinner` + ### `--user-input-color VALUE` Set the color for user input (default: #00cc00) Default: #00cc00 diff --git a/tests/basic/test_select_selector.py b/tests/basic/test_select_selector.py new file mode 100644 index 00000000000..e708da6f592 --- /dev/null +++ b/tests/basic/test_select_selector.py @@ -0,0 +1,135 @@ +"""Tests for SelectSelector fallback used when stdin is not a TTY. + +Covers: +- cecli/interruptible_input.py: SelectSelector vs DefaultSelector choice +- cecli/main.py: _SelectSelectorPolicy on macOS when stdin is not a TTY +""" + +import asyncio +import os +import selectors +import sys +from unittest import mock + +import pytest + +from cecli.interruptible_input import InterruptibleInput + + +# --------------------------------------------------------------------------- +# InterruptibleInput selector tests +# --------------------------------------------------------------------------- + + +class TestInterruptibleInputSelector: + """InterruptibleInput should pick SelectSelector for non-TTY stdin.""" + + def test_uses_select_selector_when_not_a_tty(self): + with mock.patch.object(sys.stdin, "isatty", return_value=False): + obj = InterruptibleInput() + try: + assert isinstance(obj._sel, selectors.SelectSelector) + finally: + obj.close() + + def test_uses_default_selector_when_tty(self): + with mock.patch.object(sys.stdin, "isatty", return_value=True): + obj = InterruptibleInput() + try: + assert isinstance(obj._sel, selectors.DefaultSelector) + finally: + obj.close() + + def test_selector_registers_wakeup_pipe(self): + with mock.patch.object(sys.stdin, "isatty", return_value=False): + obj = InterruptibleInput() + try: + # The wakeup read-end fd should be registered + key = obj._sel.get_key(obj._r) + assert key.data == "__wakeup__" + assert key.events & selectors.EVENT_READ + finally: + obj.close() + + def test_close_is_safe_to_call_twice(self): + with mock.patch.object(sys.stdin, "isatty", return_value=False): + obj = InterruptibleInput() + obj.close() + # Second close should not raise + obj.close() + + def test_interrupt_sets_cancel_and_wakes_selector(self): + with mock.patch.object(sys.stdin, "isatty", return_value=False): + obj = InterruptibleInput() + try: + obj.interrupt() + assert obj._cancel.is_set() + # The wakeup pipe should have data + data = os.read(obj._r, 1024) + assert len(data) > 0 + finally: + obj.close() + + def test_input_raises_interrupted_when_cancelled_before_call(self): + with mock.patch.object(sys.stdin, "isatty", return_value=False): + obj = InterruptibleInput() + try: + obj.interrupt() + with pytest.raises(InterruptedError, match="Input interrupted"): + obj.input("") + finally: + obj.close() + + @pytest.mark.skipif(os.name == "nt", reason="Unix-only") + def test_raises_on_windows(self): + with mock.patch("os.name", "nt"): + with pytest.raises(RuntimeError, match="Unix-only"): + InterruptibleInput() + + +# --------------------------------------------------------------------------- +# macOS _SelectSelectorPolicy tests +# --------------------------------------------------------------------------- + + +class TestSelectSelectorPolicyMacOS: + """On macOS with non-TTY stdin, the event loop should use SelectSelector.""" + + @pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only policy") + def test_policy_uses_select_selector_on_macos_non_tty(self): + """When stdin is not a TTY on macOS, the patched policy should + produce a SelectorEventLoop backed by SelectSelector.""" + from cecli.main import _SelectSelectorPolicy + + policy = _SelectSelectorPolicy() + loop = policy.new_event_loop() + try: + selector = loop._selector + assert isinstance(selector, selectors.SelectSelector) + finally: + loop.close() + + def test_main_module_sets_policy_on_darwin_non_tty(self): + """Simulate importing the selector-policy block on macOS with piped stdin.""" + select_selector_cls = selectors.SelectSelector + + # Build a mini _SelectSelectorPolicy the same way main.py does + class _SelectSelectorPolicy(asyncio.DefaultEventLoopPolicy): + def new_event_loop(self): + return asyncio.SelectorEventLoop(select_selector_cls()) + + policy = _SelectSelectorPolicy() + loop = policy.new_event_loop() + try: + assert isinstance(loop._selector, selectors.SelectSelector) + finally: + loop.close() + + def test_default_policy_not_changed_when_tty(self): + """When stdin IS a TTY, the default event loop policy should remain.""" + original_policy = asyncio.get_event_loop_policy() + with mock.patch.object(sys.stdin, "isatty", return_value=True): + # The policy should be whatever the system default is, + # not our custom _SelectSelectorPolicy + current = asyncio.get_event_loop_policy() + assert current is original_policy diff --git a/tests/basic/test_spinner.py b/tests/basic/test_spinner.py new file mode 100644 index 00000000000..b89b1a21a89 --- /dev/null +++ b/tests/basic/test_spinner.py @@ -0,0 +1,114 @@ +"""Tests for the --spinner / --no-spinner CLI option.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.repo import GitRepo + + +@pytest.fixture +def mock_io(): + io = MagicMock() + io.last_spinner_text = "" + return io + + +@pytest.fixture +def mock_model(): + model = MagicMock() + model.name = "test-model" + model.system_prompt_prefix = None + model.send_completion = MagicMock(return_value=MagicMock(choices=[MagicMock(message=MagicMock(content="test commit"))])) + model.token_count = MagicMock(return_value=10) + model.info = {"max_input_tokens": 100000} + model.simple_send_with_retries = MagicMock(return_value="test commit") + + async def _async_simple_send(*args, **kwargs): + return "test commit" + model.simple_send_with_retries = _async_simple_send + return model + + +class TestSpinnerOption: + """Tests that show_spinner controls whether the spinner is started.""" + + def test_spinner_enabled_by_default(self, mock_io, mock_model): + """GitRepo defaults to show_spinner=True.""" + repo = GitRepo(mock_io, models=[mock_model], fnames=[], git_dname=".") + assert repo.show_spinner is True + + def test_spinner_disabled_when_false(self, mock_io, mock_model): + """GitRepo respects show_spinner=False.""" + repo = GitRepo(mock_io, models=[mock_model], fnames=[], git_dname=".", show_spinner=False) + assert repo.show_spinner is False + + @pytest.mark.asyncio + async def test_spinner_started_during_commit_message(self, mock_io, mock_model): + """When show_spinner=True, start_spinner is called during get_commit_message.""" + repo = GitRepo(mock_io, models=[mock_model], fnames=[], git_dname=".", show_spinner=True) + await repo.get_commit_message("some diff", "some context") + mock_io.start_spinner.assert_called() + + @pytest.mark.asyncio + async def test_spinner_not_started_when_disabled(self, mock_io, mock_model): + """When show_spinner=False, start_spinner is never called during get_commit_message.""" + repo = GitRepo(mock_io, models=[mock_model], fnames=[], git_dname=".", show_spinner=False) + await repo.get_commit_message("some diff", "some context") + mock_io.start_spinner.assert_not_called() + + +class TestSpinnerArgParsing: + """Tests that argparse correctly handles --spinner / --no-spinner.""" + + def test_spinner_default_is_true(self): + """The default value for --spinner should be True.""" + from cecli.args import get_parser + + parser = get_parser(default_config_files=[], git_root=None) + args = parser.parse_args([]) + assert args.spinner is True + + def test_spinner_flag_sets_true(self): + """Passing --spinner explicitly sets spinner to True.""" + from cecli.args import get_parser + + parser = get_parser(default_config_files=[], git_root=None) + args = parser.parse_args(["--spinner"]) + assert args.spinner is True + + def test_no_spinner_flag_sets_false(self): + """Passing --no-spinner sets spinner to False.""" + from cecli.args import get_parser + + parser = get_parser(default_config_files=[], git_root=None) + args = parser.parse_args(["--no-spinner"]) + assert args.spinner is False + + + +class TestIOSpinnerGating: + """Tests that InputOutput.start_spinner respects show_spinner=False.""" + + def test_io_show_spinner_false_disables_fallback_spinner(self): + """When show_spinner=False, fallback_spinner_enabled is False.""" + from cecli.io import InputOutput + + io = InputOutput(pretty=False, show_spinner=False) + assert io.fallback_spinner_enabled is False + + def test_io_show_spinner_true_by_default(self): + """By default, fallback_spinner_enabled is True.""" + from cecli.io import InputOutput + + io = InputOutput(pretty=False) + assert io.fallback_spinner_enabled is True + + def test_io_start_spinner_noop_when_disabled(self): + """start_spinner should not create a fallback spinner when show_spinner=False.""" + from cecli.io import InputOutput + + io = InputOutput(pretty=False, show_spinner=False) + io.start_spinner("Awaiting Confirmation...") + assert io.fallback_spinner is None + assert io.spinner_running is False diff --git a/tests/helpers/monorepo/test_repomap_workspace.py b/tests/helpers/monorepo/test_repomap_workspace.py index 0b14a760c45..5649889e535 100644 --- a/tests/helpers/monorepo/test_repomap_workspace.py +++ b/tests/helpers/monorepo/test_repomap_workspace.py @@ -17,7 +17,7 @@ def mock_workspace(tmp_path): # Project 1 p1_dir = workspace_root / "p1" / "main" p1_dir.mkdir(parents=True) - subprocess.run(["git", "init"], cwd=p1_dir, check=True) + subprocess.run(["git", "init", "-b", "main"], cwd=p1_dir, check=True) subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p1_dir, check=True) subprocess.run(["git", "config", "user.name", "Test"], cwd=p1_dir, check=True) (p1_dir / "file1.py").write_text("def func1(): pass") @@ -27,7 +27,7 @@ def mock_workspace(tmp_path): # Project 2 p2_dir = workspace_root / "p2" / "main" p2_dir.mkdir(parents=True) - subprocess.run(["git", "init"], cwd=p2_dir, check=True) + subprocess.run(["git", "init", "-b", "main"], cwd=p2_dir, check=True) subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p2_dir, check=True) subprocess.run(["git", "config", "user.name", "Test"], cwd=p2_dir, check=True) (p2_dir / "file2.py").write_text("def func2(): pass") diff --git a/tests/helpers/observations/test_observation_service.py b/tests/helpers/observations/test_observation_service.py index d1abdb63f09..3db9bb85664 100644 --- a/tests/helpers/observations/test_observation_service.py +++ b/tests/helpers/observations/test_observation_service.py @@ -67,6 +67,7 @@ async def test_compact_context_with_observations(): coder.context_compaction_summary_tokens = 100 coder.last_user_message = "Last user msg" coder.io = MagicMock() + coder.args = {} # Mock observation manager with some observations obs_manager = ObservationService.get_instance(coder) @@ -138,6 +139,7 @@ async def test_compact_context_with_observations_integration(): coder.context_compaction_summary_tokens = 100 coder.last_user_message = "Last user msg" coder.io = MagicMock() + coder.args = {} # Mock observation manager with some observations obs_manager = ObservationService.get_instance(coder) diff --git a/tests/mcp/test_keepalive_resilience.py b/tests/mcp/test_keepalive_resilience.py index 1bf90efe57a..0ea16931e0f 100644 --- a/tests/mcp/test_keepalive_resilience.py +++ b/tests/mcp/test_keepalive_resilience.py @@ -117,7 +117,7 @@ async def mock_sleep(duration): with patch("asyncio.sleep", side_effect=mock_sleep): await server.connect() # Yield control to the keepalive task multiple times - for _ in range(30): + for _ in range(200): await original_sleep(0) await server.disconnect()