diff --git a/cecli/args.py b/cecli/args.py index 96fb0619ed1..7bd7cf253c4 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -31,6 +31,7 @@ "file", "read", "mcp_servers_files", + "acp_models", "set_env", "api_key", "alias", @@ -440,13 +441,9 @@ def get_parser(default_config_files, git_root): ) group.add_argument( "--scheduler", - action=argparse.BooleanOptionalAction, + metavar="SCHEDULER_CONFIG_JSON", + help="Specify scheduler configuration as a JSON string", default=None, - help=( - "Force the workflow scheduler on (--scheduler) or off (--no-scheduler) for" - " this session. (default: auto, on when any enabled scheduled workflow" - " exists)" - ), ) group.add_argument( "--system-one", @@ -1317,6 +1314,24 @@ def get_parser(default_config_files, git_root): help="Specify server configuration (WebSocket and headless) as a JSON/YAML string", default=None, ) + group.add_argument( + "--acp", + action="store_true", + default=False, + help=( + "Run as an Agent Client Protocol (ACP) agent over stdio, for editors such as Zed" + " (stdout carries the protocol)" + ), + ) + group.add_argument( + "--acp-models", + action="append", + metavar="PATTERN", + help=( + "Limit the ACP model picker's provider lists to models matching this pattern," + " matched like /models (can be used multiple times)" + ), + ) ########## group = parser.add_argument_group("Deprecated model settings") diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 45f067f1edd..47f2e13d5e9 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -842,6 +842,7 @@ async def _execute_local_tools(self, tool_calls_list): for tool_call in tool_calls_list: tool_name = tool_call.function.name result_message = "" + self.io.tool_call_event(tool_call.id, "in_progress") try: if responses.unprefix_tool_name(tool_name)[1].lower() in self.write_tools: used_write_tool = True diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 43c637224d4..0bdaf106905 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -291,8 +291,8 @@ def _reset_token_usage(self): commit_language = None file_watcher = None mcp_manager = None - run_one_completed = True compact_context_completed = True + _drain_queue_after_command = False suppress_announcements_for_next_prompt = False tool_reflection = False last_user_message = "" @@ -684,6 +684,8 @@ def __init__( self._queue_counter = 0 self._queue_lock = threading.Lock() self._processing_queue = False + self._active_queue_prompt = None + self._drain_queue_after_command = False self.data_cache = { "repo": {"last_key": "", "read_only_count": None}, @@ -1627,6 +1629,10 @@ async def run_stream(self, user_message): async for chunk in self.send_message(user_message): yield chunk + # Stream-driven interfaces bypass run_one(), so drain the queue here + # to keep queued prompts processing after the request completes. + await self.process_prompt_queue() + def init_before_message(self): self.coder_edited_files = set() self.reflected_message = None @@ -1946,14 +1952,12 @@ async def generate(self, user_message, preproc): await self.compact_context_if_needed() self.compact_context_completed = True - self.run_one_completed = False await self.run_one(user_message, preproc) self.show_undo_hint() except asyncio.CancelledError: # Don't show undo hint if cancelled raise finally: - self.run_one_completed = True self.compact_context_completed = True self.io.stop_spinner() # Trim memory in the background so it doesn't stall the event loop @@ -2013,9 +2017,6 @@ async def preproc_user_input(self, inp): try: return await self.commands.run(inp, coder=self, **run_kwargs) finally: - # Dispatch can return early (unknown/ambiguous command) or - # raise without ever running a command; the gate must still be - # reopened or the input/output loops park forever. self.commands.cmd_running_event.set() await self.check_for_file_mentions(inp) @@ -2026,6 +2027,67 @@ async def preproc_user_input(self, inp): def wrap_user_input(self, inp): return inp + async def process_prompt_queue(self, preproc=True): + """Drain the coder's prompt queue in FIFO order. + + Re-entrant calls return immediately. Each item is activated before it + runs so an interrupt can put it back at the front of the queue, and + failures are isolated so one prompt does not abort the rest. + """ + if self._processing_queue: + return + + def warn_paused(): + remaining = command_queue.get_queue_length(self) + self.io.tool_warning( + f"Queue processing interrupted. {remaining} prompt(s) remain" + " queued; use /run-queue to resume." + ) + + from cecli.helpers.agents.service import AgentService + + self._processing_queue = True + try: + while True: + item = command_queue.dequeue_for_processing(self) + if item is None: + break + + self.io.tool_output(f"Processing queued prompt (id: {item['id']})...") + try: + await self.generate(item["text"], preproc) + interrupt_event = getattr(self, "interrupt_event", None) + if interrupt_event is not None and interrupt_event.is_set() is True: + command_queue.requeue_active_prompt(self) + warn_paused() + break + except (asyncio.CancelledError, KeyboardInterrupt): + command_queue.requeue_active_prompt(self) + warn_paused() + raise + except SwitchCoderSignal as switch: + if not getattr(self, "parent_uuid", ""): + raise + + service = AgentService.get_instance(self) + info = service.sub_agents.get(self.uuid) + if info is None: + raise + + await service.switch_sub_agent_coder(info, switch, preproc) + return + except SystemExit: + raise + except Exception as e: + command_queue.clear_active_prompt(self) + self.io.tool_error(f"Error processing queued prompt (id: {item['id']}): {e}") + continue + + command_queue.clear_active_prompt(self) + finally: + self._drain_queue_after_command = False + command_queue.finish_processing(self) + async def run_one(self, user_message, preproc): self.init_before_message() @@ -2041,6 +2103,9 @@ async def run_one(self, user_message, preproc): if self.commands.is_command(user_message) and not self.commands.is_test_command( user_message ): + if self._processing_queue or self._drain_queue_after_command: + self._drain_queue_after_command = False + await self.process_prompt_queue(preproc) return if not self.commands.is_command(user_message): @@ -2112,19 +2177,10 @@ async def run_one(self, user_message, preproc): await self.auto_save_session(force=True) - # Move to the next queued prompt (CLI-33) only after the current message - # has fully completed, so the queue drains within run_one() instead of + # Drain any queued prompts (CLI-33) only after the current message has + # fully completed, so the queue is processed within run_one() instead of # being watched by the generation loops. - if self.prompt_queue and not self._processing_queue: - self._processing_queue = True - try: - item = command_queue.dequeue_prompt(self) - finally: - self._processing_queue = False - - if item is not None: - self.io.tool_output(f"Processing queued prompt (id: {item['id']})...") - await self.run_one(item["text"], preproc) + await self.process_prompt_queue(preproc) if not await HookIntegration.call_end_hooks(self): self.io.tool_warning("Execution stopped by end hook") @@ -2253,6 +2309,31 @@ async def compact_context_if_needed(self, force=False, message=""): ) return + # Compaction can only shrink the conversation buckets counted above. + # If the fixed context (files added to the chat, repo map, system + # prompt, rules, tool schemas) already meets the limit on its own, + # summarizing cannot bring the context under the limit. Bail out with + # guidance instead of churning every turn, which is what a large file + # added to the chat otherwise causes. + non_compactable_tokens = all_tokens - compactable_tokens + + if ( + self.context_compaction_max_tokens + and non_compactable_tokens >= self.context_compaction_max_tokens + ): + if getattr(self, "_compaction_noop_warning_time", 0) + 300 < time.time(): + self._compaction_noop_warning_time = time.time() + self.io.tool_warning( + "Compaction cannot reduce the context: the fixed context (files in the" + f" chat, repo map, prompts) is already ~{non_compactable_tokens:,}" + f" tokens, at or above the ~{self.context_compaction_max_tokens:,} token" + " compaction limit." + ) + self.io.tool_output("- Use /drop to remove unneeded files from the chat") + self.io.tool_output("- Use /clear to clear the chat history") + + return + message_tokens = done_tokens + cur_tokens file_tokens = diff_tokens + file_context_tokens combined_tokens = done_tokens + cur_tokens + diff_tokens + file_context_tokens @@ -2692,7 +2773,7 @@ def warm_cache_worker(): async def check_tokens(self, messages): """Check if the messages will fit within the model's token limits.""" input_tokens = self.get_active_model().token_count(messages) - max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0 + max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 128000 if max_input_tokens and input_tokens >= max_input_tokens: if ( @@ -2703,7 +2784,7 @@ async def check_tokens(self, messages): f"Estimated chat context of {input_tokens:,} tokens exceeds the" f" {max_input_tokens:,} token limit. Attempting to compact..." ) - await self.compact_context_if_needed(force=True) + await self.compact_context_if_needed() # After compaction, re-format messages and re-check tokens messages = self.format_messages() @@ -3259,6 +3340,7 @@ async def _execute_mcp_tools(self, server, tool_calls): continue tool_id_set.add(tool_call.id) + self.io.tool_call_event(tool_call.id, "in_progress") try: # Arguments can be a stream of JSON objects. @@ -3465,6 +3547,11 @@ async def process_tool_calls(self, tool_call_response): for server, server_responses in tool_responses_by_server.items(): for tool_response in server_responses: tool_responses.append(tool_response) + self.io.tool_call_event( + tool_response["tool_call_id"], + "completed", + result=tool_response.get("content"), + ) ConversationService.get_manager(self).add_message( message_dict=tool_response, @@ -3482,6 +3569,12 @@ def _print_tool_call_info(self, server_tool_calls): for server, tool_calls in server_tool_calls.items(): for tool_call in tool_calls: + self.io.tool_call_event( + tool_call.id, + "pending", + name=tool_call.function.name, + arguments=tool_call.function.arguments, + ) try: if ToolRegistry.get_tool(tool_call.function.name.lower()): ToolRegistry.get_tool(tool_call.function.name.lower()).format_output( @@ -3996,7 +4089,7 @@ async def show_send_output(self, completion): if ( not len(self.partial_response_content) and not len(self.partial_response_tool_calls) - and not len(self.partial_response_reasoning_content) + and not _is_meaningful_reasoning(self.partial_response_reasoning_content) ): self.empty_response = True return @@ -4126,7 +4219,8 @@ async def show_send_output_stream(self, completion): text += reasoning_content self.got_reasoning_content = True - received_content = True + if _is_meaningful_reasoning(reasoning_content): + received_content = True self.token_profiler.on_token() self.io.update_spinner_suffix(reasoning_content) @@ -4202,7 +4296,14 @@ async def show_send_output_stream(self, completion): self.io.tool_warning("Execution stopped by on message hook") return - if not received_content and len(self.partial_response_tool_calls) == 0: + # Treat the response as empty when nothing was received, or when the + # only thing received was reasoning made entirely of non-alphanumeric + # characters (e.g. moonshotai/kimi-k3 returning "!!!!"). + if ( + not received_content + and len(self.partial_response_tool_calls) == 0 + and not _is_meaningful_reasoning(self.partial_response_reasoning_content) + ): self.empty_response = True return @@ -4795,7 +4896,7 @@ def show_usage_report(self): if self.tui and self.tui(): self.tui().update_cost(self.usage_report.replace("\n", " ")) else: - self.io.tool_output(self.usage_report) + self.io.tool_output(self.usage_report, type="usage-report") self.io.rule() self.message_cost = 0.0 @@ -5391,3 +5492,15 @@ def _first_usage_tokens(usage: object, paths: list[str], default: int = 0) -> in if value is not None: return value return default + + +def _is_meaningful_reasoning(text): + """Return True if reasoning text contains at least one alphanumeric character. + + Some providers (e.g. moonshotai/kimi-k3) occasionally return completions + with empty ``content`` and a ``reasoning_content`` made entirely of + punctuation (e.g. ``"!!!!"``). Those responses are effectively empty, so + the empty-response detector only lets reasoning count as response + content when it holds at least one alphanumeric character. + """ + return bool(text) and any(ch.isalnum() for ch in text) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 513676f5dcd..d9062eb4b24 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -73,6 +73,7 @@ from .reset import ResetCommand from .rules import RulesCommand from .run import RunCommand +from .run_queue import RunQueueCommand from .save import SaveCommand from .save_session import SaveSessionCommand from .search_memory import SearchMemoryCommand @@ -169,6 +170,7 @@ CommandRegistry.register(ResetCommand) CommandRegistry.register(RulesCommand) CommandRegistry.register(RunCommand) +CommandRegistry.register(RunQueueCommand) CommandRegistry.register(SaveCommand) CommandRegistry.register(SaveSessionCommand) CommandRegistry.register(SearchMemoryCommand) @@ -265,6 +267,7 @@ "ResetCommand", "RulesCommand", "RunCommand", + "RunQueueCommand", "SaveCommand", "SaveSessionCommand", "SearchMemoryCommand", diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 312a1802625..bda40a64df3 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -14,6 +14,9 @@ class Commands: scraper = None + # Commands that receive their full argument text for nested completion. + FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue") + def _get_coder(self): """Return coder via weak reference, or None if collected.""" if self._coder_ref is not None: @@ -90,7 +93,7 @@ def __init__( self.last_command_show_notification = True # Commands that should NOT trigger auto-processing of the queue - self._MANAGEMENT_COMMANDS = {"queue", "list-queue", "remove-queue"} + self._MANAGEMENT_COMMANDS = {"queue", "list-queue", "remove-queue", "run-queue"} def _load_custom_commands(self, custom_commands): """ diff --git a/cecli/commands/insert_queue.py b/cecli/commands/insert_queue.py index f4cc1922c9f..50ec8246441 100644 --- a/cecli/commands/insert_queue.py +++ b/cecli/commands/insert_queue.py @@ -60,6 +60,7 @@ async def execute(cls, io, coder, args, **kwargs): try: item = command_queue.insert_prompt(coder, prompt_text, index) io.tool_output(f"Prompt inserted at position {index + 1} (id: {item['id']})") + return f"Successfully executed {cls.NORM_NAME}." except ValueError as e: return format_command_result(io, cls.NORM_NAME, "", error=str(e)) @@ -68,8 +69,20 @@ async def execute(cls, io, coder, args, **kwargs): @classmethod def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for insert-queue command.""" - return [] + """Get completion options for insert-queue command. + + Args are " "; once the index token and a space + are present, mirror the nested prompt's completions (e.g. a nested + slash command delegates to the original command's completions). + """ + text = args or "" + first_space = text.find(" ") + if first_space == -1: + # Still typing the index; suggest valid positions. + if not coder.commands: + return [] + return [str(i) for i in range(1, command_queue.get_queue_length(coder) + 2)] + return command_queue.nested_command_completions(coder, text[first_space + 1 :]) @classmethod def get_help(cls) -> str: @@ -83,5 +96,5 @@ def get_help(cls) -> str: help_text += "\nExamples:\n" help_text += " /insert-queue 1 Review the changes in src/main.py\n" help_text += " /insert-queue 3 Write unit tests for the new feature\n" - help_text += "\nSee also: /queue, /list-queue, /remove-queue\n" + help_text += "\nSee also: /queue, /list-queue, /remove-queue, /run-queue\n" return help_text diff --git a/cecli/commands/list_queue.py b/cecli/commands/list_queue.py index f699efbf939..fb8d8b7f0ad 100644 --- a/cecli/commands/list_queue.py +++ b/cecli/commands/list_queue.py @@ -66,5 +66,5 @@ def get_help(cls) -> str: help_text += " and the time it was queued.\n" help_text += "\nExamples:\n" help_text += " /list-queue # Shows all queued prompts\n" - help_text += "\nSee also: /queue, /remove-queue\n" + help_text += "\nSee also: /queue, /insert-queue, /remove-queue, /run-queue\n" return help_text diff --git a/cecli/commands/queue.py b/cecli/commands/queue.py index 9c4a858377c..62f02a5f044 100644 --- a/cecli/commands/queue.py +++ b/cecli/commands/queue.py @@ -59,6 +59,7 @@ async def execute(cls, io, coder, args, **kwargs): item = command_queue.enqueue_prompt(coder, prompt_text) position = command_queue.get_queue_length(coder) io.tool_output(f"Prompt queued at position {position} (id: {item['id']})") + return f"Successfully executed {cls.NORM_NAME}." except ValueError as e: return format_command_result(io, cls.NORM_NAME, "", error=str(e)) @@ -67,8 +68,12 @@ async def execute(cls, io, coder, args, **kwargs): @classmethod def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for queue command.""" - return [] + """Get completion options for queue command. + + When the queued prompt starts with a slash command, mirror the + original command's completions (e.g. /queue /add offers files). + """ + return command_queue.nested_command_completions(coder, args) @classmethod def get_help(cls) -> str: @@ -86,5 +91,5 @@ def get_help(cls) -> str: help_text += "\nExamples:\n" help_text += " /queue Review the changes in src/main.py\n" help_text += " /queue Write unit tests for the new feature\n" - help_text += "\nSee also: /list-queue, /remove-queue\n" + help_text += "\nSee also: /insert-queue, /list-queue, /remove-queue, /run-queue\n" return help_text diff --git a/cecli/commands/remove_queue.py b/cecli/commands/remove_queue.py index 99a5970ddd5..21fca516e41 100644 --- a/cecli/commands/remove_queue.py +++ b/cecli/commands/remove_queue.py @@ -115,5 +115,5 @@ def get_help(cls) -> str: help_text += " /remove-queue 1 # Remove the first queued prompt\n" help_text += " /remove-queue * # Clear the entire queue\n" help_text += " /remove-queue # Interactive mode\n" - help_text += "\nSee also: /queue, /list-queue\n" + help_text += "\nSee also: /queue, /insert-queue, /list-queue, /run-queue\n" return help_text diff --git a/cecli/commands/run_queue.py b/cecli/commands/run_queue.py new file mode 100644 index 00000000000..bc9522b4c6b --- /dev/null +++ b/cecli/commands/run_queue.py @@ -0,0 +1,79 @@ +"""Run-queue command for CLI-33: processes queued prompts on demand. + +Primary use: after cancelling an in-flight response (Ctrl+C), the in-flight +prompt is re-queued at the front. /run-queue resumes processing the queue. +""" + +import asyncio +from typing import List + +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue +from cecli.signals import SwitchCoderSignal + + +class RunQueueCommand(BaseCommand): + NORM_NAME = "run-queue" + DESCRIPTION = "Process queued prompts in order" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the run-queue command with given parameters. + + Args: + io: InputOutput instance + coder: Coder instance (may be None for some commands) + args: Command arguments (unused for run-queue) + **kwargs: Additional context + + Returns: + Formatted result string + """ + # Sad path: coder.commands is None + if not coder.commands: + return format_command_result( + io, cls.NORM_NAME, "", error="Command system not available. Cannot run queue." + ) + + # Resolve the target coder (sub-agent aware: use the foreground agent's queue) + target = command_queue.get_active_coder(coder) + + # Sad path: empty queue + if command_queue.get_queue_length(target) == 0: + io.tool_output("Queue is empty. Use /queue to add prompts first.") + return f"Successfully executed {cls.NORM_NAME}." + + # Happy path: drain the queue + try: + await target.process_prompt_queue() + return f"Successfully executed {cls.NORM_NAME}." + except (KeyboardInterrupt, asyncio.CancelledError): + # The interrupted item was re-queued; let the user know the queue paused. + io.tool_output("Queue processing paused. Use /run-queue to resume.") + return f"Successfully executed {cls.NORM_NAME}." + except (SwitchCoderSignal, SystemExit): + raise + except Exception as e: + return format_command_result(io, cls.NORM_NAME, "", error=str(e)) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for run-queue command.""" + return [] + + @classmethod + def get_help(cls) -> str: + """Get help text for the run-queue command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /run-queue # Process all queued prompts in FIFO order\n" + help_text += "\nDescription:\n" + help_text += " Processes every prompt currently in the queue, in order.\n" + help_text += " Queued prompts normally run automatically after the current\n" + help_text += " response finishes. Use /run-queue to resume a queue that was\n" + help_text += " paused (e.g. after cancelling an in-flight response with Ctrl+C).\n" + help_text += "\nExamples:\n" + help_text += " /run-queue # Drains the queue, processing each prompt in order\n" + help_text += "\nSee also: /queue, /insert-queue, /list-queue, /remove-queue\n" + return help_text diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 6e2e28d8f1a..febc3d78b84 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -6,6 +6,7 @@ import asyncio import logging +import threading import time import weakref from dataclasses import dataclass @@ -40,6 +41,25 @@ AGENT_INHERIT_PREFIXES = ("ws:", "wf:") +def _subagent_event(coder: Any, agent_uuid: Any, event: str, **kwargs: Any) -> None: + """Report a sub-agent's lifecycle to the IO (see ``InputOutput.subagent_event``).""" + io = getattr(coder, "io", None) + if io is None: + return + try: + io.subagent_event(str(agent_uuid), event, **kwargs) + except Exception: + logger.warning("Failed to report sub-agent event %r", event, exc_info=True) + + +def _was_cancelled(info: "SubAgentInfo") -> bool: + """Whether the current run was cancelled, even if the coder absorbed it as a ^C.""" + if info.cancel_requested: + return True + cancelling = getattr(asyncio.current_task(), "cancelling", None) # Python 3.11+ + return bool(cancelling and cancelling()) + + class SubAgentStatus(Enum): """Status of a sub-agent.""" @@ -65,6 +85,7 @@ class SubAgentInfo: auto_reap: bool = True # If True, agent may be automatically reaped when FINISHED independent: bool = False detached: bool = False + cancel_requested: bool = False # the current run was cancelled (see _was_cancelled) class AgentService: @@ -80,8 +101,22 @@ class AgentService: _uuid_coder_map: Dict[str, weakref.ref] = {} # Lock pools keyed by parent UUID — created lazily so only parents that # actually use them allocate a lock. - _spawn_locks: Dict[str, asyncio.Lock] = {} - _conversation_locks: Dict[str, asyncio.Lock] = {} + # + # Plain ``threading.Lock`` (not ``asyncio.Lock``): an ``asyncio.Lock`` binds + # itself to the event loop of its first *waiting* acquirer and raises + # ``RuntimeError('... is bound to a different event loop')`` when awaited + # from another loop. Spawn, reap and conversation writes all reach these + # pools from both the TUI main (Textual) loop and the coder worker-thread + # loop, and the pools are class-level, so a lock created before a hot reload + # outlives the loop it bound to. Every guarded section is fully synchronous, + # so a thread lock gives true cross-loop mutual exclusion with no loop + # affinity and is immune to task cancellation. + # + # Invariant: never hold one of these locks across an ``await`` — the guarded + # sections must stay synchronous, or the blocking acquire could stall the + # event loop. + _spawn_locks: Dict[str, threading.Lock] = {} + _conversation_locks: Dict[str, threading.Lock] = {} _primary_agent_uuid: str = None # ------------------------------------------------------------------ # @@ -305,10 +340,15 @@ def max_sub_agents(self) -> int: # ------------------------------------------------------------------ # # Internal helpers @classmethod - def _get_lock(cls, pool: Dict[str, asyncio.Lock], uuid: str) -> asyncio.Lock: - """Return a lock for *uuid* from *pool*, creating one if absent.""" + def _get_lock(cls, pool: Dict[str, threading.Lock], uuid: str) -> threading.Lock: + """Return a lock for *uuid* from *pool*, creating one if absent. + + Locks are ``threading.Lock`` rather than ``asyncio.Lock`` (see the pool + declarations): the guarded sections are synchronous and correct locking + must work from any event loop. + """ if uuid not in pool: - pool[uuid] = asyncio.Lock() + pool[uuid] = threading.Lock() return pool[uuid] @staticmethod @@ -444,7 +484,7 @@ def _has_unfinished_descendants(agent_uuid: str) -> bool: # Acquire the spawn lock for the primary coder to serialise with # concurrent spawn operations that also hold this lock. parent_coder = parent if parent is not None else self.coder - async with self._get_lock(self._spawn_locks, parent_coder.uuid): + with self._get_lock(self._spawn_locks, parent_coder.uuid): for coder_uuid in list(self._sub_agent_order): info = self.sub_agents.get(coder_uuid) if ( @@ -502,6 +542,8 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None: except (AttributeError, RuntimeError): logger.warning("Failed to notify TUI to remove sub-agent container", exc_info=True) + _subagent_event(self.coder, info.coder.uuid, "removed") + # Cancel any tracked generate task to avoid floating tasks if info.generate_task is not None and not info.generate_task.done(): info.generate_task.cancel() @@ -593,7 +635,7 @@ async def _create_sub_agent_coder( # tried to acquire the same lock). parent_coder = parent if parent is not None else self.coder - async with self._get_lock(self._spawn_locks, parent_coder.uuid): + with self._get_lock(self._spawn_locks, parent_coder.uuid): self._check_max_sub_agents() new_uuid = str(uuid4()).split("-")[0] @@ -686,7 +728,7 @@ async def _create_sub_agent_coder( # Re-acquire the lock to register — we must re-check max agents since # the lock was released and other spawns may have registered in between. - async with self._get_lock(self._spawn_locks, parent_coder.uuid): + with self._get_lock(self._spawn_locks, parent_coder.uuid): self._check_max_sub_agents() # Register in global coder lookup @@ -713,6 +755,16 @@ async def _create_sub_agent_coder( except Exception: logger.warning("Failed to notify TUI to create sub-agent container", exc_info=True) + _subagent_event( + parent_coder, + new_coder.uuid, + "created", + parent_uuid=str(parent_coder.uuid), + name=name, + description=metadata.get("description") if isinstance(metadata, dict) else None, + root=str(getattr(new_coder, "root", "") or ""), + ) + # Initialize system prompt from config system_prompt = getattr(config, "prompt", "") from cecli.helpers.conversation.service import ConversationService @@ -754,6 +806,40 @@ async def _create_sub_agent_coder( # Public API # ------------------------------------------------------------------ # + async def switch_sub_agent_coder(self, info: SubAgentInfo, switch, preproc=True) -> Any: + """Atomically hand a sub-agent's remaining queue to a replacement coder. + + Commands such as /add request a coder rebuild by raising SwitchCoderSignal. + A sub-agent must absorb that signal locally: letting it escape recreates the + primary coder from the sub-agent and leaves the main session in sub-agent + mode. The active prompt has already run, so only the remaining queue moves. + """ + from cecli.coders import Coder + from cecli.helpers import command_queue + + old_coder = info.coder + kwargs = dict(io=old_coder.io, from_coder=old_coder) + kwargs.update(switch.kwargs) + kwargs.pop("show_announcements", None) + kwargs["from_coder"] = old_coder + kwargs["args"] = old_coder.args + kwargs["summarize_from_coder"] = False + + new_coder = await Coder.create(**kwargs) + + new_coder.prompt_queue = list(old_coder.prompt_queue) + new_coder._queue_counter = old_coder._queue_counter + old_coder.prompt_queue.clear() + + command_queue.clear_active_prompt(old_coder) + + info.coder = new_coder + self.sub_agents[new_coder.uuid] = info + self._uuid_coder_map[new_coder.uuid] = weakref.ref(new_coder) + + await new_coder.process_prompt_queue(preproc) + return new_coder + def start_generate_task(self, info: SubAgentInfo, user_message: str) -> asyncio.Task: """Start a sub-agent's generate task in the background with status management. @@ -787,8 +873,14 @@ def start_generate_task(self, info: SubAgentInfo, user_message: str) -> asyncio. async def _run_generate(): info.status = SubAgentStatus.RUNNING + info.cancel_requested = False + _subagent_event(info.coder, info.coder.uuid, "running") + stop_reason, error = "end_turn", None try: await info.coder.generate(user_message=user_message, preproc=True) + if _was_cancelled(info): + stop_reason = "cancelled" + info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED await self._inject_sub_agent_result(info) if info.status == SubAgentStatus.RUNNING: info.status = SubAgentStatus.FINISHED @@ -811,12 +903,14 @@ async def _cleanup(): fire_and_forget(_cleanup()) except asyncio.CancelledError: + stop_reason = "cancelled" await self._inject_sub_agent_result(info) info.status = SubAgentStatus.FINISHED info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED logger.debug("Sub-agent %s generate cancelled (interrupted)", info.name) raise except Exception as exc: + stop_reason, error = "error", str(exc) or exc.__class__.__name__ await self._inject_sub_agent_result(info) info.status = SubAgentStatus.ERROR info.error = str(exc) @@ -829,6 +923,10 @@ async def _cleanup(): raise except SwitchCoderSignal: raise + finally: + _subagent_event( + info.coder, info.coder.uuid, "idle", stop_reason=stop_reason, error=error + ) # Cancel any previous generate task to prevent duplicate concurrent generates if info.generate_task is not None and not info.generate_task.done(): @@ -952,7 +1050,7 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None: else: return - async with self._get_lock(self._conversation_locks, info.parent_uuid): + with self._get_lock(self._conversation_locks, info.parent_uuid): ConversationService.get_manager(parent_coder).add_message( message_dict={"role": "user", "content": user_content}, tag=MessageTag.CUR, @@ -966,6 +1064,10 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None: force=True, ) + _subagent_event( + parent_coder, parent_coder.uuid, "message", sender_uuid=coder_uuid, text=user_content + ) + async def invoke( self, name: str, @@ -991,8 +1093,23 @@ async def invoke( return None # Blocking: run the sub-agent with the prompt using start_generate_task + sender = parent if parent is not None else self.coder + _subagent_event( + sender, new_coder.uuid, "message", sender_uuid=str(sender.uuid), text=prompt + ) task = self.start_generate_task(info, prompt) - await task + try: + await asyncio.shield(task) + except asyncio.CancelledError: + if info.cancel_requested or task.cancelled(): + # The sub-agent was cancelled; wait for its interrupted summary + await asyncio.gather(task, return_exceptions=True) + return info.summary + + # The caller was cancelled: stop the sub-agent it was waiting for + info.cancel_requested = True + task.cancel() + raise return info.summary async def spawn( @@ -1020,6 +1137,10 @@ async def spawn( name, auto_reap=auto_reap, parent=parent, independent=independent ) if prompt: + sender = parent if parent is not None else self.coder + _subagent_event( + sender, new_coder.uuid, "message", sender_uuid=str(sender.uuid), text=prompt + ) self.start_generate_task(info, prompt) return new_coder, info @@ -1059,6 +1180,19 @@ async def wait(self, parent: Any) -> List[str]: return [info.summary for info in children] + def cancel_sub_agent(self, agent_uuid: str) -> bool: + """Stop a sub-agent's current work, leaving its parent running. + + A parent waiting on it (a blocking delegation) gets the interrupted + summary. Returns False if the sub-agent was not working. + """ + info = self.sub_agents.get(str(agent_uuid)) + if info is None or info.generate_task is None or info.generate_task.done(): + return False + info.cancel_requested = True + info.generate_task.cancel() + return True + def get_active_agents(self) -> List[Dict[str, Any]]: """Return list of active sub-agents for display.""" return [ @@ -1200,8 +1334,11 @@ def _tui_notify(tui, callback, *args): """Invoke *callback* on the TUI app, from either thread. ``call_from_thread`` only works when the caller is not the app's own - thread; scheduler dispatch runs on the primary (app) event loop, so fall - back to calling directly, which is correct when already on that thread. + thread. Scheduler-dispatched workflow runs now execute on the coder's own + loop (the TUI worker-thread loop — see ``SchedulerService._on_coder_loop``), + so notifications originate from the worker thread and ``call_from_thread`` + applies; when we are already on the app thread (or the TUI is unavailable), + fall back to calling directly. """ try: diff --git a/cecli/helpers/command_queue.py b/cecli/helpers/command_queue.py index 1463cffc18c..91d7ba64c65 100644 --- a/cecli/helpers/command_queue.py +++ b/cecli/helpers/command_queue.py @@ -110,6 +110,58 @@ def dequeue_prompt(coder) -> dict | None: return coder.prompt_queue.pop(0) +def dequeue_for_processing(coder) -> dict | None: + """Remove and activate the next queued prompt for processing. + + Clearing ``_processing_queue`` while holding the queue lock avoids the + race where a prompt is enqueued just after the drain sees an empty queue + but before it releases the processing flag. + """ + with _get_lock(coder): + if not coder.prompt_queue: + coder._active_queue_prompt = None + coder._processing_queue = False + return None + item = coder.prompt_queue.pop(0) + coder._active_queue_prompt = item + return item + + +def get_active_prompt(coder) -> dict | None: + """Return the prompt currently being processed, if any.""" + with _get_lock(coder): + return getattr(coder, "_active_queue_prompt", None) + + +def clear_active_prompt(coder) -> dict | None: + """Clear and return the prompt currently being processed, if any.""" + with _get_lock(coder): + item = getattr(coder, "_active_queue_prompt", None) + coder._active_queue_prompt = None + return item + + +def requeue_active_prompt(coder) -> dict | None: + """Return the in-flight prompt to the front of the queue. + + The original item is restored unchanged so its id and timestamp match the + prompt the user originally queued. + """ + with _get_lock(coder): + item = getattr(coder, "_active_queue_prompt", None) + if item is None: + return None + coder.prompt_queue.insert(0, item) + coder._active_queue_prompt = None + return item + + +def finish_processing(coder) -> None: + """Mark the queue drain as finished.""" + with _get_lock(coder): + coder._processing_queue = False + + def get_queue_length(coder) -> int: """Return the current number of items in the coder's queue.""" return len(coder.prompt_queue) @@ -151,6 +203,34 @@ def clear_queue(coder) -> list: return items +def nested_command_completions(coder, text: str) -> list: + """Completions for a nested slash-command inside queued prompt text (CLI-33). + + While the inner command is still being typed (no space yet), suggest + matching command names from the registry (e.g. "/sa" -> "/save-session"). + Once the inner command is complete, delegate to the original command's + ``get_completions`` so ``/queue /add `` behaves exactly like ``/add ``. + """ + if not text or not text.startswith("/"): + return [] + + from cecli.commands.utils.registry import CommandRegistry + + first_space = text.find(" ") + if first_space == -1: + prefix = text[1:].lower() + return sorted( + f"/{cmd}" for cmd in CommandRegistry.list_commands() if cmd.startswith(prefix) + ) + + inner_cmd = text[:first_space] + inner_args = text[first_space + 1 :] + try: + return list(coder.commands.get_completions(inner_cmd, inner_args, coder=coder)) + except Exception: + return [] + + def _get_lock(coder) -> threading.Lock: """Return the coder's queue lock, creating one if missing.""" lock = getattr(coder, "_queue_lock", None) diff --git a/cecli/helpers/io_proxy.py b/cecli/helpers/io_proxy.py index 0dfb87154b4..da7a430412f 100644 --- a/cecli/helpers/io_proxy.py +++ b/cecli/helpers/io_proxy.py @@ -126,6 +126,12 @@ def assistant_output(self, message: str = "", pretty: Any = None, **kwargs: Any) kwargs["coder_uuid"] = self._coder_uuid return self._target.assistant_output(message=message, pretty=pretty, **kwargs) + def tool_call_event(self, tool_call_id: str, status: str, **kwargs: Any) -> Any: + """Forward tool_call_event with coder_uuid injected.""" + if "coder_uuid" not in kwargs: + kwargs["coder_uuid"] = self._coder_uuid + return self._target.tool_call_event(tool_call_id, status, **kwargs) + def reset_streaming_response(self, **kwargs) -> Any: """Forward reset_streaming_response with coder_uuid injected.""" if "coder_uuid" not in kwargs: @@ -183,7 +189,28 @@ async def get_input(self, *args, **kwargs): async def confirm_ask(self, *args, **kwargs): """Forward confirm_ask — per-coder queue iteration is handled by - TextualInputOutput which now iterates all per-coder queues.""" + TextualInputOutput which now iterates all per-coder queues. + + Coders configured with ``skip_cli_confirmations`` (unattended ``wf:`` + workflow and ``ws:`` workspace workers) are auto-confirmed. They run + with nobody watching, and — more importantly — a blocking prompt has + to be awaited from whatever loop the coder happens to run on. A + scheduler-dispatched workflow runs on the main (Textual) loop, so + blocking there used to rebind ``queues``' global input-wake slot and + strand the primary coder's input pump. + """ + coder_ref = getattr(self, "_coder", None) + coder = coder_ref() if coder_ref is not None else None + + if coder is not None and getattr(coder, "skip_cli_confirmations", False): + question = args[0] if args else kwargs.get("question", "") + logger.debug( + "auto-confirming %r for unattended coder %s (skip_cli_confirmations)", + question, + self._coder_uuid, + ) + return True + if "coder_uuid" not in kwargs: kwargs["coder_uuid"] = self._coder_uuid return await self._target.confirm_ask(*args, **kwargs) diff --git a/cecli/helpers/llms/domains/bedrock.py b/cecli/helpers/llms/domains/bedrock.py index 68681504bab..ba6f7f3d809 100644 --- a/cecli/helpers/llms/domains/bedrock.py +++ b/cecli/helpers/llms/domains/bedrock.py @@ -194,7 +194,7 @@ def bedrock_endpoint(resolved: Dict[str, Any]) -> str: ) route = resolved["route"] - return f"https://bedrock-runtime.{region}.amazonaws.com/model/{route}/converse" + return f"{resolved['api_base']}/model/{route}/converse" def _signed_headers( diff --git a/cecli/helpers/llms/litellm_compat.py b/cecli/helpers/llms/litellm_compat.py index 4086f141ab0..cb6c4b638a3 100644 --- a/cecli/helpers/llms/litellm_compat.py +++ b/cecli/helpers/llms/litellm_compat.py @@ -19,6 +19,7 @@ import asyncio import json +import math import os import re import warnings @@ -1096,15 +1097,11 @@ def encode( """Estimate tokens for ``text`` without loading a BPE tokenizer. tiktoken's cl100k_base/o200k_base encodings cost ~30-95MB of RSS the - first time they are loaded, so we approximate instead: roughly one token - per four ASCII characters (the classic rule of thumb), plus one token per - non-ASCII character (CJK/emoji tokenize at about one token per char), and - never fewer tokens than there are whitespace-delimited word/punctuation - runs (dense code with short tokens is otherwise undercounted). - - The estimate lands within ~±30% of cl100k_base on code and prose, which - is plenty for context-window checks, cost display, and file-size - warnings. + first time they are loaded, so we approximate instead with a regex + pre-tokenizer patterned on OpenAI's o200k_base (see + ``_estimate_token_count``). The estimate lands within ~±30% of + cl100k_base on code and prose, which is plenty for context-window + checks, cost display, and file-size warnings. Returns a ``range`` whose length is the estimated token count; the individual values are dummy ids (callers only use ``len()``/iteration). @@ -1244,23 +1241,41 @@ def _load_litellm(self) -> None: # Token-count estimator (tiktoken-free) # --------------------------------------------------------------------------- -_WORD_OR_PUNCT_RE = re.compile(r"\w+|[^\w\s]") -_NON_ASCII_RE = re.compile(r"[^\x00-\x7f]") +# Standard ``re`` approximation of OpenAI's o200k_base pre-tokenizer pattern. +# The letter class is Unicode-aware (``[^\W\d_]``) so non-Latin scripts are +# counted rather than dropped. +_O200K_APPROX_PATTERN = re.compile( + r"""'(?:[sdmt]|ll|ve|re)| ?[^\W\d_]+| ?\d{1,3}| ?[^\s\w]+|\s+""", re.UNICODE +) + +#: Bytes charged per token for chunks too long to be a single token, calibrated +#: against o200k_base (~5 bytes/token on code, prose and JSON). +_CHARS_PER_TOKEN = 5.0 def _estimate_token_count(text: str) -> int: """Estimate the number of tokens in ``text`` without a BPE tokenizer. - Uses the classic ~4 chars/token rule of thumb, lifted for non-ASCII text - (CJK/emoji tokenize at roughly one token per character) and floored by the - number of word/punctuation runs so dense code with many short tokens isn't - undercounted. + Splits ``text`` with a regex approximation of OpenAI's o200k_base + pre-tokenizer, then accumulates a fractional token weight per chunk: + whitespace runs collapse to a single token, numbers and short chunks + (<= 4 bytes) count as one, and longer chunks are charged at roughly + ``_CHARS_PER_TOKEN`` bytes per token. The total is rounded up and floored + at one token for any non-empty input. """ if not text: return 0 - units = len(_WORD_OR_PUNCT_RE.findall(text)) - non_ascii = len(_NON_ASCII_RE.findall(text)) + tokens = 0.0 + for chunk in _O200K_APPROX_PATTERN.findall(text): + byte_len = len(chunk.encode("utf-8")) + + if chunk.isspace(): + tokens += 1.0 + elif chunk.strip().isdigit() or byte_len <= 4: + tokens += 1.0 + else: + tokens += max(1.0, byte_len / _CHARS_PER_TOKEN) - return max(1, len(text) // 4, units) + non_ascii + return max(1, math.ceil(tokens - 1e-9)) diff --git a/cecli/helpers/onboarding/providers.py b/cecli/helpers/onboarding/providers.py index 5cd27099c5b..b37dea0913d 100644 --- a/cecli/helpers/onboarding/providers.py +++ b/cecli/helpers/onboarding/providers.py @@ -3,7 +3,7 @@ import importlib.resources as importlib_resources import json import os -from typing import Dict, List +from typing import Collection, Dict, List, Optional # Providers that authenticate through mechanisms other than a simple _API_KEY # (e.g. GitHub Copilot tokens or AWS credentials), so onboarding should never @@ -61,12 +61,19 @@ def _load_metadata() -> Dict: return data -def _models_from_metadata(data: Dict, slug: str) -> List[str]: - """Return selectable model names for a provider from the metadata dict.""" +def _models_from_metadata( + data: Dict, slug: str, modes: Optional[Collection[str]] = None +) -> List[str]: + """Return selectable model names for a provider from the metadata dict. + + ``modes`` keeps only models with those metadata modes (e.g. ``("chat",)``). + """ models = [] for name, meta in data.items(): if not isinstance(meta, dict): continue + if modes and meta.get("mode", "chat") not in modes: + continue provider = meta.get("litellm_provider") or "" if provider == slug: models.append(name if name.startswith(slug + "/") else f"{slug}/{name}") @@ -128,9 +135,12 @@ def provider_display(entry: Dict) -> str: return label -def get_models_for_provider(slug: str) -> List[str]: - """Return the selectable model names for a provider.""" - models = _models_from_metadata(_load_metadata(), slug) +def get_models_for_provider(slug: str, modes: Optional[Collection[str]] = None) -> List[str]: + """Return the selectable model names for a provider. + + ``modes`` filters the bundled metadata by mode; a live fetch is not filtered. + """ + models = _models_from_metadata(_load_metadata(), slug, modes) if models: return sorted(set(models)) diff --git a/cecli/helpers/queues.py b/cecli/helpers/queues.py index 3f868d97812..2ccd59e54f6 100644 --- a/cecli/helpers/queues.py +++ b/cecli/helpers/queues.py @@ -3,9 +3,12 @@ from __future__ import annotations import asyncio +import logging import queue from typing import Any +logger = logging.getLogger(__name__) + # Global registry: coder_uuid → queue.Queue for sending input to that coder _per_coder_queues: dict[str, "queue.Queue"] = {} @@ -19,6 +22,13 @@ _input_loop: asyncio.AbstractEventLoop | None = None _input_wake: asyncio.Event | None = None +# Dedicated wake events for consumers parked on a loop *other* than the primary +# input loop (for example a scheduler-dispatched workflow asking for input while +# the scheduler ticks on the main/Textual loop). They get their own slot so a +# foreign-loop consumer can never steal the primary binding and strand the +# primary coder's input pump. +_input_wakes: dict[asyncio.AbstractEventLoop, asyncio.Event] = {} + def register_coder_queue(coder_uuid: str, q: "queue.Queue") -> None: """Register a per-coder input queue. @@ -83,6 +93,9 @@ def set_input_loop(loop: asyncio.AbstractEventLoop) -> None: global _input_loop, _input_wake _input_loop = loop _input_wake = asyncio.Event() + # This loop now owns the primary slot, so drop any dedicated wake it may + # have registered while it was still a foreign consumer. + _input_wakes.pop(loop, None) def wake_input_waiters() -> None: @@ -95,39 +108,73 @@ def wake_input_waiters() -> None: coder worker loop), the stale binding is dropped so the next wait_for_input() rebinds to the current loop instead of raising "Event loop is closed". + + Every loop with a parked consumer is woken, not just the primary one: + a consumer on a foreign loop has its own event (see ``_input_wakes``) so + the primary binding is never stolen from it. """ global _input_loop, _input_wake loop = _input_loop - if loop is None or _input_wake is None: - return - if loop.is_closed(): - _input_loop = None - _input_wake = None - return + if loop is not None: + if _input_wake is None: + _input_loop = None + elif loop.is_closed(): + _input_loop = None + _input_wake = None + else: + loop.call_soon_threadsafe(_input_wake.set) + + for other, wake in list(_input_wakes.items()): + if other.is_closed(): + _input_wakes.pop(other, None) + continue - loop.call_soon_threadsafe(_input_wake.set) + try: + other.call_soon_threadsafe(wake.set) + except RuntimeError: + # Loop stopped accepting callbacks between the check and the call. + _input_wakes.pop(other, None) async def wait_for_input() -> None: """Await the next input push without polling. - Must be called from the input loop (the coder worker loop). Consumers - sweep the payload queues first, then block here until wake_input_waiters() - fires. Initializes the wake state from the running loop on first use and - rebinds whenever the previous loop was closed or is no longer the - running loop (which happens across a hot reload). + Intended to be called from the input loop (the coder worker loop). + Consumers sweep the payload queues first, then block here until + wake_input_waiters() fires. Initializes the wake state from the running + loop on first use and rebinds whenever the previous loop was closed (which + happens across a hot reload). + + A caller on a *different*, still-live loop no longer rebinds the primary + slot: it gets a dedicated wake event instead. Rebinding used to strand the + primary coder's input pump on the orphaned event, which froze the TUI + (prompts, slash commands and ``/exit`` all unresponsive; only Ctrl-Q + worked) whenever a scheduler-dispatched workflow needed input or a + confirmation and therefore ran on the main (Textual) loop. """ loop = asyncio.get_running_loop() - if ( - _input_loop is None - or _input_wake is None - or _input_loop.is_closed() - or _input_loop is not loop - ): + if _input_loop is None or _input_wake is None or _input_loop.is_closed(): set_input_loop(loop) - _input_wake.clear() - await _input_wake.wait() + if _input_loop is loop: + _input_wake.clear() + await _input_wake.wait() + return + + logger.warning( + "wait_for_input() called from a non-input loop (%r); using a dedicated" + " wake event instead of rebinding the primary input loop", + loop, + ) + + wake = _input_wakes.get(loop) + + if wake is None: + wake = asyncio.Event() + _input_wakes[loop] = wake + + wake.clear() + await wake.wait() diff --git a/cecli/helpers/scheduler/service.py b/cecli/helpers/scheduler/service.py index a6d1ad493ca..f3d81c1a8e8 100644 --- a/cecli/helpers/scheduler/service.py +++ b/cecli/helpers/scheduler/service.py @@ -173,7 +173,9 @@ async def _dispatch(self, wf: Any, lease: Any, slot: Any, now: datetime) -> None renewal = asyncio.create_task(renewal) try: - result = await execute_workflow(self.coder, wf, source="scheduler") + result = await self._on_coder_loop( + lambda: execute_workflow(self.coder, wf, source="scheduler") + ) await self.db.finish(lease, result) await self._log_tasks(lease, result) except asyncio.CancelledError: @@ -191,6 +193,43 @@ async def _dispatch(self, wf: Any, lease: Any, slot: Any, now: datetime) -> None nxt = self._successor(wf, slot, now, next_run) await self.db.advance(wf.name, nxt) + def _coder_loop(self) -> Optional[asyncio.AbstractEventLoop]: + """Best-effort discovery of the loop the interactive coder runs on. + + In TUI mode the coder lives on a worker-thread loop while the scheduler + ticks on the main (Textual) loop, so the two differ. Returns ``None`` + when the coder's loop cannot be determined (linear/one-shot sessions, + tests), in which case callers stay on the current loop. + """ + + tui_ref = getattr(self.coder, "tui", None) + + try: + tui = tui_ref() if callable(tui_ref) else tui_ref + except Exception: + tui = None + + worker = getattr(tui, "worker", None) + loop = getattr(worker, "loop", None) + + return loop if isinstance(loop, asyncio.AbstractEventLoop) else None + + async def _on_coder_loop(self, make_coro: Any) -> Any: + """Await ``make_coro()`` on the coder's own event loop. + + A workflow run owns coder I/O (prompts, confirmations, tool output), + which is bound to the loop the coder actually runs on. + """ + + target = self._coder_loop() + running = asyncio.get_running_loop() + + if target is None or target is running or target.is_closed(): + return await make_coro() + + future = asyncio.run_coroutine_threadsafe(make_coro(), target) + return await asyncio.wrap_future(future) + async def _log_tasks(self, lease: Any, result: Any) -> None: """Mirror the runner's per-task outcomes into the DB ``task_log``.""" diff --git a/cecli/helpers/server/acp/agent/__init__.py b/cecli/helpers/server/acp/agent/__init__.py new file mode 100644 index 00000000000..96086b12b20 --- /dev/null +++ b/cecli/helpers/server/acp/agent/__init__.py @@ -0,0 +1,30 @@ +"""``cecli --acp``: cecli as an Agent Client Protocol (ACP) v1 agent. + +ACP clients such as Zed launch cecli as a subprocess and drive it with +newline-delimited JSON-RPC 2.0 on stdin/stdout: +https://agentclientprotocol.com/protocol/v1/schema + +- ``agent``: AcpAgent, which answers the client and runs each prompt as a turn +- ``transport``: the channel to the client (stdio) +- ``io``: AcpInputOutput, which routes cecli's input and output through the agent +- ``updates``: cecli's output as ``session/update`` notifications +- ``config``: the mode, model and effort pickers +- ``mcp``: MCP servers passed by the client + +The WebSocket bridge in the parent package is a separate feature, which streams +cecli's events to dashboards. +""" + +from __future__ import annotations + +from cecli.helpers.server.acp.agent.agent import AcpAgent +from cecli.helpers.server.acp.agent.transport import StdioTransport + +_agent: AcpAgent | None = None + + +def get_agent() -> AcpAgent: + global _agent + if _agent is None: + _agent = AcpAgent(StdioTransport()) + return _agent diff --git a/cecli/helpers/server/acp/agent/agent.py b/cecli/helpers/server/acp/agent/agent.py new file mode 100644 index 00000000000..9fa63382a2c --- /dev/null +++ b/cecli/helpers/server/acp/agent/agent.py @@ -0,0 +1,760 @@ +"""AcpAgent: the agent side of the Agent Client Protocol (ACP) v1. + +One process serves one project. ``bootstrap()`` runs before ``main_async`` so +the first ``session/new`` can pick the working directory the coder is built in; +its response is deferred until the coder first asks for input. From then on the +coder's normal linear run loop drives each turn: a turn starts when +``next_input`` hands the loop a prompt and ends when the loop asks again. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import uuid +import weakref +from dataclasses import dataclass, field +from typing import Any, Callable +from urllib.parse import unquote, urlparse + +from cecli import __version__ +from cecli.helpers.server.acp.agent.config import SessionConfig, current_value +from cecli.helpers.server.acp.agent.mcp import ClientMcpServers +from cecli.helpers.server.acp.agent.transport import ( + EOF, + INTERNAL_ERROR, + INVALID_PARAMS, + METHOD_NOT_FOUND, + Transport, + error_message, + log, + result_message, +) +from cecli.helpers.server.acp.agent.updates import CONSOLE_FENCE, UpdateEmitter + +PROTOCOL_VERSION = 1 + +# Commands that make no sense when an editor owns the UI +HIDDEN_COMMANDS = { + "edit", + "editor", + "exit", + "hot-reload", + "multiline-mode", + "quit", + "terminal-setup", + "voice", +} + +_DEFERRED = object() + + +class AcpError(Exception): + def __init__(self, code: int, message: str): + super().__init__(message) + self.code = code + self.message = message + + +# ── Prompt conversion ────────────────────────────────────────── + + +def _file_path(uri: str | None) -> str | None: + if not uri or not uri.startswith("file://"): + return None + path = unquote(urlparse(uri).path) + if os.name == "nt" and len(path) > 2 and path[0] == "/" and path[2] == ":": + path = path[1:] + return path + + +def _display_path(path: str, root: str) -> str: + try: + rel = os.path.relpath(path, root) + except ValueError: # different drive + return path + return path if rel.startswith("..") else rel + + +def prompt_to_text(blocks: list, root: str) -> tuple[str, list[str]]: + """Flatten ACP content blocks into cecli input text plus mentioned file paths.""" + parts: list[str] = [] + paths: list[str] = [] + for block in blocks or []: + if not isinstance(block, dict): + continue + kind = block.get("type") + if kind == "text": + parts.append(block.get("text") or "") + elif kind == "resource_link": + path = _file_path(block.get("uri")) + if path: + paths.append(path) + parts.append(_display_path(path, root)) + else: + parts.append(block.get("uri") or block.get("name") or "") + elif kind == "resource": + resource = block.get("resource") or {} + uri = resource.get("uri") or "" + path = _file_path(uri) + if path: + paths.append(path) + label = _display_path(path, root) if path else uri + text = resource.get("text") + if text is not None and (not path or "#" in uri): + # A selection or non-file context: the text itself is the point + parts.append(f"\n{label}:\n```\n{text}\n```\n") + else: + parts.append(label) + return " ".join(p for p in parts if p).strip(), paths + + +# ── Agent ────────────────────────────────────────────────────── + + +@dataclass +class _Turn: + text: str + on_finish: Callable[[str], None] + request_id: Any = None # None for internal commands, whose output is not shown + paths: list[str] = field(default_factory=list) + cancelled: bool = False + + +@dataclass +class _Subagent: + """A cecli sub-agent, shown to the client as a child session.""" + + agent_uuid: str + session_id: str + parent_session_id: str + emitter: UpdateEmitter + running: bool = False + cancelled: bool = False + removed: bool = False + + +class AcpAgent: + def __init__(self, transport: Transport): + self.transport = transport + self.emitter = UpdateEmitter(self._send_turn_update) + self.config = SessionConfig() + self.mcp = ClientMcpServers(log) + self.session_id: str | None = None + self.cwd: str | None = None + self.turn: _Turn | None = None + self._deferred_new: list[Any] = [] + self._ready = False + self._loop: asyncio.AbstractEventLoop | None = None + self._inputs: asyncio.Queue | None = None + self._dispatcher: asyncio.Task | None = None + self._coder_ref = None + self._next_request_id = 0 + # request id -> (future, the session that asked) + self._pending: dict[Any, tuple[asyncio.Future, str | None]] = {} + self._sent_options: list[dict] | None = None + # Sub-agents as child sessions, keyed by coder uuid; only for clients + # that advertise the (unstable) subagents capability + self.subagents_supported = False + self.subagents: dict[str, _Subagent] = {} + self._message_count = 0 + + # ── Lifecycle ─────────────────────────────────────────────── + + def bootstrap(self) -> str | None: + """Open the transport, answer ``initialize`` and wait for the first ``session/new``. + + Returns that session's cwd, or None if the client hung up first. + """ + self.transport.open() + # Commands run in a pseudo-terminal, where git log and friends start a + # pager that waits for a keypress nobody can send + os.environ["PAGER"] = "cat" + os.environ["GIT_PAGER"] = "cat" + return asyncio.run(self._await_first_session()) + + async def _await_first_session(self) -> str | None: + self.transport.attach(asyncio.get_running_loop()) + try: + while True: + msg = await self.transport.receive() + if msg is EOF: + return None + if msg.get("method") == "session/new" and "id" in msg: + try: + cwd = _require_cwd(msg.get("params") or {}) + except AcpError as err: + self.transport.send(error_message(msg["id"], err.code, err.message)) + continue + self.cwd = cwd + self.session_id = _new_session_id() + self.mcp.want((msg.get("params") or {}).get("mcpServers")) + self._deferred_new.append(msg["id"]) + return cwd + self._handle(msg) + finally: + self.transport.detach() + + @property + def launched(self) -> bool: + """True once ``bootstrap`` has a session to serve.""" + return self.session_id is not None + + def start(self) -> None: + """Serve the protocol on the running loop (``main_async``'s).""" + if not self.launched: + raise RuntimeError("--acp must be launched through the cecli command line") + loop = asyncio.get_running_loop() + if self._loop is loop: + return + if self.turn is not None: + # A program reload dropped the loop mid-turn + self._finish_turn() + self._loop = loop + self._inputs = asyncio.Queue() + self.transport.attach(loop) + self._dispatcher = loop.create_task(self._dispatch()) + + def shutdown(self) -> None: + """Answer whatever is still waiting before the process exits.""" + for request_id in self._deferred_new: + self.transport.send( + error_message( + request_id, + INTERNAL_ERROR, + "cecli exited before the session was ready; see its stderr for details", + ) + ) + self._deferred_new.clear() + self._finish_turn() + + async def _dispatch(self) -> None: + while True: + msg = await self.transport.receive() + if msg is EOF: + self._on_eof() + return + if "method" in msg: + self._handle(msg) + else: + self._on_response(msg) + + def _on_eof(self) -> None: + self._cancel_pending_requests() + if self.turn is not None: + self.turn.cancelled = True + self._interrupt_coder() + self._inputs.put_nowait(EOF) + + # ── Inbound ───────────────────────────────────────────────── + + def _handle(self, msg: dict) -> None: + method = msg.get("method") + params = msg.get("params") or {} + is_request = "id" in msg + request_id = msg.get("id") + + handlers = { + "initialize": self._on_initialize, + "authenticate": lambda params, request_id: {}, + "session/new": self._on_session_new, + "session/prompt": self._on_session_prompt, + "session/cancel": self._on_session_cancel, + "session/set_config_option": self._on_set_config_option, + "session/set_mode": self._on_set_mode, + } + handler = handlers.get(method) + if handler is None: + if is_request: + self.transport.send( + error_message(request_id, METHOD_NOT_FOUND, f"Method not found: {method}") + ) + return + + try: + result = handler(params, request_id) + except AcpError as err: + if is_request: + self.transport.send(error_message(request_id, err.code, err.message)) + return + except Exception as err: # never let one bad message kill the dispatcher + log(f"error handling {method}: {err!r}") + if is_request: + self.transport.send(error_message(request_id, INTERNAL_ERROR, str(err))) + return + + if is_request and result is not _DEFERRED: + self.transport.send(result_message(request_id, result)) + + def _on_initialize(self, params: dict, request_id: Any) -> dict: + capabilities = params.get("clientCapabilities") or {} + self.subagents_supported = isinstance(capabilities.get("subagents"), dict) + return { + "protocolVersion": PROTOCOL_VERSION, + "agentCapabilities": { + "loadSession": False, + "promptCapabilities": {"image": False, "audio": False, "embeddedContext": True}, + "mcpCapabilities": {"http": True, "sse": True}, + }, + "authMethods": [], + "agentInfo": {"name": "cecli", "title": "cecli", "version": __version__}, + } + + def _on_session_new(self, params: dict, request_id: Any) -> Any: + cwd = _require_cwd(params) + if self.cwd is None: + raise AcpError(INTERNAL_ERROR, "cecli is not running in ACP mode") + if os.path.realpath(cwd) != os.path.realpath(self.cwd): + raise AcpError( + INVALID_PARAMS, + f"This cecli process serves {self.cwd}; start another agent for {cwd}", + ) + self.mcp.want(params.get("mcpServers")) + if not self._ready: + self._deferred_new.append(request_id) + return _DEFERRED + + # A new thread on the same project: clear the chat, then hand out a new id + session_id = _new_session_id() + + def finish(_stop_reason: str) -> None: + self.session_id = session_id + self.transport.send(result_message(request_id, self._session_state())) + self._announce_commands() + + self._inputs.put_nowait(_Turn("/clear", on_finish=finish)) + return _DEFERRED + + def _on_session_prompt(self, params: dict, request_id: Any) -> Any: + self._require_session(params.get("sessionId")) + text, paths = prompt_to_text(params.get("prompt"), self.cwd or os.getcwd()) + if not text and not paths: + return {"stopReason": "end_turn"} + + def finish(stop_reason: str) -> None: + self.transport.send(result_message(request_id, {"stopReason": stop_reason})) + + self._inputs.put_nowait(_Turn(text, on_finish=finish, request_id=request_id, paths=paths)) + return _DEFERRED + + def _on_session_cancel(self, params: dict, request_id: Any) -> None: + if params.get("sessionId") != self.session_id: + self._cancel_subagent(params.get("sessionId")) + return None + + # Prompts queued behind the running one are dropped + kept = [] + while not self._inputs.empty(): + queued = self._inputs.get_nowait() + if isinstance(queued, _Turn) and queued.request_id is not None: + queued.on_finish("cancelled") + else: + kept.append(queued) + for queued in kept: + self._inputs.put_nowait(queued) + + if self.turn is not None and self.turn.request_id is not None: + self.turn.cancelled = True + # Sub-agents the turn waits for are cancelled with it and report that + # themselves; ones working in the background keep going + self._cancel_pending_requests(self.session_id) + self._interrupt_coder() + return None + + def _on_set_config_option(self, params: dict, request_id: Any) -> Any: + self._require_session(params.get("sessionId")) + command = self._config_command(params.get("configId"), params.get("value")) + if command is None: + return {"configOptions": self._current_options()} + + def finish(_stop_reason: str) -> None: + self.transport.send( + result_message(request_id, {"configOptions": self._current_options()}) + ) + + self._inputs.put_nowait(_Turn(command, on_finish=finish)) + return _DEFERRED + + def _on_set_mode(self, params: dict, request_id: Any) -> Any: + # Legacy modes, for clients without config options + self._require_session(params.get("sessionId")) + command = self._config_command("mode", params.get("modeId")) + if command is None: + return {} + + def finish(_stop_reason: str) -> None: + self._sync_config() + self.transport.send(result_message(request_id, {})) + + self._inputs.put_nowait(_Turn(command, on_finish=finish)) + return _DEFERRED + + def _config_command(self, config_id: Any, value: Any) -> str | None: + try: + return self.config.command(self._coder(), config_id, value) + except ValueError as err: + raise AcpError(INVALID_PARAMS, str(err)) + + def _on_response(self, msg: dict) -> None: + future, _session_id = self._pending.get(msg.get("id"), (None, None)) + if future is None or future.done(): + return + if "error" in msg: + log(f"client error: {msg['error']}") + future.set_result(None) + else: + future.set_result(msg.get("result")) + + def _require_session(self, session_id: Any) -> None: + if not self._ready or session_id != self.session_id: + raise AcpError(INVALID_PARAMS, f"Unknown session: {session_id}") + + # ── Turns (called from the coder loop via AcpInputOutput) ─── + + async def next_input(self, coder: Any = None) -> str: + """End the current turn, then wait for the next prompt.""" + # A mode or model switch replaces the coder, so track it before the + # finished turn reports the new configuration + if coder is not None: + self._coder_ref = weakref.ref(coder) + if self.mcp.pending: + # Before session/new is answered, so the first prompt has the tools + await self._sync_mcp(coder) + self._finish_turn() + + if not self._ready: + self._ready = True + for request_id in self._deferred_new: + self.transport.send(result_message(request_id, self._session_state())) + self._deferred_new.clear() + self._announce_commands() + + turn = await self._inputs.get() + if turn is EOF: + self._inputs.put_nowait(EOF) + raise EOFError + self.turn = turn + if turn.paths and coder is not None: + _add_mentions(coder, turn.paths) + return turn.text + + async def _sync_mcp(self, coder: Any) -> None: + try: + await self.mcp.sync( + getattr(coder, "mcp_manager", None), coder.io, getattr(coder, "verbose", False) + ) + except Exception as err: # a broken MCP server must not break the session + log(f"could not connect the client's MCP servers: {err!r}") + + def in_prompt(self) -> bool: + """True while a client prompt (not an internal command) is running.""" + return self.turn is not None and self.turn.request_id is not None + + def _finish_turn(self) -> None: + turn = self.turn + if turn is not None and turn.request_id is not None: + # Flush while the turn is still current so the updates go out + self.emitter.finish_turn(cancelled=turn.cancelled) + self._sync_config() + else: + self.emitter.reset() + self.turn = None + if turn is not None: + turn.on_finish("cancelled" if turn.cancelled else "end_turn") + + def _coder(self) -> Any: + return self._coder_ref() if self._coder_ref is not None else None + + def _interrupt_coder(self) -> None: + coder = self._coder() + if coder is None: + return + task = getattr(coder.io, "output_task", None) + if task is not None and not task.done(): + task.cancel() + event = getattr(coder, "interrupt_event", None) + if event is not None: + event.set() + + # ── Outbound ──────────────────────────────────────────────── + + def _send_turn_update(self, update: dict) -> None: + if self.in_prompt(): + self.send_update(update) + elif update.get("sessionUpdate") == "agent_message_chunk": + # Output outside a prompt (startup, internal commands) is only logged, + # so startup failures still explain themselves in the client's logs + text = update["content"]["text"].replace(CONSOLE_FENCE + "\n", "") + sys.stderr.write(text) + sys.stderr.flush() + + def send_update(self, update: dict, session_id: str | None = None) -> None: + self.transport.send( + { + "jsonrpc": "2.0", + "method": "session/update", + "params": {"sessionId": session_id or self.session_id, "update": update}, + } + ) + + def _current_options(self) -> list[dict]: + """The config options as they now stand, remembered as what the client has.""" + try: + options = self.config.options(self._coder()) + except Exception as err: # never let a model quirk break the session + log(f"could not build config options: {err!r}") + options = [] + self._sent_options = options + return options + + def _session_state(self) -> dict: + state: dict[str, Any] = {"sessionId": self.session_id} + options = self._current_options() + if options: + state["configOptions"] = options + state["modes"] = self.config.modes(options) + return state + + def _sync_config(self) -> None: + """Tell the client when a typed command (/model, /ask, ...) changed the options.""" + previous = self._sent_options + options = self._current_options() + if not options or options == previous: + return + self.send_update({"sessionUpdate": "config_option_update", "configOptions": options}) + mode = current_value(options, "mode") + if mode is not None and mode != current_value(previous or [], "mode"): + self.send_update({"sessionUpdate": "current_mode_update", "currentModeId": mode}) + + def _announce_commands(self) -> None: + from cecli.commands.utils.registry import CommandRegistry + + commands = [] + for name in sorted(CommandRegistry.list_commands()): + if name in HIDDEN_COMMANDS: + continue + command = CommandRegistry.get_command(name) + description = getattr(command, "DESCRIPTION", "") or name + commands.append({"name": name, "description": description}) + self.send_update( + {"sessionUpdate": "available_commands_update", "availableCommands": commands} + ) + + async def request(self, method: str, params: dict) -> Any: + """Send a request to the client and wait for its result (None on error).""" + self._next_request_id += 1 + request_id = self._next_request_id + future = asyncio.get_running_loop().create_future() + self._pending[request_id] = (future, params.get("sessionId")) + self.transport.send( + {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + ) + try: + return await future + finally: + self._pending.pop(request_id, None) + + def _cancel_pending_requests(self, session_id: str | None = None) -> None: + """Answer outstanding requests as cancelled: all of them, or one session's.""" + for future, owner in self._pending.values(): + if not future.done() and session_id in (None, owner): + future.set_result({"outcome": {"outcome": "cancelled"}}) + + async def ask_permission( + self, question: str, subject: str | None, options: list[dict], agent_uuid: str | None = None + ) -> str | None: + """Ask the client to approve something; returns the chosen optionId or None.""" + subagent = self.subagents.get(agent_uuid) if agent_uuid else None + if subagent is not None: + # A sub-agent may ask while the parent's turn is over + if not subagent.running or subagent.cancelled: + return None + emitter, session_id = subagent.emitter, subagent.session_id + self._send_subagent_state(subagent, {"state": "requires_action"}) + elif not self.in_prompt() or self.turn.cancelled: + return None + else: + emitter, session_id = self.emitter, self.session_id + + tool_call_id, synthetic = emitter.permission_target(question, subject) + result = await self.request( + "session/request_permission", + { + "sessionId": session_id, + "toolCall": emitter.permission_prompt(tool_call_id, question), + "options": options, + }, + ) + if subagent is not None and subagent.running and not subagent.cancelled: + self._send_subagent_state(subagent, {"state": "running"}) + outcome = (result or {}).get("outcome") or {} + choice = outcome.get("optionId") if outcome.get("outcome") == "selected" else None + + allowed = any(o["optionId"] == choice and o["kind"].startswith("allow") for o in options) + emitter.permission_resolved(tool_call_id, synthetic, allowed) + return choice + + # ── Sub-agents (the unstable ACP subagents RFD) ───────────── + + def emitter_for(self, agent_uuid: str | None) -> UpdateEmitter: + """The emitter for output from the coder with this uuid.""" + subagent = self.subagents.get(agent_uuid) if agent_uuid else None + return subagent.emitter if subagent is not None else self.emitter + + def subagent_event(self, agent_uuid: str, event: str, **details: Any) -> None: + """Show a cecli sub-agent to the client as a child session. + + Clients that don't advertise the subagents capability see sub-agent + output in the parent session instead, as before. + """ + if not self.subagents_supported or self.session_id is None: + return + if event == "created": + self._announce_subagent(agent_uuid, **details) + return + if event == "message": + self._report_message(details.get("sender_uuid"), agent_uuid, details.get("text")) + return + + subagent = self.subagents.get(agent_uuid) + if subagent is None: + return + if event == "running": + subagent.running, subagent.cancelled = True, False + self._send_subagent_state(subagent, {"state": "running"}) + elif event == "idle": + stop_reason = details.get("stop_reason") or "end_turn" + subagent.emitter.finish_turn(cancelled=stop_reason == "cancelled") + subagent.running = False + self._cancel_pending_requests(subagent.session_id) + state = {"state": "idle", "stopReason": stop_reason} + if stop_reason == "error": + state["error"] = { + "code": INTERNAL_ERROR, + "message": details.get("error") or "The sub-agent failed", + } + self._send_subagent_state(subagent, state) + elif event == "removed": + # Reaped: nothing left to cancel. The child's history stays with the client + subagent.removed = True + self.send_update( + { + "sessionUpdate": "subagent_update", + "sessionId": subagent.session_id, + "capabilities": None, + }, + subagent.parent_session_id, + ) + + def _announce_subagent( + self, + agent_uuid: str, + parent_uuid: str | None = None, + name: str | None = None, + description: str | None = None, + root: str | None = None, + **_details: Any, + ) -> None: + if agent_uuid in self.subagents: + return + session_id = _new_session_id() + parent_session_id = self._session_of(parent_uuid) + emitter = UpdateEmitter(lambda update: self.send_update(update, session_id)) + if root: + emitter.root = root + + update: dict[str, Any] = { + "sessionUpdate": "subagent_update", + "sessionId": session_id, + "capabilities": {"cancel": {}}, + } + if name: + update["title"] = name + if description: + update["description"] = str(description) + # The announcement goes out before anything that names the child + self.send_update(update, parent_session_id) + self.subagents[agent_uuid] = _Subagent(agent_uuid, session_id, parent_session_id, emitter) + + def _session_of(self, agent_uuid: str | None) -> str: + """The session showing this coder: its own if it's a sub-agent, else the parent's.""" + subagent = self.subagents.get(agent_uuid) if agent_uuid else None + return subagent.session_id if subagent is not None else self.session_id + + def _report_message(self, sender_uuid: str | None, recipient_uuid: str, text: Any) -> None: + """A message between agents, as the sender sent it and as the recipient got it.""" + if not text: + return + sender, recipient = self._session_of(sender_uuid), self._session_of(recipient_uuid) + if sender == recipient: + return + content = [{"type": "text", "text": str(text)}] + for session_id in (sender, recipient): + self._message_count += 1 + self.send_update( + { + "sessionUpdate": "session_message", + "messageId": f"cecli-message-{self._message_count}", + "senderSessionId": sender, + "recipientSessionId": recipient, + "content": content, + }, + session_id, + ) + + def _send_subagent_state(self, subagent: _Subagent, state: dict) -> None: + self.send_update( + {"sessionUpdate": "subagent_update", "sessionId": subagent.session_id, "state": state}, + subagent.parent_session_id, + ) + + def _cancel_subagent(self, session_id: Any) -> None: + """``session/cancel`` on a child: stop that sub-agent's current work only.""" + subagent = next((s for s in self.subagents.values() if s.session_id == session_id), None) + if subagent is None or subagent.removed or not subagent.running: + return + subagent.cancelled = True + self._cancel_pending_requests(subagent.session_id) + coder = self._coder() + if coder is None: + return + from cecli.helpers.agents.service import AgentService + + # It reports the cancelled idle state itself once it has stopped + AgentService.get_instance(coder).cancel_sub_agent(subagent.agent_uuid) + + +def _require_cwd(params: dict) -> str: + cwd = params.get("cwd") + if not isinstance(cwd, str) or not os.path.isabs(cwd): + raise AcpError(INVALID_PARAMS, "session/new requires an absolute cwd") + if not os.path.isdir(cwd): + raise AcpError(INVALID_PARAMS, f"Not a directory: {cwd}") + return cwd + + +def _new_session_id() -> str: + return uuid.uuid4().hex + + +def _add_mentions(coder: Any, paths: list[str]) -> None: + """Put @-mentioned files in the chat, as cecli would for /add.""" + root = os.path.realpath(coder.root) + for path in paths: + if not os.path.isfile(path): + continue + real = os.path.realpath(path) + if real in coder.abs_fnames or real in coder.abs_read_only_fnames: + continue + try: + inside = os.path.commonpath([root, real]) == root + except ValueError: # different drives + inside = False + if inside: + rel = coder.get_rel_fname(real) + coder.add_rel_fname(rel) + coder.io.tool_output(f"Added {rel} to the chat") + else: + coder.abs_read_only_fnames.add(real) + coder.io.tool_output(f"Added {real} to the chat (read-only)") diff --git a/cecli/helpers/server/acp/agent/config.py b/cecli/helpers/server/acp/agent/config.py new file mode 100644 index 00000000000..82e3fe54e68 --- /dev/null +++ b/cecli/helpers/server/acp/agent/config.py @@ -0,0 +1,327 @@ +"""Session config options for ``cecli --acp``: chat mode, model and thinking effort. + +ACP clients render these as pickers next to the prompt box: +https://agentclientprotocol.com/protocol/session-config-options + +Picking a value runs the matching cecli command (``/ask``, ``/model ...``, +``/reasoning-effort ...``), so a switch behaves exactly as if it were typed. +""" + +from __future__ import annotations + +import fnmatch +from typing import Any + +import yaml + +from cecli.helpers.server.acp.agent.transport import log + +MODES = [ + ("code", "Code", "Make the requested changes to the code"), + ("ask", "Ask", "Answer questions without editing files"), + ("architect", "Architect", "Plan with the main model, then edit with the editor model"), + ("context", "Context", "Work out which files a change needs"), + ("agent", "Agent", "Work autonomously, finding files and running tools"), + ("agent-yolo", "Agent (YOLO)", "Agent mode that runs tools and commands without asking"), +] +# Agent mode with what --yolo turns on: every confirmation answered yes +YOLO_MODE = "agent-yolo" +REASONING_EFFORTS = ["low", "medium", "high"] +THINKING_BUDGETS = ["4k", "8k", "16k", "32k"] +# Model metadata modes cecli can chat with (not image generation and the like) +CHAT_MODES = ("chat", "responses") + + +def _value(value: str, name: str | None = None, description: str | None = None) -> dict: + entry = {"value": value, "name": name or value} + if description: + entry["description"] = description + return entry + + +def _select(config_id: str, name: str, category: str, current: str, values: list[dict]) -> dict: + return { + "id": config_id, + "name": name, + "category": category, + "type": "select", + "currentValue": current, + "options": values, + } + + +def _token_count(budget: str) -> float: + """``"8k"`` -> 8192, as cecli formats thinking budgets.""" + scale = {"k": 1024, "m": 1024 * 1024}.get(budget[-1:].lower()) + return float(budget[:-1]) * scale if scale else float(budget) + + +def _flat_values(option: dict) -> list[dict]: + """A select option's values, with any groups flattened.""" + values = [] + for entry in option["options"]: + values.extend(entry["options"] if "group" in entry else [entry]) + return values + + +def current_value(options: list[dict], config_id: str) -> str | None: + option = next((o for o in options if o["id"] == config_id), None) + return option["currentValue"] if option else None + + +def resolve_model(value: str) -> tuple[str, bool]: + """The model name cecli would run for ``value``, and whether a tag override applies.""" + from cecli import models + + name = models.MODEL_ALIASES.get(value, value) + base, overrides = models.ModelOverrides.apply(name) + return (base, True) if overrides else (name, False) + + +def settings_file_models(coder: Any) -> list[str]: + """Models defined in the user's .cecli.model.settings.yml files.""" + from cecli.helpers.file_searcher import generate_search_path_list + + args = getattr(coder, "args", None) + repo = getattr(coder, "repo", None) + fnames = generate_search_path_list( + ".cecli.model.settings.yml", + getattr(repo, "root", None), + getattr(args, "model_settings_file", None), + ) + names = [] + for fname in fnames: + try: + with open(fname, encoding="utf-8") as f: + entries = yaml.safe_load(f) or [] + except (OSError, yaml.YAMLError): + continue + for entry in entries if isinstance(entries, list) else []: + name = entry.get("name") if isinstance(entry, dict) else None + # cecli/extra_params holds settings for every model, not a model + if isinstance(name, str) and not name.endswith("/extra_params"): + names.append(name) + return names + + +def keyed_provider_models() -> list[tuple[str, str, list[str]]]: + """``(slug, name, models)`` for each provider whose API key is set.""" + from cecli.helpers.onboarding.providers import ( + get_models_for_provider, + iter_providers, + provider_has_key, + ) + + groups = [] + for provider in iter_providers(): + if provider_has_key(provider): + models = get_models_for_provider(provider["slug"], modes=CHAT_MODES) + if models: + groups.append((provider["slug"], provider["display_name"], models)) + return groups + + +def matches_filter(name: str, patterns: list[str]) -> bool: + """Whether ``name`` matches any of ``patterns``, as ``/models`` matches its search.""" + name = name.lower() + for pattern in patterns: + pattern = pattern.lower() + if any(char in pattern for char in "*?["): + if fnmatch.fnmatchcase(name, f"*{pattern}*"): + return True + elif pattern in name: + return True + return False + + +class SessionConfig: + """Builds the config options from a coder and maps choices to cecli commands.""" + + def __init__(self): + self.model_choice: str | None = None # how the user last named the model + # (group id, group name, [(value, description, model it runs)]) + self._model_groups: list[tuple[str, str, list[tuple[str, str | None, str]]]] | None = None + self._before_yolo: tuple[Any, Any] | None = None + + def options(self, coder: Any) -> list[dict]: + if coder is None: + return [] + options = [self._mode_option(coder), self._model_option(coder)] + effort = self._effort_option(coder) + if effort is not None: + options.append(effort) + return options + + def command(self, coder: Any, config_id: Any, value: Any) -> str | None: + """The cecli command that applies ``value``, or None if it is already current. + + Raises ValueError for an unknown option or value. + """ + option = next((o for o in self.options(coder) if o["id"] == config_id), None) + if option is None: + raise ValueError(f"Unknown config option: {config_id}") + if value not in {entry["value"] for entry in _flat_values(option)}: + raise ValueError(f"Unknown value for {config_id}: {value}") + if value == option["currentValue"]: + return None + + if config_id == "mode": + yolo = value == YOLO_MODE + # Leaving YOLO, or picking plain Agent, brings the confirmations back + if yolo or value == "agent" or option["currentValue"] == YOLO_MODE: + self._set_yolo(coder, yolo) + target = "agent" if yolo else value + return None if coder.edit_format == target else f"/{target}" + if config_id == "model": + self.model_choice = value + # Agent mode answers with the agent model, which /model leaves alone + command = "/agent-model" if coder.edit_format == "agent" else "/model" + return f"{command} {value}" + if config_id == "reasoning_effort": + return f"/reasoning-effort {'none' if value == 'default' else value}" + return f"/think-tokens {value}" + + @staticmethod + def modes(options: list[dict]) -> dict | None: + """The same choice as the legacy ``modes`` state, for clients without config options.""" + mode = next((o for o in options if o["id"] == "mode"), None) + if mode is None: + return None + return { + "currentModeId": mode["currentValue"], + "availableModes": [ + {"id": v["value"], "name": v["name"], "description": v.get("description")} + for v in mode["options"] + ], + } + + # ── Options ───────────────────────────────────────────────── + + def _set_yolo(self, coder: Any, on: bool) -> None: + """Turn on (or back off) the two settings ``--yolo`` sets at startup.""" + args = coder.args + if on: + if self._before_yolo is None: + self._before_yolo = (coder.io.yes, getattr(args, "yes_always_commands", False)) + coder.io.yes = True + args.yes_always_commands = True + else: + # Launched with --yolo: nothing to restore, so ask again (None asks) + coder.io.yes, args.yes_always_commands = self._before_yolo or (None, False) + self._before_yolo = None + + def _mode_option(self, coder: Any) -> dict: + ids = [mode_id for mode_id, _, _ in MODES] + # Every other edit format (diff, whole, udiff, ...) is code mode + current = coder.edit_format if coder.edit_format in ids else "code" + if self._before_yolo is not None and current != "agent": + # Left agent mode with a typed command; YOLO doesn't outlive it + self._set_yolo(coder, False) + if current == "agent" and getattr(coder.args, "yes_always_commands", False): + current = YOLO_MODE + values = [_value(*mode) for mode in MODES] + return _select("mode", "Mode", "mode", current, values) + + def _model_option(self, coder: Any) -> dict: + # The model that answers in this mode (the agent model in agent mode) + running = coder.get_active_model().name + groups = [] + matches = [] + for group_id, name, entries in self._models(coder): + values = [] + for value, description, model in entries: + values.append(_value(value, description=description)) + if model == running: + matches.append(value) + groups.append((group_id, name, values)) + + if self.model_choice in matches: + current = self.model_choice + elif matches: + current = matches[0] + else: + # Switched with /model to something not listed + current = running + groups[0][2].insert(0, _value(running)) + options = [ + {"group": group_id, "name": name, "options": values} + for group_id, name, values in groups + if values + ] + return _select("model", "Model", "model", current, options) + + def _models(self, coder: Any) -> list[tuple[str, str, list[tuple[str, str | None, str]]]]: + """The models you configured, then those of every provider with an API key set.""" + if self._model_groups is not None: + return self._model_groups + + from cecli import models + + args = getattr(coder, "args", None) + candidates: list[tuple[str, str | None]] = [] + if getattr(args, "model", None): + candidates.append((args.model, None)) + for alias_def in getattr(args, "alias", None) or []: + alias, _, target = alias_def.partition(":") + if alias.strip() and target.strip(): + candidates.append((alias.strip(), target.strip())) + for base, tags in getattr(models.ModelOverrides, "_overrides", {}).items(): + for tag in tags if isinstance(tags, dict) else []: + candidates.append((f"{base}:{tag}", None)) + try: + candidates.extend((name, None) for name in settings_file_models(coder)) + except Exception: + pass + + seen = set() + configured = [] + for value, description in candidates: + name, tagged = resolve_model(value) + key = value if tagged else name + if key in seen: + continue + seen.add(key) + if description is None and name != value and not tagged: + description = name + configured.append((value, description, name)) + self._model_groups = [("configured", "Configured", configured)] + + try: + providers = keyed_provider_models() + except Exception as err: # the configured models still work + log(f"could not list provider models: {err!r}") + providers = [] + patterns = getattr(args, "acp_models", None) or [] + for slug, provider_name, names in providers: + entries = [] + for value in names: + if value in seen or (patterns and not matches_filter(value, patterns)): + continue + seen.add(value) + entries.append((value, None, resolve_model(value)[0])) + if entries: + self._model_groups.append((slug, provider_name, entries)) + return self._model_groups + + def _effort_option(self, coder: Any) -> dict | None: + model = coder.get_active_model() + accepts = getattr(model, "accepts_settings", None) or [] + + effort = model.get_reasoning_effort() + if "reasoning_effort" in accepts or effort is not None: + current = "default" if effort is None else str(effort) + choices = ["default", *REASONING_EFFORTS] + if current not in choices: + choices.append(current) + values = [_value(v, "Default" if v == "default" else v.capitalize()) for v in choices] + return _select("reasoning_effort", "Reasoning effort", "thought_level", current, values) + + budget = model.get_raw_thinking_tokens() + if "thinking_tokens" in accepts or budget is not None: + current = model.get_thinking_tokens() if budget else "0" + choices = ["0", *THINKING_BUDGETS] + if current not in choices: + choices = sorted([*choices, current], key=_token_count) + values = [_value(v, "Off" if v == "0" else f"{v} tokens") for v in choices] + return _select("thinking_tokens", "Thinking budget", "thought_level", current, values) + return None diff --git a/cecli/helpers/server/acp/agent/io.py b/cecli/helpers/server/acp/agent/io.py new file mode 100644 index 00000000000..074b45e2421 --- /dev/null +++ b/cecli/helpers/server/acp/agent/io.py @@ -0,0 +1,201 @@ +"""AcpInputOutput - InputOutput adapter for ``cecli --acp``. + +Prompts come from the ACP client instead of a terminal, confirmations become +``session/request_permission`` requests, and output is translated into +``session/update`` notifications by the agent's UpdateEmitter. +""" + +from io import StringIO + +from rich.console import Console +from rich.text import Text + +from cecli.helpers.server.acp.agent import get_agent +from cecli.helpers.server.acp.agent.transport import log +from cecli.helpers.server.acp.agent.updates import strip_ansi +from cecli.io import InputOutput + + +def _plain(*messages) -> str: + return strip_ansi(" ".join(m.plain if isinstance(m, Text) else str(m) for m in messages)) + + +class AcpInputOutput(InputOutput): + def __init__(self, pretty=False, *args, agent=None, **kwargs): + # Set before InputOutput.__init__, which may already emit warnings + self.agent = agent or get_agent() + # Renders the odd Rich object (tables, panels) that reaches stream_print + self._plain_console = Console( + file=StringIO(), force_terminal=False, no_color=True, highlight=False, width=200 + ) + kwargs["fancy_input"] = False + kwargs["notifications"] = False + kwargs["show_spinner"] = False + super().__init__(False, *args, **kwargs) + self.fallback_spinner_enabled = False + + # ── Input ─────────────────────────────────────────────────── + + async def get_input( + self, + root, + rel_fnames, + addable_rel_fnames, + commands, + abs_read_only_fnames=None, + abs_read_only_stubs_fnames=None, + edit_format=None, + ): + self.agent.emitter.root = root + text = await self.agent.next_input(getattr(commands, "coder", None)) + self.user_input(text) + return text + + async def _confirm_ask( + self, + question, + default="y", + subject=None, + explicit_yes_required=False, + group=None, + group_response=None, + allow_never=False, + allow_tweak=False, + acknowledge=False, + **kwargs, + ): + self.num_user_asks += 1 + question_id = (question, subject) + + if question_id in self.never_prompts: + return False + if group and not group.show_group: + group = None + if group: + allow_never = True + + if self.yes is True and not explicit_yes_required: + res = "y" + elif group_response and group_response in self.group_responses: + return self.group_responses[group_response] + elif group and group.preference: + res = group.preference + else: + options = [{"optionId": "y", "name": "Yes", "kind": "allow_once"}] + if group or group_response: + if not explicit_yes_required or group_response: + options.append({"optionId": "a", "name": "Yes to all", "kind": "allow_always"}) + options.append({"optionId": "s", "name": "Skip all", "kind": "reject_always"}) + options.append({"optionId": "n", "name": "No", "kind": "reject_once"}) + if allow_never and not (group or group_response): + options.append( + {"optionId": "d", "name": "Don't ask again", "kind": "reject_always"} + ) + + choice = await self.agent.ask_permission( + question, subject, options, agent_uuid=kwargs.get("coder_uuid") + ) + if choice is None: + log(f"declined: {question}") + choice = "n" + res = choice + + res = res.lower()[0] + + if res == "d" and allow_never: + self.never_prompts.add(question_id) + self.append_chat_history(f"{question.strip()} {res}", linebreak=True, blockquote=True) + return False + + if explicit_yes_required and not group_response: + is_yes = res == "y" + else: + is_yes = res in ("y", "a") + + is_all = res == "a" and ( + (group is not None and not explicit_yes_required) or group_response + ) + is_skip = res == "s" and (group is not None or group_response) + + if group: + if is_all and not explicit_yes_required: + group.preference = "all" + elif is_skip: + group.preference = "skip" + + self.append_chat_history(f"{question.strip()} {res}", linebreak=True, blockquote=True) + + if group_response and (is_all or is_skip): + self.group_responses[group_response] = is_yes + + return is_yes + + def prompt_ask(self, question, default="", subject=None): + # Free-form answers have no ACP equivalent; take the default + log(f"using default for prompt: {question} -> {default!r}") + self.append_chat_history(f"{question.strip()} {default}", linebreak=True, blockquote=True) + return default + + # ── Output ────────────────────────────────────────────────── + + def tool_output(self, *messages, log_only=False, bold=False, type=None, **kwargs): + super().tool_output(*messages, log_only=True) + # The per-request token/cost line stays in the chat history only + if log_only or type == "usage-report": + return + + emitter = self.agent.emitter_for(kwargs.get("coder_uuid")) + if type == "Tool Call": + emitter.tool_header(_plain(*messages)) + elif type == "tool-footer": + emitter.tool_footer() + elif messages: + emitter.block(_plain(*messages)) + + def _tool_message(self, message="", strip=True, color=None, **kwargs): + if message.strip(): + if "\n" in message: + for line in message.splitlines(): + self.append_chat_history(line, linebreak=True, blockquote=True, strip=strip) + else: + hist = message.strip() if strip else message + self.append_chat_history(hist, linebreak=True, blockquote=True) + self.agent.emitter_for(kwargs.get("coder_uuid")).block(_plain(message)) + + def assistant_output(self, message, pretty=None, **kwargs): + emitter = self.agent.emitter_for(kwargs.get("coder_uuid")) + emitter.stream(_plain(message or ""), final=True) + + def stream_output(self, text, final=False, **kwargs): + emitter = self.agent.emitter_for(kwargs.get("coder_uuid")) + emitter.stream(strip_ansi(text or ""), final=final) + + def reset_streaming_response(self, **kwargs): + pass + + def stream_print(self, *messages, **kwargs): + console = self._plain_console + with console.capture() as capture: + console.print(*messages, markup=False, soft_wrap=True) + self.agent.emitter_for(kwargs.get("coder_uuid")).block(strip_ansi(capture.get())) + + def tool_call_event(self, tool_call_id, status, coder_uuid=None, **kwargs): + self.agent.emitter_for(coder_uuid).tool_event(tool_call_id, status, **kwargs) + + def subagent_event(self, agent_uuid, event, **kwargs): + self.agent.subagent_event(agent_uuid, event, **kwargs) + + def display_user_input(self, inp): + # The client already shows what the user sent + pass + + # ── Terminal-only behavior ────────────────────────────────── + + def rule(self): + pass + + def ring_bell(self): + pass + + def notify_user_input_required(self): + pass diff --git a/cecli/helpers/server/acp/agent/mcp.py b/cecli/helpers/server/acp/agent/mcp.py new file mode 100644 index 00000000000..8210cabc434 --- /dev/null +++ b/cecli/helpers/server/acp/agent/mcp.py @@ -0,0 +1,94 @@ +"""MCP servers passed by the ACP client in ``session/new``. + +Editors hand the agent the MCP servers they have configured (Zed's context +servers) so it can use the same tools. They join cecli's own servers in the +shared McpServerManager, as if they were listed in an MCP config file. +""" + +from __future__ import annotations + +from typing import Any, Callable + + +def _pairs(items: Any) -> dict[str, str]: + """ACP ``[{name, value}]`` lists (env vars, headers) as a dict.""" + pairs = {} + for item in items if isinstance(items, list) else []: + if isinstance(item, dict) and isinstance(item.get("name"), str): + pairs[item["name"]] = str(item.get("value", "")) + return pairs + + +def to_cecli_configs(mcp_servers: Any) -> dict[str, dict]: + """ACP McpServer entries as cecli MCP server configs, keyed by lowercased name.""" + configs = {} + for entry in mcp_servers if isinstance(mcp_servers, list) else []: + if not isinstance(entry, dict) or not entry.get("name"): + continue + transport = entry.get("type", "stdio") + if transport == "stdio" and entry.get("command"): + config = { + "transport": "stdio", + "command": entry["command"], + "args": [str(arg) for arg in entry.get("args") or []], + "env": _pairs(entry.get("env")), + } + elif transport in ("http", "sse") and entry.get("url"): + config = { + "transport": transport, + "url": entry["url"], + "headers": _pairs(entry.get("headers")), + } + else: + continue + config["name"] = str(entry["name"]) + configs[config["name"].lower()] = config + return configs + + +def _make_server(config: dict, io: Any, verbose: bool) -> Any: + from cecli.mcp.server import HttpStreamingServer, McpServer, SseServer + + server_class = {"stdio": McpServer, "http": HttpStreamingServer, "sse": SseServer} + return server_class[config["transport"]](dict(config), io=io, verbose=verbose) + + +class ClientMcpServers: + """Keeps the manager's client-provided servers in line with the latest session.""" + + def __init__(self, log: Callable[[str], None]): + self._log = log + self._wanted: dict[str, dict] | None = None + self._ours: dict[str, dict] = {} # servers this class added, by name + + def want(self, mcp_servers: Any) -> None: + """Record a session's servers; ``sync`` applies them on the coder's loop.""" + self._wanted = to_cecli_configs(mcp_servers) + + @property + def pending(self) -> bool: + return self._wanted is not None + + async def sync(self, manager: Any, io: Any = None, verbose: bool = False) -> None: + wanted, self._wanted = self._wanted, None + if wanted is None or manager is None: + return + + for name in set(self._ours) - set(wanted): + # No longer offered by the client (a later session dropped it) + await manager.disconnect_server(name) + + for name, config in wanted.items(): + server = manager.get_server(name) + if server is None: + self._ours[name] = config + if await manager.add_server(_make_server(config, io, verbose), connect=True): + self._log(f"connected MCP server from the client: {config['name']}") + continue + if name not in self._ours: + self._log(f"skipped the client's MCP server {name!r}: cecli has one by that name") + continue + if config != self._ours[name]: + self._log(f"the client changed MCP server {name!r}; restart the agent to apply it") + if server not in manager.connected_servers: + await manager.connect_server(name) diff --git a/cecli/helpers/server/acp/agent/transport.py b/cecli/helpers/server/acp/agent/transport.py new file mode 100644 index 00000000000..daa559b412b --- /dev/null +++ b/cecli/helpers/server/acp/agent/transport.py @@ -0,0 +1,158 @@ +"""How AcpAgent exchanges JSON-RPC messages with the client. + +The agent only needs the small ``Transport`` interface below. ``StdioTransport`` +implements it for clients that launch cecli as a subprocess (``cecli --acp``); +another transport, such as a WebSocket connection, can drive the same agent. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import threading +from typing import Any, Protocol + +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + +# Returned by ``Transport.receive`` once the client has gone away +EOF = object() + + +def log(message: str) -> None: + """Diagnostics go to stderr; stdout belongs to the protocol.""" + print(f"[cecli acp] {message}", file=sys.stderr, flush=True) + + +def result_message(request_id: Any, result: Any) -> dict: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def error_message(request_id: Any, code: int, message: str) -> dict: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +class Transport(Protocol): + """A connection to one ACP client, carrying JSON-RPC messages as dicts.""" + + def open(self) -> None: + """Start receiving messages from the client.""" + + def attach(self, loop: asyncio.AbstractEventLoop) -> None: + """Deliver incoming messages to ``loop`` (called from that loop).""" + + def detach(self) -> None: + """Stop delivering to the current loop, keeping unread messages for the next.""" + + async def receive(self) -> Any: + """The next message from the client, or ``EOF`` once it has gone away.""" + + def send(self, msg: dict) -> None: + """Send a message to the client (safe to call from any thread).""" + + +class StdioTransport: + """Newline-delimited JSON-RPC on the process's original stdin/stdout. + + ``open()`` moves the protocol onto private duplicates of fds 0 and 1, + then points fd 0 at /dev/null and fd 1 at stderr, so stray prints, Rich + consoles and child processes can neither corrupt nor consume the stream. + + A reader thread hands messages to whichever event loop is attached; + messages that arrive while no loop is attached wait in a backlog. + """ + + def __init__(self): + self._lock = threading.Lock() + self._write_lock = threading.Lock() + self._out = None + self._loop: asyncio.AbstractEventLoop | None = None + self._inbox: asyncio.Queue | None = None + self._backlog: list[Any] = [] + + def open(self) -> None: + sys.stdout.flush() + in_fd = os.dup(0) + out_fd = os.dup(1) + devnull = os.open(os.devnull, os.O_RDONLY) + os.dup2(devnull, 0) + os.close(devnull) + os.dup2(2, 1) + self.start(os.fdopen(in_fd, "rb"), os.fdopen(out_fd, "wb")) + + def start(self, reader, writer) -> None: + self._out = writer + thread = threading.Thread( + target=self._read_loop, args=(reader,), name="cecli-acp-stdin", daemon=True + ) + thread.start() + + def _read_loop(self, reader) -> None: + for raw in reader: + line = raw.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError as err: + self.send(error_message(None, PARSE_ERROR, f"Parse error: {err}")) + continue + if not isinstance(msg, dict): + self.send(error_message(None, INVALID_REQUEST, "Invalid Request")) + continue + self._deliver(msg) + self._deliver(EOF) + + def _deliver(self, msg: Any) -> None: + with self._lock: + loop = self._loop + if loop is not None: + try: + loop.call_soon_threadsafe(self._enqueue, msg) + return + except RuntimeError: # loop closed under us + self._loop = None + self._inbox = None + self._backlog.append(msg) + + def _enqueue(self, msg: Any) -> None: + with self._lock: + if self._inbox is not None: + self._inbox.put_nowait(msg) + else: + self._backlog.append(msg) + + def attach(self, loop: asyncio.AbstractEventLoop) -> None: + with self._lock: + self._loop = loop + self._inbox = asyncio.Queue() + for msg in self._backlog: + self._inbox.put_nowait(msg) + self._backlog.clear() + + def detach(self) -> None: + with self._lock: + if self._inbox is not None: + while not self._inbox.empty(): + self._backlog.append(self._inbox.get_nowait()) + self._loop = None + self._inbox = None + + async def receive(self) -> Any: + return await self._inbox.get() + + def send(self, msg: dict) -> None: + data = json.dumps(msg, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + with self._write_lock: + if self._out is None: + return + try: + self._out.write(data + b"\n") + self._out.flush() + except (OSError, ValueError) as err: + log(f"failed to write to client: {err}") diff --git a/cecli/helpers/server/acp/agent/updates.py b/cecli/helpers/server/acp/agent/updates.py new file mode 100644 index 00000000000..520c2ae3bd0 --- /dev/null +++ b/cecli/helpers/server/acp/agent/updates.py @@ -0,0 +1,463 @@ +"""Translate cecli's console-oriented output into ACP v1 ``session/update`` payloads. + +cecli reports progress as a stream of text: model tokens (with reasoning wrapped +in REASONING_START/REASONING_END markers), ``tool_output`` lines, and tool call +panels printed as header/body/footer. ``UpdateEmitter`` keeps just enough state +to turn that stream into the structured updates an ACP client renders: +message and thought chunks, plus ``tool_call``/``tool_call_update`` cards driven +by ``InputOutput.tool_call_event``. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any, Callable + +from cecli.helpers.responses import unprefix_tool_name +from cecli.reasoning_tags import REASONING_END, REASONING_START + +# Longest tool output (in characters) mirrored into a tool call card +MAX_TOOL_TEXT = 20_000 + +# Wraps cecli's own console lines; longer than a typical ``` so code in them survives +CONSOLE_FENCE = "````" + +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") + +# Normalized cecli tool name -> ACP ToolKind +_TOOL_KINDS = { + "readfile": "read", + "ls": "read", + "gitstatus": "read", + "gitlog": "read", + "gitshow": "read", + "gitdiff": "read", + "gitbranch": "read", + "gitremote": "read", + "grep": "search", + "explorecode": "search", + "searchfacts": "search", + "editfile": "edit", + "undochange": "edit", + "replacefacts": "edit", + # Not "execute": Zed treats execute calls as live terminals and hides its + # progress spinner while one runs, but cecli runs commands itself + "command": "other", + "thinking": "think", +} + +# Argument keys worth showing in a tool call's title, in priority order +_TITLE_KEYS = ("command", "file_path", "path", "pattern", "query", "url", "symbol", "name") +_PATH_KEYS = ("file_path", "path") + + +def strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def tool_kind(name: str) -> str: + norm = re.sub(r"[^a-z0-9]", "", name.lower()) + if norm in _TOOL_KINDS: + return _TOOL_KINDS[norm] + if "fetch" in norm or "web" in norm or "scrape" in norm: + return "fetch" + if "search" in norm or "grep" in norm or "find" in norm: + return "search" + if "read" in norm or "view" in norm or norm.startswith("list"): + return "read" + if "delete" in norm or "remove" in norm: + return "delete" + if "edit" in norm or "write" in norm or "replace" in norm or "create" in norm: + return "edit" + return "other" + + +def parse_arguments(arguments: Any) -> dict: + if isinstance(arguments, dict): + return arguments + if not arguments: + return {} + try: + parsed = json.loads(arguments) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def tool_title(name: str, args: dict) -> str: + for key in _TITLE_KEYS: + value = args.get(key) + if isinstance(value, str) and value.strip(): + first = value.strip().splitlines()[0] + if len(first) > 120: + first = first[:117] + "..." + return f"{name}: {first}" + return name + + +def _fenced(text: str) -> str: + if len(text) > MAX_TOOL_TEXT: + text = "...(truncated)\n" + text[-MAX_TOOL_TEXT:] + fence = "```" + while fence in text: + fence += "`" + return f"{fence}\n{text.rstrip()}\n{fence}" + + +def _partial_suffix(text: str, marker: str) -> int: + """Length of the longest suffix of ``text`` that is a proper prefix of ``marker``.""" + for size in range(min(len(text), len(marker) - 1), 0, -1): + if marker.startswith(text[-size:]): + return size + return 0 + + +def plan_entries(tasks: Any) -> list[dict]: + """UpdateTodoList's ``tasks`` argument as ACP plan entries.""" + entries = [] + for task in tasks if isinstance(tasks, list) else []: + if isinstance(task, dict): + content = str(task.get("task", "")).strip() + if task.get("done"): + status = "completed" + elif task.get("current"): + status = "in_progress" + else: + status = "pending" + else: + content, status = str(task).strip(), "pending" + if content: + entries.append({"content": content, "priority": "medium", "status": status}) + return entries + + +@dataclass +class _Tool: + id: str + title: str + kind: str + status: str = "pending" + output: list[str] = field(default_factory=list) + synthetic: bool = False + # Shown as an agent message once the tool runs (Yield's final answer) + message: str | None = None + + +class UpdateEmitter: + """Stateful translator from cecli output to ``session/update`` payloads. + + ``send`` receives each update dict (the value of the notification's + ``update`` field). State is per turn; call ``finish_turn`` when it ends. + """ + + def __init__(self, send: Callable[[dict], None]): + self._send = send + self.root = os.getcwd() + self._synthetic_count = 0 + self.reset() + + def reset(self) -> None: + self._thinking = False + self._held = "" + self._pending_ws = "" + self._msg_tail = "" + self._console_open = False + self._tools: dict[str, _Tool] = {} + self._active: str | None = None + self._in_panel = False + + # ── Model text ────────────────────────────────────────────── + + def stream(self, text: str, final: bool = False) -> None: + """Model output; reasoning between the markers becomes thought chunks.""" + if text: + self._close_console() + self._settle_tools() + + buf = self._held + (text or "") + self._held = "" + while buf: + marker = REASONING_END if self._thinking else REASONING_START + idx = buf.find(marker) + if idx >= 0: + self._emit_text(buf[:idx]) + buf = buf[idx + len(marker) :] + self._thinking = not self._thinking + continue + keep = 0 if final else _partial_suffix(buf, marker) + self._emit_text(buf[: len(buf) - keep]) + self._held = buf[len(buf) - keep :] + break + + if final: + self._thinking = False + + def block(self, text: str) -> None: + """A line of cecli's own output (tool_output, warnings, errors). + + Consecutive lines stream into one monospace block, so tables and + aligned output survive markdown rendering. + """ + if self._in_panel: + # Tool panel body: the arguments already travel as rawInput + return + if not text or not text.strip(): + return + if self._active is not None: + self._tool_output(self._active, text) + return + + self._flush_held() + prefix = "" + if not self._console_open: + tail = self._msg_tail + if not tail or tail.endswith("\n\n"): + prefix = CONSOLE_FENCE + "\n" + elif tail.endswith("\n"): + prefix = "\n" + CONSOLE_FENCE + "\n" + else: + prefix = "\n\n" + CONSOLE_FENCE + "\n" + self._console_open = True + self._emit_message(prefix + text.strip("\n") + "\n") + + # ── Tool calls ────────────────────────────────────────────── + + def tool_event(self, tool_call_id: str, status: str, **kwargs: Any) -> None: + if not tool_call_id: + return + self._close_console() + if status == "pending": + self._announce(tool_call_id, kwargs.get("name") or "tool", kwargs.get("arguments")) + elif status == "in_progress": + if self._active not in (None, tool_call_id): + self._set_status(self._active, "completed") + if tool_call_id not in self._tools: + self._announce(tool_call_id, kwargs.get("name") or "tool", kwargs.get("arguments")) + self._active = tool_call_id + self._set_status(tool_call_id, "in_progress") + elif status in ("completed", "failed"): + tool = self._tools.get(tool_call_id) + if tool is None: + return + result = kwargs.get("result") + if result and not tool.output: + # Nothing was printed while it ran; show what the model will see + tool.output.append(str(result)) + self._update(tool, content=self._content(tool)) + if self._active == tool_call_id: + self._active = None + if tool.status not in ("completed", "failed"): + self._set_status(tool_call_id, status) + + def tool_header(self, text: str) -> None: + """Start of a printed tool panel (``type="Tool Call"``).""" + self._in_panel = True + + def tool_footer(self) -> None: + self._in_panel = False + + def permission_target(self, question: str, subject: str | None) -> tuple[str, bool]: + """Pick the tool call a confirmation is about, creating one if needed. + + Returns ``(tool_call_id, synthetic)``. + """ + if self._active is not None: + return self._active, False + + pending = [t for t in self._tools.values() if t.status == "pending" and not t.synthetic] + if len(pending) == 1: + return pending[0].id, False + + self._close_console() + self._synthetic_count += 1 + tool_id = f"cecli-confirm-{self._synthetic_count}" + lowered = question.lower() + if "command" in lowered or lowered.startswith("run"): + kind = "execute" + elif "edit" in lowered or "create" in lowered or "apply" in lowered: + kind = "edit" + else: + kind = "other" + + tool = _Tool(id=tool_id, title=question.strip(), kind=kind, synthetic=True) + if subject: + tool.output.append(str(subject)) + elif pending: + tool.output.append("\n".join(t.title for t in pending)) + self._tools[tool_id] = tool + + update = { + "sessionUpdate": "tool_call", + "toolCallId": tool_id, + "title": tool.title, + "kind": kind, + "status": "pending", + } + if tool.output: + update["content"] = self._content(tool) + self._send(update) + return tool_id, True + + def permission_prompt(self, tool_call_id: str, question: str) -> dict: + """The ``toolCall`` of a permission request, showing the question above the options. + + A tool can be asked about more than once ("Run tools?", then "Allow + execution of this command?"), so the question goes in the card's content + until ``permission_resolved`` puts the tool's own content back. + """ + tool = self._tools.get(tool_call_id) + if tool is None or tool.synthetic: + return {"toolCallId": tool_call_id} # its title is already the question + question_text = {"type": "text", "text": question.strip()} + return { + "toolCallId": tool_call_id, + "content": [{"type": "content", "content": question_text}], + } + + def permission_resolved(self, tool_call_id: str, synthetic: bool, allowed: bool) -> None: + tool = self._tools.get(tool_call_id) + if tool is not None and not synthetic: + self._update(tool, content=self._content(tool) if tool.output else []) + if allowed: + if synthetic: + self._set_status(tool_call_id, "completed") + return + + # Rejected: the tool (or every tool waiting behind a group prompt) won't run + targets = [tool_call_id] + if synthetic: + targets += [ + t.id for t in self._tools.values() if t.status == "pending" and not t.synthetic + ] + for target in targets: + self._set_status(target, "failed") + + # ── Turn boundaries ───────────────────────────────────────── + + def finish_turn(self, cancelled: bool = False) -> None: + self._flush_held() + self._close_console() + final_status = "failed" if cancelled else "completed" + for tool in self._tools.values(): + if tool.status == "pending": + # Announced but never started (the confirmation was skipped) + self._set_status(tool.id, "failed") + elif tool.status not in ("completed", "failed"): + self._set_status(tool.id, final_status) + self.reset() + + # ── Internals ─────────────────────────────────────────────── + + def _announce(self, tool_call_id: str, name: str, arguments: Any) -> None: + _server, short_name = unprefix_tool_name(name) + args = parse_arguments(arguments) + tool = _Tool( + id=tool_call_id, title=tool_title(short_name, args), kind=tool_kind(short_name) + ) + self._tools[tool_call_id] = tool + + norm = re.sub(r"[^a-z0-9]", "", short_name.lower()) + if norm == "updatetodolist": + tool.title, tool.kind = "Update plan", "think" + entries = plan_entries(args.get("tasks")) + if entries: + self._send({"sessionUpdate": "plan", "entries": entries}) + elif norm == "yield" and isinstance(args.get("summary"), str): + tool.title = "Finish" + tool.message = args["summary"] + + update = { + "sessionUpdate": "tool_call", + "toolCallId": tool_call_id, + "title": tool.title, + "kind": tool.kind, + "status": "pending", + "rawInput": args, + } + locations = self._locations(args) + if locations: + update["locations"] = locations + self._send(update) + + def _locations(self, args: dict) -> list[dict]: + for key in _PATH_KEYS: + value = args.get(key) + if isinstance(value, str) and value.strip(): + path = value.strip() + if not os.path.isabs(path): + path = os.path.join(self.root, path) + # ACP paths are slash-separated on every platform; normpath() + # alone would emit backslashes on Windows. + return [{"path": os.path.normpath(path).replace(os.sep, "/")}] + return [] + + def _tool_output(self, tool_call_id: str, text: str) -> None: + tool = self._tools.get(tool_call_id) + if tool is None: + return + tool.output.append(text.rstrip("\n")) + self._update(tool, content=self._content(tool)) + + def _content(self, tool: _Tool) -> list[dict]: + text = "\n".join(tool.output) + return [{"type": "content", "content": {"type": "text", "text": _fenced(text)}}] + + def _set_status(self, tool_call_id: str, status: str) -> None: + tool = self._tools.get(tool_call_id) + if tool is None or tool.status == status: + return + tool.status = status + self._update(tool, status=status) + if status == "completed" and tool.message: + message, tool.message = tool.message, None + self._close_console() + tail = self._msg_tail + prefix = "" if not tail or tail.endswith("\n\n") else "\n\n" + self._emit_message(prefix + message.strip() + "\n\n") + + def _update(self, tool: _Tool, **fields: Any) -> None: + self._send({"sessionUpdate": "tool_call_update", "toolCallId": tool.id, **fields}) + + def _settle_tools(self) -> None: + """The model is talking again, so tools that already ran are done.""" + if self._active is not None: + self._set_status(self._active, "completed") + self._active = None + for tool in self._tools.values(): + if tool.status == "in_progress": + self._set_status(tool.id, "completed") + + def _close_console(self) -> None: + if self._console_open: + self._console_open = False + self._emit_message(CONSOLE_FENCE + "\n\n") + + def _flush_held(self) -> None: + if self._held: + held, self._held = self._held, "" + self._emit_text(held) + + def _emit_text(self, text: str) -> None: + if not text: + return + if self._thinking: + # Whitespace before a thought (e.g. the newline ahead of a reasoning + # marker) would otherwise surface as an empty message + self._pending_ws = "" + self._send( + {"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": text}} + ) + elif not text.strip(): + self._pending_ws += text + else: + self._emit_message(text) + + def _emit_message(self, text: str) -> None: + text, self._pending_ws = self._pending_ws + text, "" + self._msg_tail = (self._msg_tail + text)[-2:] + self._send( + {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}} + ) diff --git a/cecli/helpers/workflows/config.py b/cecli/helpers/workflows/config.py index 3b142767c7a..8b49b955744 100644 --- a/cecli/helpers/workflows/config.py +++ b/cecli/helpers/workflows/config.py @@ -10,8 +10,10 @@ ``None``, ``''``, ``'{}'``, ``'[]'`` and ``'null'`` count as *absent* so that lower-precedence sources still load. 2. ``/.cecli.workflows.yml`` / ``.cecli.workflows.yaml``. -3. ``merged_config["workflows"]`` — the deep-merged ``.cecli.conf.yml`` value. -4. ``~/.cecli/workflows/*.yml`` / ``*.yaml`` — globbed in stable sorted order. +3. ``/.cecli/workflows/*.yml`` / ``*.yaml`` — globbed in stable sorted order. +4. ``/.agents/workflows/*.yml`` / ``*.yaml`` — globbed in stable sorted order. +5. ``merged_config["workflows"]`` — the deep-merged ``.cecli.conf.yml`` value. +6. ``~/.cecli/workflows/*.yml`` / ``*.yaml`` — globbed in stable sorted order. Each source may hold a single workflow mapping or a ``workflows: [...]`` list. On a duplicate ``name`` the higher-precedence definition wins and the loser is @@ -41,6 +43,8 @@ WORKFLOW_DIR = Path(".cecli") / "workflows" +AGENTS_WORKFLOW_DIR = Path(".agents") / "workflows" + # Values argparse/deep-merge normalization leaves behind when nothing was set. _ABSENT_STRINGS = ("", "{}", "[]", "null", "~") @@ -93,6 +97,8 @@ def resolve_workflow_configs( ordered += _entries_from_arg(str(config_arg)) ordered += _entries_from_local(git_root) + ordered += _entries_from_local_dir(git_root) + ordered += _entries_from_local_agents_dir(git_root) if isinstance(merged_config, dict): ordered += _entries(merged_config.get("workflows"), None, _CONFIG_LABEL) @@ -289,9 +295,28 @@ def _entries_from_local(git_root: Optional[Any]) -> List[_Discovered]: return [] +def _entries_from_local_dir(git_root: Optional[Any]) -> List[_Discovered]: + """Resolve a repo-local ``.cecli/workflows/*.yml|*.yaml`` directory.""" + base = Path(git_root).expanduser() if git_root else Path.cwd() + + return _entries_from_dir(base / WORKFLOW_DIR) + + +def _entries_from_local_agents_dir(git_root: Optional[Any]) -> List[_Discovered]: + """Resolve a repo-local ``.agents/workflows/*.yml|*.yaml`` directory.""" + base = Path(git_root).expanduser() if git_root else Path.cwd() + + return _entries_from_dir(base / AGENTS_WORKFLOW_DIR) + + def _entries_from_home() -> List[_Discovered]: """Resolve ``~/.cecli/workflows/*.yml|*.yaml`` in stable sorted order.""" - root = Path.home() / WORKFLOW_DIR + + return _entries_from_dir(Path.home() / WORKFLOW_DIR) + + +def _entries_from_dir(root: Path) -> List[_Discovered]: + """Resolve ``*.yml`` / ``*.yaml`` files under *root* in stable sorted order.""" if not root.is_dir(): return [] diff --git a/cecli/helpers/workflows/runner.py b/cecli/helpers/workflows/runner.py index 81aa02a1bc0..2cdbc815f13 100644 --- a/cecli/helpers/workflows/runner.py +++ b/cecli/helpers/workflows/runner.py @@ -81,6 +81,9 @@ async def execute_workflow( for text in texts[1:]: command_queue.enqueue_prompt(worker, text) + # The first task is started directly, so make sure a command task also + # drains the staged queue instead of stopping after the command. + worker._drain_queue_after_command = True service.start_generate_task(info, texts[0]) outcome, detail = await _await_run(info) diff --git a/cecli/io.py b/cecli/io.py index c696e4e1c33..58829863a4a 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -265,7 +265,17 @@ def get_command_completions(self, document, complete_event, text, words): yield from raw_completer(document, complete_event) return - candidates = self.commands.get_completions(cmd, partial) + if cmd in self.commands.FULL_ARGS_COMPLETION_COMMANDS: + nested_text = text[len(words[0]) :].lstrip() + candidates = self.commands.get_completions(cmd, nested_text) + # The substring filter and start_position below operate on the last + # word; an empty/trailing-space nested text means "no partial yet". + if not nested_text or nested_text[-1].isspace(): + partial = "" + else: + partial = nested_text.split()[-1].lower() + else: + candidates = self.commands.get_completions(cmd, partial) if candidates is None: return @@ -1569,6 +1579,29 @@ def tool_output(self, *messages, log_only=False, bold=False, type=None, **kwargs self.stream_print(*messages, style=style, **kwargs) + def tool_call_event(self, tool_call_id, status, **kwargs): + """Structured tool call progress, for front ends that render tool calls natively. + + ``status`` is "pending" when a call is announced (with ``name`` and + ``arguments``), "in_progress" when it starts running, and "completed" + once its ``result`` is known. Terminal output already shows tool panels, + so this is a no-op here; the ACP adapter overrides it. + """ + pass + + def subagent_event(self, agent_uuid, event, **kwargs): + """Sub-agent lifecycle, for front ends that show each sub-agent separately. + + ``event`` is "created" (with ``parent_uuid``, ``name``, ``description`` + and ``root``), "running" when the sub-agent starts working, "idle" when + it stops (with ``stop_reason``: "end_turn", "cancelled" or "error", and + ``error``), "message" when an agent sends it a message (with + ``sender_uuid`` and ``text``), and "removed" once it is reaped. Output + already carries each agent's ``coder_uuid``, so this is a no-op here; + the ACP adapter overrides it. + """ + pass + def escape(self, text): """Formats valid Rich tags and prints invalid ones as literal text using a single regex pass.""" diff --git a/cecli/main.py b/cecli/main.py index 450ad919f34..42554098805 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -173,6 +173,7 @@ def convert_yaml_to_json_string(value, config_file_value=None): "system_one": "system-one", "server_config": "server-config", "workflows": "workflows", + "scheduler": "scheduler", } @@ -581,6 +582,21 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F attach_debug_thread_monitor(argv) + if "--acp" in (sys.argv[1:] if argv is None else argv): + # From here on stdout carries the protocol. The client's first + # session/new picks the project, so move there before any setup runs. + import atexit + + from cecli.helpers.server.acp.agent import get_agent + + acp_agent = get_agent() + cwd = acp_agent.bootstrap() + if cwd is None: + return 0 + os.chdir(cwd) + # Answer anything still waiting (e.g. a failed startup) on the way out + atexit.register(acp_agent.shutdown) + # Tracks the coder instance from a ReloadProgramSignal so the new # main_async() can pass it as from_coder to Coder.create(), preserving # UUID, edit_format, and other state across the reload cycle. @@ -722,6 +738,22 @@ async def main_async( args, unknown = parser.parse_known_args(argv) os.unlink(_tmp_cfg) + if args.acp: + from cecli.helpers.server.acp.agent import get_agent + + if not get_agent().launched: + # Set by a config file or CECLI_ACP, too late: the client's first + # session/new has to pick the project before any config is read + print("Ignoring the acp setting: pass --acp on the command line", file=sys.stderr) + args.acp = False + + if args.acp: + # The ACP client owns stdin/stdout: plain linear output, no TUI or prompts + args.tui = False + args.linear_output = True + args.fancy_input = False + args.pretty = False + if args.workspaces or args.workspace_name: from cecli.helpers.workspaces.config import ( find_active_workspace_name, @@ -867,8 +899,14 @@ async def main_async( args.yes_always = True editing_mode = "VI" if args.vim else "EMACS" + io_class = InputOutput + if args.acp: + from cecli.helpers.server.acp.agent.io import AcpInputOutput + + io_class = AcpInputOutput + def get_io(pretty): - return InputOutput( + return io_class( pretty, args.yes_always, args.input_history_file, @@ -936,6 +974,10 @@ def get_io(pretty): raise err io = get_io(False) io.tool_warning("Terminal does not support pretty output (UnicodeDecodeError)") + if args.acp: + from cecli.helpers.server.acp.agent import get_agent + + get_agent().start() if args.set_env: for env_setting in args.set_env: try: @@ -1212,6 +1254,16 @@ def get_io(pretty): lint_cmds = parse_lint_cmds(args.lint_cmd, io) if lint_cmds is None: return await graceful_exit(None, 1) + + # Create/attach the git repo before the coder is built. The coder resolves + # its repo, root, and repo_map at construction; if the repo is created + # afterwards the coder keeps repo=None for the whole session and + # repo-backed tools (Grep, git_*, ...) fail with "Not in a git repository." + if args.git and not (suppress_pre_init or args.workspaces or args.workspace_name): + git_root = await setup_git(git_root, pre_init_io) + if args.gitignore: + await check_gitignore(git_root, pre_init_io) + repo = None if args.git: try: @@ -1382,10 +1434,6 @@ def get_io(pretty): pre_init_io.tool_output() except KeyboardInterrupt: return await graceful_exit(coder, 1) - if args.git and not (suppress_pre_init or args.workspaces or args.workspace_name): - git_root = await setup_git(git_root, pre_init_io) - if args.gitignore: - await check_gitignore(git_root, pre_init_io) except UnknownEditFormat as err: pre_init_io.tool_error(str(err)) await pre_init_io.offer_url( @@ -1539,17 +1587,17 @@ def get_io(pretty): # --message/--exit paths above return before the scheduler can tick. # Auto mode turns it on iff at least one enabled scheduled workflow # exists, so nothing ever touches the coordination DB otherwise. - _scheduler_cfg = merged_config.get("scheduler") or {} - if _workflows and _scheduler_enabled(args, _scheduler_cfg, _workflows): + _scheduler_cfg = _scheduler_config(args) + if _workflows and _scheduler_enabled(_scheduler_cfg, _workflows): from cecli.helpers.scheduler import SchedulerService coder.scheduler = SchedulerService( coder, _workflows, io, - tick_seconds=_scheduler_cfg.get("tick_seconds", 60), - catch_up=_scheduler_cfg.get("catch_up", True), - max_catchup=_scheduler_cfg.get("max_catchup", 1), + tick_seconds=_scheduler_cfg["tick_seconds"], + catch_up=_scheduler_cfg["catch_up"], + max_catchup=_scheduler_cfg["max_catchup"], ) await coder.scheduler.start() @@ -1685,18 +1733,46 @@ def get_io(pretty): raise -def _scheduler_enabled(args, scheduler_cfg, workflows): - """Resolve whether the scheduler should run: CLI flag > config > auto.""" - - if getattr(args, "scheduler", None) is not None: - return bool(args.scheduler) +def _scheduler_enabled(scheduler_cfg, workflows): + """Resolve whether the scheduler should run: explicit ``enabled`` > auto.""" - if scheduler_cfg.get("enabled", None) is not None: - return bool(scheduler_cfg.get("enabled")) + if scheduler_cfg.get("enabled") is not None: + return bool(scheduler_cfg["enabled"]) return any(getattr(wf, "schedule", None) and getattr(wf, "enabled", True) for wf in workflows) +def _scheduler_config(args): + """Resolve the ``scheduler`` config object, defaults layered underneath. + + ``args.scheduler`` is a JSON string (the CLI ``--scheduler`` value deep-merged + over the config-file object) or ``None`` when unset, in which case the + documented defaults apply and ``enabled`` stays ``None`` (auto). + """ + + defaults = { + "enabled": None, + "tick_seconds": 60, + "catch_up": True, + "max_catchup": 1, + } + + raw = getattr(args, "scheduler", None) + + if not raw: + return dict(defaults) + + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return dict(defaults) + + if not isinstance(parsed, dict): + return dict(defaults) + + return {**defaults, **parsed} + + async def _stop_scheduler_quietly(coder): """Best-effort scheduler shutdown used by reload/exit paths.""" diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 2bfa2472c99..feaf905962a 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -153,7 +153,8 @@ async def execute(cls, coder, **kwargs): # Retrieve exceptions from completed sub-agent tasks so they # are not silently lost. for t in done: - if t is not interrupt_task: + # A cancelled sub-agent has no exception, and asking raises + if t is not interrupt_task and not t.cancelled(): exc = t.exception() if exc: logger.warning("Sub-agent task raised an exception: %s", exc) diff --git a/cecli/tools/broadcast.py b/cecli/tools/broadcast.py index 8792d420728..e886af5a299 100644 --- a/cecli/tools/broadcast.py +++ b/cecli/tools/broadcast.py @@ -225,6 +225,10 @@ def _deliver(info, message, agent_service, sender_coder): sender_name = agent_service.get_agent_name(sender_coder) or "primary" sender_uuid = str(sender_coder.uuid) + target_uuid = info.coder.uuid if isinstance(info, SubAgentInfo) else info.uuid + sender_coder.io.subagent_event( + str(target_uuid), "message", sender_uuid=sender_uuid, text=message + ) message = ( f"\n" f"{message}\n\n" diff --git a/cecli/tui/app.py b/cecli/tui/app.py index d5ca44c35a8..79ea0175e71 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -2,6 +2,7 @@ import concurrent.futures import json +import logging import platform import queue import time @@ -41,6 +42,8 @@ if platform.system() == "Windows": IS_WINDOWS = True +logger = logging.getLogger(__name__) + class TUI(App): """Main Textual application for cecli TUI.""" @@ -1938,9 +1941,18 @@ def _get_suggestions(self, text: str) -> list[str]: else: # Use standard command completions (no file fallback) try: - cmd_completions = commands.get_completions( - cmd_name, args=arg_prefix, coder=active_coder - ) + if cmd_name in commands.FULL_ARGS_COMPLETION_COMMANDS: + # Nested-host commands receive the FULL text after + # the command name so inner slash-command argument + # completion works (e.g. "/queue /add "). + nested_text = text[len(cmd_name) :].lstrip() + cmd_completions = commands.get_completions( + cmd_name, args=nested_text, coder=active_coder + ) + else: + cmd_completions = commands.get_completions( + cmd_name, args=arg_prefix, coder=active_coder + ) if cmd_completions: exempt_from_substring_matching = { "/model", @@ -1974,27 +1986,22 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: """Calculate the new text after applying completion.""" if current_text.startswith("/"): parts = current_text.rsplit(maxsplit=1) + commands = self.worker.coder.commands + host = current_text.split(maxsplit=1)[0] + is_nested_host = host in getattr(commands, "FULL_ARGS_COMPLETION_COMMANDS", ()) - # Check if we have a command with trailing space - # This is when we want to insert argument completions after the space + if is_nested_host and current_text.endswith(" "): + return current_text + completion if len(parts) == 1 and current_text.endswith(" "): - # Command with trailing space, insert completion after space return current_text + completion elif len(parts) == 1: - # Replace entire command (command name completion) - # Only add space if command takes arguments - commands = self.worker.coder.commands try: cmd_completions = commands.get_completions(completion) has_completions = cmd_completions is not None except Exception as e: - # Check if it's a CommandCompletionException if isinstance(e, CommandCompletionException): - # For CommandCompletionException, treat it as having completions - # so we add a space after the command has_completions = True else: - # For other exceptions, assume no completions has_completions = False if has_completions: @@ -2002,7 +2009,9 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: else: return completion else: - # Replace argument + if is_nested_host: + prefix = current_text[: current_text.rfind(parts[-1])] + return prefix + completion return parts[0] + " " + completion elif "@" in current_text: # Replace from @ onwards with the symbol diff --git a/cecli/watch.py b/cecli/watch.py index f7418147666..4c9e1be094a 100644 --- a/cecli/watch.py +++ b/cecli/watch.py @@ -322,6 +322,12 @@ def catch_up_scan(self): # before this scan can share the threshold tick and must not be re-found. threshold = self.last_scan_time found = set() + # Track the newest filesystem mtime observed during this scan. On + # Windows the wall clock (time.time) is coarser than file mtimes, so a + # file written just before this scan can carry an mtime slightly ahead + # of scan_started; advancing the threshold from the filesystem clock + # instead of the wall clock keeps such a file from being re-found. + max_mtime = threshold for root in self.get_roots_to_watch(): root_path = Path(root) @@ -335,12 +341,15 @@ def catch_up_scan(self): for path in candidates: try: + max_mtime = max(max_mtime, path.stat().st_mtime) if self.filter_func(None, str(path)): found.add(str(path.absolute())) except Exception: continue - self.last_scan_time = scan_started + # Advance to the newest filesystem timestamp we saw so the next scan's + # threshold is never behind a file's mtime (see note above). + self.last_scan_time = max(scan_started, max_mtime) if found: self.changed_files.update(found) diff --git a/cecli/website/docs/config/acp.md b/cecli/website/docs/config/acp.md new file mode 100644 index 00000000000..041268556a2 --- /dev/null +++ b/cecli/website/docs/config/acp.md @@ -0,0 +1,117 @@ +--- +parent: Configuration +nav_order: 43 +description: Use cecli as an agent inside editors that speak the Agent Client Protocol, such as Zed. +--- + +# Editor integration (ACP) + +`cecli --acp` runs cecli as an [Agent Client Protocol](https://agentclientprotocol.com) agent. +The editor launches cecli as a subprocess and talks to it with JSON-RPC over stdin/stdout +(ACP v1), so cecli's chat, tool calls and confirmations show up in the editor's agent panel. + +This is separate from the WebSocket server described in [ACP over WebSocket](api.html), which +streams events to custom dashboards. + +## Zed + +Add cecli to `agent_servers` in Zed's `settings.json`: + +```json +{ + "agent_servers": { + "cecli": { + "type": "custom", + "command": "cecli", + "args": ["--acp"], + "env": {} + } + } +} +``` + +Then start a new thread with cecli from the agent panel. cecli reads your usual configuration +(`~/.cecli/conf.yml`, `.cecli.conf.yml`, `.env`), so the model and API keys are the same as on +the command line. Any other flags can go in `args`, for example `["--acp", "--model", "sonnet"]`. +`--acp` itself has to be on the command line: it is ignored in config files and `CECLI_ACP`. + +Use `dev: open acp logs` from the command palette to see the messages exchanged with cecli. +cecli's own diagnostics go to stderr. + +## How cecli maps onto ACP + +- **Prompts** are handled exactly like terminal input, so slash commands work (`/add`, `/ask`, + `/model`, ...) and are offered as completions in the editor. +- **@-mentioned files** are added to the chat. Files outside the project are added read-only. +- **Model output** streams as messages; reasoning streams as thoughts. +- **Tool calls** in agent mode appear as tool cards with their output. The todo list is shown as + the editor's plan, and the final `Yield` summary is shown as a message. +- **Confirmations** ("Run tools?", "Add file to the chat?", shell commands) become permission + requests. "Yes to all" lasts for the rest of the session, just as in the terminal. + `--yes-always` skips them. +- **Cancel** interrupts the running request. +- **MCP servers** configured in the editor (Zed's context servers) are passed to cecli, which + connects them alongside its own MCP servers before the session starts. stdio, HTTP and SSE + servers are supported. If cecli already has a server with the same name, cecli's wins. +- **Shell commands** run without a pager (`PAGER` and `GIT_PAGER` are set to `cat`), since no + one can press a key to page through their output. +- cecli's own status lines (commits, lint results) appear in monospace blocks. The per-request + token and cost line is left out, but it is still written to the chat history file. + +## Mode, model and effort pickers + +cecli offers three pickers next to the editor's prompt box. Choosing a value runs the matching +command, so it behaves exactly as if you had typed it, and typing the command updates the picker. + +- **Mode**: code, ask, architect, context or agent (`/code`, `/ask`, ...), plus **Agent (YOLO)**: + agent mode with what `--yolo` turns on, so tools and shell commands run without asking (the + cost limit still asks). Choosing any other mode, or typing a command that leaves agent mode, + brings the confirmations back. Launched with `--yolo`, agent mode shows as Agent (YOLO). +- **Model**: the model that answers in the current mode. In agent mode that is the agent model + (`/agent-model`); otherwise it is the main model (`/model`). The list starts with the models + you configured (the startup `--model`, your `alias` entries, model tag overrides, and models in + `.cecli.model.settings.yml`), followed by the chat models of every provider whose API key is + set, grouped by provider. Any other model can still be chosen by typing `/model`. +- **Effort**: reasoning effort (`/reasoning-effort`) or thinking budget (`/think-tokens`), + whichever the current model accepts. It is hidden for models that accept neither. + +A key for a large provider such as OpenRouter adds hundreds of models. To list only some of +them, set `acp-models` to patterns, matched the way `/models` matches its search: a plain word +matches anywhere in the name, and `*` and `?` work as wildcards. Your configured models are +always listed. Aliases give your favorite models short names at the top of the list. For +example, in `.cecli.conf.yml`: + +```yaml +acp-models: + - "claude-sonnet" + - "gpt-5*" +alias: + - "fast:gemini/gemini-2.5-flash" + - "smart:anthropic/claude-sonnet-4-5" +``` + +## Sub-agents + +When cecli delegates work to sub-agents (the `Delegate` tool, workspace and workflow agents), +editors that support ACP subagent sessions show each sub-agent as its own session under the +thread. Its messages, tool calls and permission requests appear there instead of in the main +thread, along with whether it is working, waiting for you or idle. The task it was given and the +result it sent back show as messages between the two sessions. + +Each sub-agent can be stopped on its own, and the agent that delegated carries on with the +interrupted result. Cancelling the main thread also stops the sub-agents it is waiting for; +sub-agents working in the background keep going. + +Subagent sessions are an unstable part of ACP, so this only happens with editors that advertise +the `subagents` client capability. With other editors, sub-agent output appears in the main +thread, and a sub-agent still working after the main thread's turn has ended can't ask for +permission: those confirmations are declined. + +## Limitations + +- One process serves one project: the directory of the first session. New threads on the same + project reuse the process and start with a cleared chat; earlier threads can't be continued. +- Sessions can't be reloaded from the editor's history. +- A change to an editor MCP server's settings takes effect after restarting the agent. +- Questions that need free-form answers take their default, and confirmations asked during + startup (before any prompt) are declined. diff --git a/cecli/website/docs/config/retries.md b/cecli/website/docs/config/retries.md index 3140c2de6ed..0aa01c39f85 100644 --- a/cecli/website/docs/config/retries.md +++ b/cecli/website/docs/config/retries.md @@ -11,6 +11,7 @@ Cecli can be configured to retry failed API calls. This is useful for handling i - `retry-timeout`: The timeout in seconds for each retry. - `retry-backoff-factor`: The backoff factor to use between retries. - `retry-on-unavailable`: Whether to retry on 503 Service Unavailable errors. +- `retry-on-empty`: Whether to retry when the model returns an empty response. A response is considered empty when it has no content, no tool calls, and no *meaningful* reasoning. Reasoning that contains no alphanumeric characters (for example a `reasoning_content` of `"!!!!"`) does not count as a response, so it is retried like any other empty response. - `retry-on-unauthorized`: Whether to retry on 401 Unauthorized (and 403 Forbidden) errors. Default: false. Example usage in `.cecli.conf.yml`: diff --git a/cecli/website/docs/config/scheduler.md b/cecli/website/docs/config/scheduler.md index 1d976a7bd81..2ab66ec410e 100644 --- a/cecli/website/docs/config/scheduler.md +++ b/cecli/website/docs/config/scheduler.md @@ -1,7 +1,7 @@ --- parent: Configuration nav_order: 48 -description: The scheduler turns workflow schedule: entries into runs, coordinating any number of cecli instances through a shared SQLite database. +description: The scheduler turns workflow schedule entries into runs, coordinating any number of cecli instances through a shared SQLite database. --- # Scheduler @@ -9,14 +9,7 @@ The [scheduler](workflows.html) is the component that turns a [workflow](workflo ## Running it -The scheduler starts automatically in any interactive cecli session that has at least one enabled, scheduled workflow. Control it explicitly per session with: - -```bash -cecli --scheduler # force on for this session -cecli --no-scheduler # force off (workflows are still usable via /workflows run) -``` - -or persistently in `.cecli.conf.yml`: +The scheduler starts automatically in any interactive cecli session that has at least one enabled, scheduled workflow. It is configured as an object in `.cecli.conf.yml`: ```yaml scheduler: @@ -26,7 +19,9 @@ scheduler: max_catchup: 1 # how many missed slots to replay before skipping ahead ``` -Resolution order for whether it runs: the CLI flag (`--scheduler` / `--no-scheduler`) wins, then `scheduler.enabled` from config, then auto — on when any enabled workflow has a `schedule:`, off otherwise. Nothing touches the coordination database when no scheduled workflow exists, and one-shot (`--message`) runs return before the scheduler can tick. +`--scheduler ` overrides it for a single session with the same object, e.g. `cecli --scheduler '{"enabled": false}'`. Disabling is always expressed through the object's `enabled` key — there is no separate off switch — and workflows remain usable manually via `/workflows run`. + +Resolution order for whether it runs: `enabled` (from the CLI `--scheduler ` deep-merged over the `scheduler:` config key) decides on/off; when `enabled` is omitted the mode is auto — on when any enabled workflow has a `schedule:`, off otherwise. Nothing touches the coordination database when no scheduled workflow exists, and one-shot (`--message`) runs return before the scheduler can tick. The `catch_up` pair controls what happens after downtime (e.g. a sleeping laptop): @@ -35,10 +30,6 @@ The `catch_up` pair controls what happens after downtime (e.g. a sleeping laptop An individual workflow may override the global setting with its own top-level `catch_up: true|false` key (see [Workflows](workflows.html)); unset means "follow the scheduler". -A headless daemon mode (scheduling on a server with no interactive session) is planned but not yet available; today scheduled runs only fire while a cecli session is alive. - -In TUI mode the scheduler ticks and dispatches on the primary event loop while the interactive coder runs on the TUI worker-thread loop. Scheduled runs still get their own sub-agent pane: the container notifications handle being called from the app's own thread. Workflow dispatch itself is loop-safe (the DB is threaded, sub-agent spawning marshals through `AgentService`), but a run's worker conversation shares MCP connections across loops; prefer linear (non-TUI) sessions or a dedicated `--scheduler` instance for production schedules until a `run_coroutine_threadsafe` bridge lands. - ## Multi-instance coordination You can safely run **many** cecli instances at once (same machine, or one shared `~/.cecli` on a network filesystem — see caveats). They cooperate through a global SQLite database: @@ -68,5 +59,6 @@ Scheduler state is visible from any session with the [workflow commands](workflo ## Arguments -- `--scheduler` / `--no-scheduler`: Force the workflow scheduler on or off for this session. +- `--scheduler `: Override scheduler configuration for this session (same object as the `scheduler:` config key). - `--workflows `: Workflow definitions the scheduler will consider (see [Workflows](workflows.html)). +- To disable the scheduler, pass `--scheduler '{"enabled": false}'` (or set `enabled: false` under the `scheduler:` config key). diff --git a/cecli/website/docs/config/workflows.md b/cecli/website/docs/config/workflows.md index 9f1915fe868..5b4ccabdb16 100644 --- a/cecli/website/docs/config/workflows.md +++ b/cecli/website/docs/config/workflows.md @@ -17,12 +17,14 @@ Preferred: one YAML file per workflow in `~/.cecli/workflows/`: morning-triage.yml ``` -Also supported: inline under a `workflows:` key in `.cecli.conf.yml`, or a repo-local `.cecli.workflows.yml`. Each source may hold a single workflow mapping, a `workflows: [...]` list, or a plain list of mappings. Resolution precedence (highest first): +Also supported: a repo-local `.cecli/workflows/` or `.agents/workflows/` directory (one YAML file per workflow), inline under a `workflows:` key in `.cecli.conf.yml`, or a repo-local `.cecli.workflows.yml`. Each source may hold a single workflow mapping, a `workflows: [...]` list, or a plain list of mappings. Resolution precedence (highest first): 1. **CLI argument** — an inline JSON/YAML string or a file path passed to `--workflows`. 2. **Local file** — `.cecli.workflows.yml` / `.cecli.workflows.yaml` in the git root (or working directory). -3. **Merged config** — the `workflows:` key in `.cecli.conf.yml`. -4. **Global files** — `~/.cecli/workflows/*.yml` / `*.yaml`, loaded in sorted order. +3. **Local directory** — `.cecli/workflows/*.yml` / `*.yaml` in the git root (or working directory), loaded in sorted order. +4. **Standard local directory** — `.agents/workflows/*.yml` / `*.yaml` in the git root (or working directory), loaded in sorted order. +5. **Merged config** — the `workflows:` key in `.cecli.conf.yml`. +6. **Global files** — `~/.cecli/workflows/*.yml` / `*.yaml`, loaded in sorted order. On a duplicate `name`, the higher-precedence definition wins and the loser is skipped with a warning. A source that fails to parse is skipped with a warning as well — a malformed workflows file never crashes startup. @@ -152,4 +154,4 @@ tasks: ## Arguments - `--workflows `: Provide an inline workflows configuration or a path to a workflows file, overriding all file-based sources. -- `--scheduler` / `--no-scheduler`: Force the [scheduler](scheduler.html) on or off for this session. +- `--scheduler `: Override [scheduler](scheduler.html) configuration for this session (same object as the `scheduler:` config key). diff --git a/cecli/website/docs/usage/commands.md b/cecli/website/docs/usage/commands.md index e3f47303fab..150f42bdf6a 100644 --- a/cecli/website/docs/usage/commands.md +++ b/cecli/website/docs/usage/commands.md @@ -61,19 +61,7 @@ Cecli supports commands from within the chat, which all start with `/`. ## Prompt Queue Management -| Command | Description | -| :--- | :--- | -| **/queue** | Queue a prompt for processing after current tasks complete | -| **/list-queue** | List all prompts currently in the queue | -| **/remove-queue** | Remove a prompt from the queue by index, or '*' to clear all | - -{: .tip } - -## Prompt Queue Management Commands - -The prompt queue management feature (`CLI-33`) adds three new commands for managing a first-in-first-out (FIFO) queue of prompts. - -### Queue Commands +The prompt queue is a first-in-first-out (FIFO) queue of prompts attached to the active agent. Queued prompts run after the current response finishes. Starting a new prompt also drains the queue after that prompt completes; use `/run-queue` to start it explicitly. | Command | Description | |---------|-------------| @@ -81,12 +69,15 @@ The prompt queue management feature (`CLI-33`) adds three new commands for manag | **/insert-queue** | Insert a prompt at a specific position in the queue | | **/list-queue** | List all prompts currently in the queue | | **/remove-queue** | Remove a prompt from the queue by index, or '*' to clear all | +| **/run-queue** | Process queued prompts immediately | + +If a response is interrupted (for example with Ctrl+C), the in-flight prompt is put back at the front of the queue. Use `/run-queue` to resume processing. -#### `/queue` Command +### `/queue` Command **Usage:** `/queue ` -**Description:** Adds a prompt to the queue for processing after the current command completes. +**Description:** Adds a prompt to the queue for processing after the current response completes. **Examples:** ```bash @@ -94,18 +85,41 @@ The prompt queue management feature (`CLI-33`) adds three new commands for manag /queue "add unit tests for user service" ``` -#### `/insert-queue` Command +### `/insert-queue` Command -**Usage:** `/insert-queue `, `/insert-queue ` +**Usage:** `/insert-queue ` -**Description:** Inserts a prompt at a specific position in the queue. When called without an index, the prompt is added at the front of the queue. +**Description:** Inserts a prompt at a specific 1-based position in the queue. Existing items shift down, and the index is clamped to the queue bounds. **Examples:** ```bash -/insert-queue "add tests for login" +/insert-queue 1 "add tests for login" /insert-queue 3 "refactor database layer" ``` +### `/list-queue` Command + +**Usage:** `/list-queue` + +**Description:** Lists all queued prompts with their position and queue time. + +### `/remove-queue` Command + +**Usage:** `/remove-queue `, `/remove-queue *` + +**Description:** Removes the prompt at the given 1-based position, or clears the entire queue when `*` is used. + +### `/run-queue` Command + +**Usage:** `/run-queue` + +**Description:** Processes every queued prompt immediately, in FIFO order. This is mainly useful to resume a queue that was paused by an interruption. + +**Examples:** +```bash +/run-queue +``` + > **Tip:** You can easily re-send commands or messages. Use the up arrow ⬆ to scroll back or CONTROL-R to search your message history. diff --git a/tests/basic/test_agent_config_merge.py b/tests/basic/test_agent_config_merge.py index b691666eab5..680dbb066b4 100644 --- a/tests/basic/test_agent_config_merge.py +++ b/tests/basic/test_agent_config_merge.py @@ -113,6 +113,7 @@ def test_all_yaml_to_json_args_deep_merge_with_config_file(): "system_one", "server_config", "workflows", + "scheduler", } assert all("_" not in key for key in YAML_TO_JSON_ARG_KEYS.values()) diff --git a/tests/basic/test_empty_response.py b/tests/basic/test_empty_response.py new file mode 100644 index 00000000000..4b562fc7222 --- /dev/null +++ b/tests/basic/test_empty_response.py @@ -0,0 +1,223 @@ +"""Tests for empty-response detection with non-meaningful reasoning content. + +Regression coverage for the ``retry-on-empty`` fix: a completion with empty +``content``, no tool calls, and a ``reasoning_content`` made entirely of +non-alphanumeric characters (e.g. moonshotai/kimi-k3 returning ``"!!!!"``) must +be classified as an empty response so the existing retry loop engages. +""" + +from types import SimpleNamespace + +import pytest + +from cecli.coders.base_coder import Coder, _is_meaningful_reasoning +from cecli.helpers.threading import ThreadSafeEvent +from cecli.llm import litellm + + +# --------------------------------------------------------------------------- # +# Test doubles +# --------------------------------------------------------------------------- # +class _AlwaysSetEvent: + def is_set(self): + return True + + +class _FakeIO: + def __init__(self): + self.confirmation_in_progress_event = _AlwaysSetEvent() + self.assistant_outputs = [] + + def tool_error(self, *a, **k): + pass + + def tool_warning(self, *a, **k): + pass + + def update_spinner_suffix(self, *a, **k): + pass + + def reset_streaming_response(self): + pass + + def stream_output(self, *a, **k): + pass + + def ai_output(self, *a, **k): + pass + + def tool_output(self, *a, **k): + pass + + def assistant_output(self, *a, **k): + self.assistant_outputs.append(a) + + +class _FakeTokenProfiler: + def start(self): + pass + + def on_token(self): + pass + + def on_error(self): + pass + + def add_to_usage_report(self, *a, **k): + return a[0] if a else "" + + +def _make_coder(stream=False): + coder = Coder.__new__(Coder) + coder.stream = stream + coder.verbose = False + coder.args = SimpleNamespace(debug=False, show_thinking=False) + coder.io = _FakeIO() + coder.interrupt_event = ThreadSafeEvent() + coder.pretty = False + coder.reasoning_tag_name = "THINKING" + coder.got_reasoning_content = False + coder.ended_reasoning_content = False + coder.empty_response = False + coder.tool_reflection = False + coder.partial_response_content = "" + coder.partial_response_reasoning_content = "" + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [] + coder.partial_response_function_call = dict() + coder.partial_response_consolidated = None + coder.multi_response_content = "" + coder.chat_completion_response_hashes = [] + coder._streaming_buffer_length = 0 + coder.token_profiler = _FakeTokenProfiler() + coder._output_loop_detected = False + coder._output_loop_message = "" + coder._has_empty_reflected = False + coder.edit_format = "code" + return coder + + +def _tc(index, call_id, name, arguments): + return litellm.ChatCompletionMessageToolCall( + id=call_id, + function=litellm.Function(arguments=arguments or "", name=name), + type="function", + index=index, + ) + + +def _non_streaming_completion(content="", reasoning=None, tool_calls=None): + message = {"role": "assistant", "content": content} + if reasoning is not None: + message["reasoning_content"] = reasoning + if tool_calls is not None: + message["tool_calls"] = tool_calls + return litellm.ModelResponse( + id="test-completion", + created=0, + model="gpt-test", + object="chat.completion", + choices=[{"finish_reason": "stop", "index": 0, "message": message}], + usage={"completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2}, + ) + + +def _reasoning_chunk(text): + delta = litellm.Delta(role="assistant", content=None, reasoning_content=text) + choice = litellm.StreamChoice(finish_reason=None, index=0, delta=delta) + return litellm.StreamChunk( + id="cmpl-test", created=1000, model="gpt-test", choices=[choice], usage=None + ) + + +async def _agen(chunks): + for chunk in chunks: + yield chunk + + +# --------------------------------------------------------------------------- # +# _is_meaningful_reasoning unit tests +# --------------------------------------------------------------------------- # +def test_is_meaningful_reasoning_empty_and_none(): + assert _is_meaningful_reasoning("") is False + assert _is_meaningful_reasoning(None) is False + + +def test_is_meaningful_reasoning_punctuation_only(): + assert _is_meaningful_reasoning("!!!!") is False + assert _is_meaningful_reasoning("...?!,;:") is False + + +def test_is_meaningful_reasoning_whitespace_only(): + assert _is_meaningful_reasoning(" \n\t ") is False + + +def test_is_meaningful_reasoning_normal_text(): + assert _is_meaningful_reasoning("Let me think about this") is True + + +def test_is_meaningful_reasoning_mixed_punctuation_and_alnum(): + assert _is_meaningful_reasoning("42?") is True + assert _is_meaningful_reasoning("!!!a!!!") is True + + +# --------------------------------------------------------------------------- # +# Non-streaming path (show_send_output) +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_junk_reasoning_is_empty_non_streaming(): + coder = _make_coder(stream=False) + await coder.show_send_output(_non_streaming_completion(content="", reasoning="!" * 40)) + assert coder.empty_response is True + + +@pytest.mark.asyncio +async def test_meaningful_reasoning_is_not_empty_non_streaming(): + coder = _make_coder(stream=False) + await coder.show_send_output( + _non_streaming_completion(content="", reasoning="Let me think about this") + ) + assert coder.empty_response is False + + +@pytest.mark.asyncio +async def test_tool_calls_are_not_empty_non_streaming(): + coder = _make_coder(stream=False) + await coder.show_send_output( + _non_streaming_completion( + content="", + reasoning="", + tool_calls=[_tc(0, "call_1", "Local--ls", "{}")], + ) + ) + assert coder.empty_response is False + + +@pytest.mark.asyncio +async def test_mixed_reasoning_is_not_empty_non_streaming(): + coder = _make_coder(stream=False) + await coder.show_send_output(_non_streaming_completion(content="", reasoning="42?")) + assert coder.empty_response is False + + +# --------------------------------------------------------------------------- # +# Streaming path (show_send_output_stream) +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_junk_reasoning_is_empty_streaming(): + coder = _make_coder(stream=True) + coder.args.show_thinking = True + async for _ in coder.show_send_output_stream(_agen([_reasoning_chunk("!" * 40)])): + pass + assert coder.empty_response is True + + +@pytest.mark.asyncio +async def test_meaningful_reasoning_is_not_empty_streaming(): + coder = _make_coder(stream=True) + coder.args.show_thinking = True + async for _ in coder.show_send_output_stream( + _agen([_reasoning_chunk("Let me think about this")]) + ): + pass + assert coder.empty_response is False diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index fe26a786fab..392dfd5dd02 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -101,6 +101,14 @@ def test_main_with_empty_dir_no_files_on_command(dummy_io): main(["--no-git", "--exit", "--yes-always"], **dummy_io) +def test_main_ignores_acp_outside_command_line(dummy_io, monkeypatch, capsys): + # ACP mode starts before config is read, so only the --acp flag can turn it on + monkeypatch.setenv("CECLI_ACP", "1") + coder = main(["--no-git", "--exit", "--yes-always"], **dummy_io, return_coder=True) + assert type(coder.io._target) is InputOutput + assert "pass --acp on the command line" in capsys.readouterr().err + + def test_main_with_empty_dir_new_file(dummy_io): main(["foo.txt", "--yes-always", "--no-git", "--exit"], **dummy_io) assert os.path.exists("foo.txt") @@ -1050,8 +1058,17 @@ def test_main_exit_with_git_command_not_found(dummy_io, git_temp_dir, mocker): def test_reasoning_effort_option(dummy_io, git_temp_dir): + # Fix the model so the assertion does not depend on which provider keys are set. coder = main( - ["--reasoning-effort", "3", "--no-check-model-accepts-settings", "--yes-always", "--exit"], + [ + "--model", + "gpt-3.5-turbo", + "--reasoning-effort", + "3", + "--no-check-model-accepts-settings", + "--yes-always", + "--exit", + ], **dummy_io, return_coder=True, ) diff --git a/tests/basic/test_scheduler_config.py b/tests/basic/test_scheduler_config.py new file mode 100644 index 00000000000..6b53bf3ef77 --- /dev/null +++ b/tests/basic/test_scheduler_config.py @@ -0,0 +1,96 @@ +import os +import tempfile + +import yaml + +from cecli.args import get_parser +from cecli.main import ( + _scheduler_config, + _scheduler_enabled, + convert_yaml_to_json_string, +) + + +class _Args: + def __init__(self, scheduler=None): + self.scheduler = scheduler + + +class _Workflow: + def __init__(self, schedule=None, enabled=True): + self.schedule = schedule + self.enabled = enabled + + +def _parse_config(config, argv=()): + fd, path = tempfile.mkstemp(suffix=".yml") + os.close(fd) + + try: + with open(path, "w") as f: + yaml.dump(config, f) + + parser = get_parser([path], None) + + return parser.parse_known_args(list(argv))[0] + finally: + os.unlink(path) + + +def _resolve_scheduler(config, argv=()): + """Mirror main_async: parse the config, then deep-merge the scheduler arg.""" + + args = _parse_config(config, argv) + + if args.scheduler is not None: + args.scheduler = convert_yaml_to_json_string(args.scheduler, config.get("scheduler")) + + return _scheduler_config(args) + + +def test_scheduler_object_parses_without_aborting_startup(): + """Regression: the documented object form used to abort argparse startup.""" + + resolved = _resolve_scheduler({"scheduler": {"enabled": False, "tick_seconds": 5}}) + + assert resolved == { + "enabled": False, + "tick_seconds": 5, + "catch_up": True, + "max_catchup": 1, + } + + +def test_scheduler_absent_uses_documented_defaults_and_auto(): + resolved = _resolve_scheduler({}) + + assert resolved == { + "enabled": None, + "tick_seconds": 60, + "catch_up": True, + "max_catchup": 1, + } + + +def test_cli_scheduler_json_overrides_config_per_key(): + config = {"scheduler": {"enabled": True, "tick_seconds": 5}} + resolved = _resolve_scheduler(config, argv=["--scheduler", '{"enabled": false}']) + + assert resolved["enabled"] is False # CLI wins + assert resolved["tick_seconds"] == 5 # file-only key preserved + + +def test_scheduler_config_defaults_on_bad_input(): + assert _scheduler_config(_Args(None))["tick_seconds"] == 60 + assert _scheduler_config(_Args("not json"))["tick_seconds"] == 60 + assert _scheduler_config(_Args("[1, 2]"))["tick_seconds"] == 60 + + +def test_scheduler_enabled_explicit_and_auto(): + scheduled = _Workflow(schedule="@daily") + manual = _Workflow(schedule=None) + + assert _scheduler_enabled({"enabled": True}, [manual]) is True + assert _scheduler_enabled({"enabled": False}, [scheduled]) is False + assert _scheduler_enabled({"enabled": None}, [scheduled]) is True # auto + assert _scheduler_enabled({"enabled": None}, [manual]) is False # auto diff --git a/tests/basic/test_watch.py b/tests/basic/test_watch.py index 90d3bcb3195..388a3b3f33c 100644 --- a/tests/basic/test_watch.py +++ b/tests/basic/test_watch.py @@ -248,6 +248,34 @@ def test_catch_up_scan_ignores_file_at_threshold(tmp_path): assert watcher.changed_files == set() +def test_catch_up_scan_advances_past_file_mtime(tmp_path): + """A file whose mtime is ahead of the wall clock is not re-found. + + On Windows the wall clock (``time.time()``) can be coarser than the + filesystem timestamp, so a file written just before a scan may report an + mtime slightly ahead of the time the scan started. The threshold must be + advanced using the filesystem timestamp so the file is not re-found. + """ + io = InputOutput(pretty=False, fancy_input=False, yes=False) + coder = MinimalCoder(io) + watcher = FileWatcher(coder, root=tmp_path) + watcher.last_scan_time = 0 + + target = tmp_path / "a.py" + target.write_text("# ai!\n") + + # Simulate the clock skew by giving the file an mtime in the future + # relative to the wall clock reading taken when the scan starts. + ahead = time.time() + 5 + os.utime(target, (ahead, ahead)) + + assert watcher.catch_up_scan() + watcher.changed_files = set() + + assert not watcher.catch_up_scan() + assert watcher.changed_files == set() + + def test_process_changes_consumes_catch_up(tmp_path): io = InputOutput(pretty=False, fancy_input=False, yes=False) coder = MinimalCoder(io) diff --git a/cecli/tests/test_queue_commands.py b/tests/commands/test_queue_commands.py similarity index 53% rename from cecli/tests/test_queue_commands.py rename to tests/commands/test_queue_commands.py index 2449c55e35c..edb2dd70dc9 100644 --- a/cecli/tests/test_queue_commands.py +++ b/tests/commands/test_queue_commands.py @@ -1,31 +1,18 @@ -""" -Test suite for CLI-33 Queue Commands. - -This module contains comprehensive tests for: -- Unit tests: Queue logic in the command_queue helper -- Integration tests: QueueCommand, ListQueueCommand, RemoveQueueCommand -- E2E tests: Full queue lifecycle and processing -- Regression tests: Existing command integrity - -Test categories: -- UTC-01 through UTC-20: Unit tests for queue helpers -- ITC-01 through ITC-20: Integration tests for commands -- ETC-01 through ETC-10: E2E tests for full lifecycle -- RTC-01 through RTC-05: Regression tests for existing functionality -- TDS-01 through TDS-04: Test data setup and fixtures -""" +"""Tests for prompt queue commands and queue processing.""" import threading import time -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest # Import the actual classes to test from cecli.commands.core import Commands +from cecli.commands.insert_queue import InsertQueueCommand from cecli.commands.list_queue import ListQueueCommand from cecli.commands.queue import QueueCommand from cecli.commands.remove_queue import RemoveQueueCommand +from cecli.commands.run_queue import RunQueueCommand from cecli.commands.utils.registry import CommandRegistry from cecli.helpers import command_queue from cecli.signals import ReloadProgramSignal, SwitchCoderSignal @@ -42,6 +29,9 @@ def _make_coder(): coder.prompt_queue = [] coder._queue_counter = 0 coder._queue_lock = None + coder._active_queue_prompt = None + coder._processing_queue = False + coder._drain_queue_after_command = False return coder @@ -76,9 +66,7 @@ def _reset_agent_service(): AgentService._primary_agent_uuid = None -# ============================================================================ -# Test Fixtures (Section 10.6, TDS-01 through TDS-04) -# ============================================================================ +# Fixtures @pytest.fixture @@ -134,9 +122,7 @@ def mock_coder_no_commands(): return coder -# ============================================================================ -# Unit Tests - Queue Logic (Section 10.2, UTC-01 through UTC-20) -# ============================================================================ +# Queue helper tests class TestEnqueuePrompt: @@ -325,9 +311,7 @@ def test_utc_19_timestamps_monotonic(self, clean_coder): assert item1["timestamp"] <= item2["timestamp"] -# ============================================================================ -# Integration Tests - Command Classes (Section 10.3, ITC-01 through ITC-20) -# ============================================================================ +# Command tests class TestQueueCommand: @@ -503,15 +487,520 @@ def test_itc_19_get_completions_returns_indices_and_wildcard(self, populated_que assert "*" in completions def test_itc_20_commands_registered_in_registry(self): - """ITC-20: All three commands are registered and discoverable via help/registry lookup.""" + """ITC-20: All four queue commands are registered and discoverable via registry lookup.""" assert CommandRegistry.get_command("queue") is not None assert CommandRegistry.get_command("list-queue") is not None assert CommandRegistry.get_command("remove-queue") is not None + assert CommandRegistry.get_command("run-queue") is not None -# ============================================================================ -# E2E Tests - Full Queue Lifecycle (Section 10.4, ETC-01 through ETC-10) -# ============================================================================ +class TestRunQueueCommand: + """Integration tests for RunQueueCommand.""" + + @pytest.mark.asyncio + async def test_itc_21_run_queue_drains_multiple_items_fifo(self, mock_io): + """ITC-21: /run-queue drains every queued item in FIFO order.""" + coder = _make_coder_with_commands(("first", "second", "third")) + processed = [] + + async def fake_drain(): + while coder.prompt_queue: + item = command_queue.dequeue_prompt(coder) + processed.append(item["text"]) + + coder.process_prompt_queue = fake_drain + + result = await RunQueueCommand.execute(mock_io, coder, "") + + assert result == "Successfully executed run-queue." + assert processed == ["first", "second", "third"] + assert command_queue.get_queue_length(coder) == 0 + + @pytest.mark.asyncio + async def test_itc_22_run_queue_exception_returns_formatted_error(self, mock_io): + """ITC-22: An exception in process_prompt_queue returns a formatted error.""" + coder = _make_coder_with_commands(("boom",)) + + async def failing_drain(): + raise RuntimeError("drain exploded") + + coder.process_prompt_queue = failing_drain + + result = await RunQueueCommand.execute(mock_io, coder, "") + + assert "Error" in result + assert "drain exploded" in result + mock_io.tool_error.assert_called_once() + + @pytest.mark.asyncio + async def test_itc_23_run_queue_empty_queue_informs_user(self, mock_io, mock_coder): + """ITC-23: /run-queue on an empty queue informs the user without error.""" + result = await RunQueueCommand.execute(mock_io, mock_coder, "") + + assert result == "Successfully executed run-queue." + calls = [str(call) for call in mock_io.tool_output.call_args_list] + assert any("Queue is empty" in call for call in calls) + + @pytest.mark.asyncio + async def test_itc_24_run_queue_handles_coder_commands_none( + self, mock_io, mock_coder_no_commands + ): + """ITC-24: /run-queue handles coder.commands is None with error message.""" + result = await RunQueueCommand.execute(mock_io, mock_coder_no_commands, "") + + assert "Error" in result or "not available" in result.lower() + + +class TestQueueIdleNoAutoDrain: + """Queue commands enqueue without starting work on their own.""" + + @pytest.mark.asyncio + async def test_itc_25_queue_idle_only_enqueues(self, mock_io): + """ITC-25: /queue while idle only enqueues; processing waits for a prompt.""" + coder = _make_coder_with_commands() + coder._processing_queue = False + drain_calls = [] + + async def fake_drain(): + drain_calls.append(True) + + coder.process_prompt_queue = fake_drain + + result = await QueueCommand.execute(mock_io, coder, "idle prompt") + + assert result == "Successfully executed queue." + assert drain_calls == [] + assert command_queue.get_queue_length(coder) == 1 + assert coder.prompt_queue[0]["text"] == "idle prompt" + + @pytest.mark.asyncio + async def test_itc_26_queue_busy_does_not_trigger_processing(self, mock_io): + """ITC-26: /queue does not trigger processing.""" + coder = _make_coder_with_commands() + coder._processing_queue = False + drain_calls = [] + + async def fake_drain(): + drain_calls.append(True) + + coder.process_prompt_queue = fake_drain + + result = await QueueCommand.execute(mock_io, coder, "busy prompt") + + assert result == "Successfully executed queue." + assert drain_calls == [] + assert command_queue.get_queue_length(coder) == 1 + assert coder.prompt_queue[0]["text"] == "busy prompt" + + @pytest.mark.asyncio + async def test_itc_27_queue_guard_prevents_double_drain(self, mock_io): + """ITC-27: /queue does not start a drain while one is active.""" + coder = _make_coder_with_commands() + coder._processing_queue = True + drain_calls = [] + + async def fake_drain(): + drain_calls.append(True) + + coder.process_prompt_queue = fake_drain + + result = await QueueCommand.execute(mock_io, coder, "guarded prompt") + + assert result == "Successfully executed queue." + assert drain_calls == [] + assert command_queue.get_queue_length(coder) == 1 + + @pytest.mark.asyncio + async def test_itc_28_insert_queue_idle_only_enqueues(self, mock_io): + """ITC-28: /insert-queue while idle only enqueues.""" + coder = _make_coder_with_commands() + coder._processing_queue = False + drain_calls = [] + + async def fake_drain(): + drain_calls.append(True) + + coder.process_prompt_queue = fake_drain + + result = await InsertQueueCommand.execute(mock_io, coder, "1 idle insert") + + assert result == "Successfully executed insert-queue." + assert drain_calls == [] + assert command_queue.get_queue_length(coder) == 1 + assert coder.prompt_queue[0]["text"] == "idle insert" + + @pytest.mark.asyncio + async def test_itc_29_insert_queue_busy_does_not_trigger_processing(self, mock_io): + """ITC-29: /insert-queue does not trigger processing.""" + coder = _make_coder_with_commands() + coder._processing_queue = False + drain_calls = [] + + async def fake_drain(): + drain_calls.append(True) + + coder.process_prompt_queue = fake_drain + + result = await InsertQueueCommand.execute(mock_io, coder, "1 busy insert") + + assert result == "Successfully executed insert-queue." + assert drain_calls == [] + assert command_queue.get_queue_length(coder) == 1 + assert coder.prompt_queue[0]["text"] == "busy insert" + + +class TestRunOneCommandPathDrain: + """Regression tests for the run_one() command-path drain.""" + + @pytest.mark.asyncio + async def test_itc_30_run_one_idle_command_does_not_start_drain(self, mock_io): + """An idle command must not start a queue drain from run_one().""" + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands() + coder.io = mock_io + coder._processing_queue = False + + calls = [] + + async def fake_preproc(inp): + calls.append("preproc") + return await coder.commands.run(inp, coder=coder) + + async def fake_drain(preproc=True): + calls.append("drain") + + coder.preproc_user_input = fake_preproc + coder.process_prompt_queue = fake_drain + + await Coder.run_one(coder, "/queue tell me the color of the sun", preproc=True) + + assert calls == ["preproc"] + assert command_queue.get_queue_length(coder) == 1 + + @pytest.mark.asyncio + async def test_itc_31_run_one_queued_command_continues_active_drain(self, mock_io): + """A command processed from the queue must let the active drain continue.""" + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("queued prompt",)) + coder.io = mock_io + coder._processing_queue = True + + calls = [] + processed = [] + + async def fake_preproc(inp): + calls.append("preproc") + return await coder.commands.run(inp, coder=coder) + + async def fake_drain(preproc=True): + calls.append("drain") + while coder.prompt_queue: + item = command_queue.dequeue_prompt(coder) + processed.append(item["text"]) + + coder.preproc_user_input = fake_preproc + coder.process_prompt_queue = fake_drain + + await Coder.run_one(coder, "/queue another prompt", preproc=True) + + assert calls == ["preproc", "drain"] + assert processed == ["queued prompt", "another prompt"] + assert command_queue.get_queue_length(coder) == 0 + + @pytest.mark.asyncio + async def test_itc_32_staged_workflow_command_drains_queue(self, mock_io): + """A workflow-staged command drains the tasks queued behind it.""" + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("queued prompt",)) + coder.io = mock_io + coder._processing_queue = False + coder._drain_queue_after_command = True + + calls = [] + processed = [] + + async def fake_preproc(inp): + calls.append("preproc") + return await coder.commands.run(inp, coder=coder) + + async def fake_drain(preproc=True): + calls.append("drain") + while coder.prompt_queue: + item = command_queue.dequeue_prompt(coder) + processed.append(item["text"]) + + coder.preproc_user_input = fake_preproc + coder.process_prompt_queue = fake_drain + + await Coder.run_one(coder, "/load-mcp github", preproc=True) + + assert calls == ["preproc", "drain"] + assert processed == ["queued prompt"] + assert coder._drain_queue_after_command is False + + +class TestQueueRequeue: + """Tests for tracking and restoring the in-flight queue item.""" + + def test_dequeue_for_processing_tracks_and_clears_active_prompt(self, clean_coder): + item = command_queue.enqueue_prompt(clean_coder, "only prompt") + clean_coder._processing_queue = True + + active = command_queue.dequeue_for_processing(clean_coder) + + assert active is item + assert command_queue.get_active_prompt(clean_coder) is item + assert command_queue.dequeue_for_processing(clean_coder) is None + assert command_queue.get_active_prompt(clean_coder) is None + assert clean_coder._processing_queue is False + + def test_requeue_active_prompt_restores_original_item(self, clean_coder): + first = command_queue.enqueue_prompt(clean_coder, "first") + command_queue.enqueue_prompt(clean_coder, "second") + command_queue.dequeue_for_processing(clean_coder) + + restored = command_queue.requeue_active_prompt(clean_coder) + + assert restored is first + assert command_queue.list_queue(clean_coder)[0] is first + assert command_queue.get_queue_length(clean_coder) == 2 + assert command_queue.get_active_prompt(clean_coder) is None + + @pytest.mark.asyncio + async def test_process_prompt_queue_requeues_on_interrupt(self, mock_io): + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("first", "second")) + coder.io = mock_io + original_ids = [item["id"] for item in command_queue.list_queue(coder)] + + async def interrupted_generate(text, preproc=True): + raise KeyboardInterrupt + + coder.generate = interrupted_generate + + with pytest.raises(KeyboardInterrupt): + await Coder.process_prompt_queue(coder) + + assert [item["id"] for item in command_queue.list_queue(coder)] == original_ids + assert command_queue.get_active_prompt(coder) is None + assert coder._processing_queue is False + mock_io.tool_warning.assert_called_once() + + @pytest.mark.asyncio + async def test_process_prompt_queue_requeues_when_interrupt_event_is_set(self, mock_io): + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("first", "second")) + coder.io = mock_io + original_ids = [item["id"] for item in command_queue.list_queue(coder)] + coder.interrupt_event = MagicMock() + coder.interrupt_event.is_set.return_value = True + + async def swallowed_interrupt_generate(text, preproc=True): + return None + + coder.generate = swallowed_interrupt_generate + + await Coder.process_prompt_queue(coder) + + assert [item["id"] for item in command_queue.list_queue(coder)] == original_ids + assert command_queue.get_active_prompt(coder) is None + assert coder._processing_queue is False + mock_io.tool_warning.assert_called_once() + + +class TestSubAgentCoderSwitch: + """A sub-agent's queued command signal swaps coders instead of escaping.""" + + @pytest.mark.asyncio + async def test_process_prompt_queue_moves_queue_to_replacement_coder(self, mock_io): + """A SwitchCoderSignal from a queued command switches only the sub-agent.""" + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("raises-switch", "second", "third")) + coder.uuid = "sub-uuid" + coder.parent_uuid = "primary-uuid" + coder.io = mock_io + coder.args = MagicMock() + + async def signal_generate(text, preproc=True): + raise SwitchCoderSignal(edit_format="subagent", from_coder=coder) + + coder.generate = AsyncMock(side_effect=signal_generate) + + new_coder = MagicMock() + new_coder.uuid = "sub-uuid" + new_coder.process_prompt_queue = AsyncMock() + + from cecli.helpers.agents.service import AgentService, SubAgentInfo + + parent = MagicMock() + parent.uuid = "primary-uuid" + service = AgentService(parent) + info = SubAgentInfo(name="wf:storytime", coder=coder, parent_uuid="primary-uuid") + service.sub_agents["sub-uuid"] = info + service._sub_agent_order.append("sub-uuid") + + with ( + patch("cecli.coders.Coder.create", AsyncMock(return_value=new_coder)) as create, + patch("cecli.helpers.agents.service.AgentService.get_instance", return_value=service), + ): + await Coder.process_prompt_queue(coder) + + assert [item["text"] for item in new_coder.prompt_queue] == ["second", "third"] + assert coder.prompt_queue == [] + assert info.coder is new_coder + assert service.sub_agents["sub-uuid"] is info + assert coder._processing_queue is False + assert command_queue.get_active_prompt(coder) is None + new_coder.process_prompt_queue.assert_awaited_once_with(True) + + create_kwargs = create.call_args.kwargs + assert create_kwargs["from_coder"] is coder + + @pytest.mark.asyncio + async def test_process_prompt_queue_reraises_switch_for_primary(self, mock_io): + """A primary coder still propagates its own SwitchCoderSignal.""" + from cecli.coders.base_coder import Coder + + coder = _make_coder_with_commands(("raises-switch",)) + coder.uuid = "primary-uuid" + coder.parent_uuid = "" + coder.io = mock_io + coder.args = MagicMock() + + async def signal_generate(text, preproc=True): + raise SwitchCoderSignal(edit_format="agent", from_coder=coder) + + coder.generate = AsyncMock(side_effect=signal_generate) + + with pytest.raises(SwitchCoderSignal): + await Coder.process_prompt_queue(coder) + + assert coder._processing_queue is False + + +class TestTUICompletionApplication: + """Applying nested completions must preserve the host and inner command.""" + + def _app(self, coder): + from cecli.tui.app import TUI + + app = TUI.__new__(TUI) + app.worker = MagicMock() + app.worker.coder.commands = coder.commands + return app + + def test_queue_argument_completion_preserves_inner_command(self, mock_io): + coder = _make_coder_with_commands() + app = self._app(coder) + + assert app._get_completed_text("/queue /add ", "src/main.py") == ("/queue /add src/main.py") + assert app._get_completed_text("/queue /add sr", "src/main.py") == ( + "/queue /add src/main.py" + ) + + def test_insert_queue_argument_completion_preserves_inner_command(self, mock_io): + coder = _make_coder_with_commands() + app = self._app(coder) + + assert ( + app._get_completed_text("/insert-queue 1 /save-session ", "auto-save") + == "/insert-queue 1 /save-session auto-save" + ) + + def test_non_nested_argument_completion_keeps_existing_behavior(self, mock_io): + coder = _make_coder_with_commands() + app = self._app(coder) + + assert app._get_completed_text("/add sr", "src/main.py") == "/add src/main.py" + + +# Nested completion tests + + +class TestNestedCompletions: + """Unit tests for nested slash-command completions in /queue and /insert-queue.""" + + def test_ntc_01_queue_prefix_matches_registry_commands(self, mock_io): + """NTC-01: /queue /sa offers inner commands whose names start with "sa".""" + coder = _make_coder_with_commands() + + result = QueueCommand.get_completions(mock_io, coder, "/sa") + + assert "/save-session" in result + assert all(name.startswith("/sa") for name in result) + assert result == sorted(result) + + def test_ntc_02_queue_full_inner_command_delegates_to_original(self, mock_io): + """NTC-02: /queue /add delegates to the original command via + Commands.get_completions so file names are offered like /add .""" + coder = _make_coder_with_commands() + file_completions = ["file_a.py", "file_b.py"] + coder.commands.get_completions = MagicMock(return_value=file_completions) + + result = QueueCommand.get_completions(mock_io, coder, "/add ") + + assert result == file_completions + coder.commands.get_completions.assert_called_once_with("/add", "", coder=coder) + + def test_ntc_03_queue_save_session_delegates_session_names(self, mock_io): + """NTC-03: /queue /save-session delegates to SaveSessionCommand so + existing session names are offered exactly like /save-session .""" + coder = _make_coder_with_commands() + session_names = ["auto-save", "feature-x"] + coder.commands.get_completions = MagicMock(return_value=session_names) + + result = QueueCommand.get_completions(mock_io, coder, "/save-session ") + + assert result == session_names + coder.commands.get_completions.assert_called_once_with("/save-session", "", coder=coder) + + def test_ntc_04_queue_plain_text_returns_empty(self, mock_io): + """NTC-04: non-command queued text (and empty args) have no completions.""" + coder = _make_coder_with_commands() + + assert QueueCommand.get_completions(mock_io, coder, "plain text") == [] + assert QueueCommand.get_completions(mock_io, coder, "") == [] + + def test_ntc_05_queue_unknown_inner_command_returns_empty(self, mock_io): + """NTC-05: delegation errors (e.g. unknown inner command) degrade to [].""" + coder = _make_coder_with_commands() + + def boom(cmd, args="", coder=None): + raise RuntimeError("unknown command") + + coder.commands.get_completions = boom + + assert QueueCommand.get_completions(mock_io, coder, "/no-such-cmd arg ") == [] + + def test_ntc_06_insert_queue_delegates_after_index(self, mock_io): + """NTC-06: /insert-queue 1 /save-session delegates after the index.""" + coder = _make_coder_with_commands() + session_names = ["auto-save"] + coder.commands.get_completions = MagicMock(return_value=session_names) + + result = InsertQueueCommand.get_completions(mock_io, coder, "1 /save-session ") + + assert result == session_names + coder.commands.get_completions.assert_called_once_with("/save-session", "", coder=coder) + + def test_ntc_07_insert_queue_index_only_suggests_positions(self, mock_io): + """NTC-07: while typing the index, /insert-queue offers clamped positions.""" + coder = _make_coder_with_commands() + command_queue.enqueue_prompt(coder, "only item") + + result = InsertQueueCommand.get_completions(mock_io, coder, "1") + + # Queue holds 1 item, so positions 1..2 (append at end) are valid. + assert result == ["1", "2"] + + +# Lifecycle tests class TestQueueLifecycle: @@ -550,7 +1039,12 @@ async def test_etc_06_management_commands_dont_trigger_processing( self, mock_io, clean_commands ): """ETC-06: Management commands do not trigger auto-processing of queued items.""" - assert clean_commands._MANAGEMENT_COMMANDS == {"queue", "list-queue", "remove-queue"} + assert clean_commands._MANAGEMENT_COMMANDS == { + "queue", + "list-queue", + "remove-queue", + "run-queue", + } @pytest.mark.asyncio async def test_etc_05_prevent_infinite_loop(self, populated_queue): @@ -590,9 +1084,7 @@ async def test_etc_10_full_lifecycle_sequence(self, mock_io, populated_queue): assert command_queue.get_queue_length(populated_queue) == 3 -# ============================================================================ -# Regression Tests - Existing Command Integrity (Section 10.5, RTC-01 through RTC-05) -# ============================================================================ +# Regression tests class TestRegression: @@ -639,7 +1131,7 @@ def test_rtc_05_thread_safety_under_concurrent_access(self, clean_commands): coder = clean_commands.coder command_queue.enqueue_prompt(coder, "item") - assert isinstance(coder._queue_lock, threading.Lock) + assert isinstance(coder._queue_lock, type(threading.Lock())) # ============================================================================ @@ -691,6 +1183,7 @@ def test_all_queue_commands_registered(self): assert "queue" in commands assert "list-queue" in commands assert "remove-queue" in commands + assert "run-queue" in commands def test_command_classes_have_required_attributes(self): """Verify command classes have NORM_NAME and DESCRIPTION.""" @@ -703,6 +1196,9 @@ def test_command_classes_have_required_attributes(self): assert RemoveQueueCommand.NORM_NAME == "remove-queue" assert RemoveQueueCommand.DESCRIPTION is not None + assert RunQueueCommand.NORM_NAME == "run-queue" + assert RunQueueCommand.DESCRIPTION is not None + def test_command_classes_have_execute_method(self): """Verify command classes have async execute method.""" assert hasattr(QueueCommand, "execute") @@ -712,6 +1208,7 @@ def test_command_classes_have_execute_method(self): assert callable(QueueCommand.execute) assert callable(ListQueueCommand.execute) assert callable(RemoveQueueCommand.execute) + assert callable(RunQueueCommand.execute) # ============================================================================ @@ -761,6 +1258,155 @@ def test_reload_program_signal_message(self): assert signal.message == "Custom message" +# Completer plumbing + + +class TestCompleterPlumbing: + """Full-args completion plumbing through Commands.get_completions. + + These assert the completer-level call path (``/queue`` receives the FULL + text after the command name), not just the inner command class (which is + already covered by TestNestedCompletions). + """ + + def test_tcp_01_queue_add_trailing_space_delegates_empty_inner_args(self, monkeypatch): + """TCP-01: /queue /add reaches AddCommand with "" (trailing space).""" + from cecli.commands.add import AddCommand + + coder = _make_coder_with_commands() + seen = [] + + def spy(cls, io, coder_, args): + seen.append(args) + return ["file_a.py", "file_b.py"] + + monkeypatch.setattr(AddCommand, "get_completions", classmethod(spy)) + + result = coder.commands.get_completions("/queue", args="/add ") + + assert result == ["file_a.py", "file_b.py"] + assert seen == [""] + + def test_tcp_02_queue_add_partial_word_delegates_partial(self, monkeypatch): + """TCP-02: /queue /add s reaches AddCommand with "s".""" + from cecli.commands.add import AddCommand + + coder = _make_coder_with_commands() + seen = [] + + def spy(cls, io, coder_, args): + seen.append(args) + return ["src/"] + + monkeypatch.setattr(AddCommand, "get_completions", classmethod(spy)) + + result = coder.commands.get_completions("/queue", args="/add s") + + assert result == ["src/"] + assert seen == ["s"] + + def test_tcp_03_queue_inner_command_names(self): + """TCP-03: /queue /sa surfaces matching inner command names.""" + coder = _make_coder_with_commands() + + result = coder.commands.get_completions("/queue", args="/sa") + + assert "/save-session" in result + assert all(name.startswith("/sa") for name in result) + + def test_tcp_04_queue_save_session_delegates_session_names(self, monkeypatch): + """TCP-04: /queue /save-session pre reaches SaveSessionCommand with "pre".""" + from cecli.commands.save_session import SaveSessionCommand + + coder = _make_coder_with_commands() + seen = [] + + def spy(cls, io, coder_, args): + seen.append(args) + return ["presentation"] + + monkeypatch.setattr(SaveSessionCommand, "get_completions", classmethod(spy)) + + result = coder.commands.get_completions("/queue", args="/save-session pre") + + assert result == ["presentation"] + assert seen == ["pre"] + + def test_tcp_05_queue_plain_text_returns_empty(self): + """TCP-05: /queue plain text has no completions.""" + coder = _make_coder_with_commands() + + assert coder.commands.get_completions("/queue", args="plain text") == [] + + def test_tcp_06_insert_queue_delegates_after_index(self, monkeypatch): + """TCP-06: /insert-queue 1 /add delegates to AddCommand after index.""" + from cecli.commands.add import AddCommand + + coder = _make_coder_with_commands() + seen = [] + + def spy(cls, io, coder_, args): + seen.append(args) + return ["file_a.py"] + + monkeypatch.setattr(AddCommand, "get_completions", classmethod(spy)) + + result = coder.commands.get_completions("/insert-queue", args="1 /add ") + + assert result == ["file_a.py"] + assert seen == [""] + + def test_tcp_07_io_partial_derivation(self): + """TCP-07: io.get_command_completions derives the last-word partial from + the FULL nested text ("" after a trailing space, "s" for "/add s") and + forwards the full nested text (trailing space preserved) to the command. + """ + from cecli.io import AutoCompleter + + completer = AutoCompleter.__new__(AutoCompleter) + captured = [] + commands = MagicMock() + commands.FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue") + # Mirror Commands.matching_commands: match the input's first word. + commands.matching_commands = lambda text: ( + ( + ["/queue"] + if text.startswith("/queue") + else (["/add"] if text.startswith("/add") else None) + ), + text.split()[0], + "", + ) + commands.get_raw_completions = lambda cmd: None + + def fake_get_completions(cmd, args="", coder=None): + captured.append((cmd, args)) + return ["file_a.py"] + + commands.get_completions = fake_get_completions + completer.commands = commands + + # Trailing space: nested text keeps the space; partial is empty. + results = list( + completer.get_command_completions(None, None, "/queue /add ", ["/queue", "/add"]) + ) + assert captured[-1] == ("/queue", "/add ") + assert all(c.start_position == 0 for c in results) + + # Partial word: nested text is forwarded verbatim; partial is the word. + results = list( + completer.get_command_completions(None, None, "/queue /add s", ["/queue", "/add", "s"]) + ) + assert captured[-1] == ("/queue", "/add s") + assert all(c.start_position == -1 for c in results) + + # Non-nested commands keep the legacy last-word partial. + commands.FULL_ARGS_COMPLETION_COMMANDS = () + captured.clear() + list(completer.get_command_completions(None, None, "/add s", ["/add", "s"])) + assert captured[-1] == ("/add", "s") + + # ============================================================================ # Async Test Configuration # ============================================================================ diff --git a/tests/helpers/observations/test_observation_service.py b/tests/helpers/observations/test_observation_service.py index a8a438288b8..79a70aaece2 100644 --- a/tests/helpers/observations/test_observation_service.py +++ b/tests/helpers/observations/test_observation_service.py @@ -247,3 +247,48 @@ def fake_summarize(messages, prompt, max_tokens=None, coder=None): # The observation prompt is appended as the final user message assert sent_messages[-1]["role"] == "user" assert "Observation Prompt" in sent_messages[-1]["content"] + + +@pytest.mark.asyncio +async def test_compaction_bails_out_when_fixed_context_dominates(): + """A large file in the chat cannot be compacted away, so bail instead of looping.""" + from cecli.coders.base_coder import Coder + + coder = MagicMock(spec=Coder) + coder.uuid = "test-coder-fixed-context" + coder.enable_context_compaction = True + coder.context_compaction_max_tokens = 1000 + coder.context_compaction_summary_tokens = 100 + coder.io = MagicMock() + coder.gpt_prompts = MagicMock() + coder._rate_limit_sleep = AsyncMock() + + # done/cur/diff/file_context are empty, so the entire context is the fixed + # context (chat files, repo map, prompts). Summarizing cannot shrink it. + coder.summarizer = MagicMock() + coder.summarizer.count_tokens.side_effect = [0, 0, 0, 0, 6000] + coder.summarizer.summarize_all_as_text = AsyncMock(return_value="Summary Text") + + mock_conv_manager = MagicMock() + mock_conv_manager.get_messages_dict.return_value = [] + + obs = MagicMock() + obs.check_and_trigger = AsyncMock() + + with ( + patch( + "cecli.coders.base_coder.ConversationService.get_manager", + return_value=mock_conv_manager, + ), + patch( + "cecli.coders.base_coder.ObservationService.get_instance", + return_value=obs, + ), + ): + await Coder.compact_context_if_needed(coder, force=True) + + # Compaction must not summarize anything... + coder.summarizer.summarize_all_as_text.assert_not_called() + # ...and must tell the user why, with a remedy. + coder.io.tool_warning.assert_called_once() + coder.io.tool_output.assert_any_call("- Use /drop to remove unneeded files from the chat") diff --git a/tests/helpers/scheduler/test_service.py b/tests/helpers/scheduler/test_service.py index 4c20de93bde..1f14237d932 100644 --- a/tests/helpers/scheduler/test_service.py +++ b/tests/helpers/scheduler/test_service.py @@ -6,6 +6,7 @@ """ import asyncio +import threading from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -385,3 +386,63 @@ async def stubborn(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task + + +class TestCoderLoopBridge: + """Scheduled runs must execute on the coder's own (worker) event loop.""" + + def test_coder_loop_is_none_without_tui(self): + """A coder with no resolvable worker loop (linear/test) bridges to the current loop.""" + service = _service([make_wf()]) + + assert service._coder_loop() is None + + @pytest.mark.asyncio + async def test_on_coder_loop_awaits_directly_without_tui(self): + service = _service([make_wf()]) + seen = [] + + async def make_coro(): + seen.append(asyncio.get_running_loop()) + return "ok" + + result = await asyncio.wait_for(service._on_coder_loop(make_coro), timeout=5) + + assert result == "ok" + assert seen == [asyncio.get_running_loop()] + + @pytest.mark.asyncio + async def test_on_coder_loop_bridges_to_worker_loop(self): + """A scheduled dispatch must be awaited on the coder's worker-thread loop.""" + service = _service([make_wf()]) + foreign = asyncio.new_event_loop() + started = threading.Event() + + def run_foreign(): + asyncio.set_event_loop(foreign) + foreign.call_soon(started.set) + foreign.run_forever() + + thread = threading.Thread(target=run_foreign, daemon=True) + thread.start() + + try: + assert started.wait(timeout=5) + service.coder.tui = lambda: SimpleNamespace(worker=SimpleNamespace(loop=foreign)) + + assert service._coder_loop() is foreign + + seen = [] + + async def make_coro(): + seen.append(asyncio.get_running_loop()) + return "bridged" + + result = await asyncio.wait_for(service._on_coder_loop(make_coro), timeout=5) + + assert result == "bridged" + assert seen == [foreign] + finally: + foreign.call_soon_threadsafe(foreign.stop) + thread.join(timeout=5) + foreign.close() diff --git a/tests/helpers/server/acp/test_agent.py b/tests/helpers/server/acp/test_agent.py new file mode 100644 index 00000000000..0ef33b6f1a8 --- /dev/null +++ b/tests/helpers/server/acp/test_agent.py @@ -0,0 +1,1010 @@ +"""Tests for the ACP v1 agent (``cecli --acp``).""" + +from __future__ import annotations + +import asyncio +import os +import threading +from types import SimpleNamespace + +import pytest + +from cecli import models +from cecli.helpers.server.acp.agent import config as acp_config +from cecli.helpers.server.acp.agent.agent import AcpAgent, prompt_to_text +from cecli.helpers.server.acp.agent.config import SessionConfig, current_value +from cecli.helpers.server.acp.agent.io import AcpInputOutput +from cecli.helpers.server.acp.agent.mcp import ClientMcpServers, to_cecli_configs +from cecli.helpers.server.acp.agent.transport import ( + EOF, + INTERNAL_ERROR, + INVALID_PARAMS, + METHOD_NOT_FOUND, +) +from cecli.helpers.server.acp.agent.updates import UpdateEmitter +from cecli.reasoning_tags import REASONING_END, REASONING_START + + +@pytest.fixture(autouse=True) +def configured_models(monkeypatch): + """User aliases and model files as main() would register them, without reading ~.""" + monkeypatch.setitem(models.MODEL_ALIASES, "fast", "gpt-x") + monkeypatch.setitem(models.MODEL_ALIASES, "smart", "big-model") + monkeypatch.setattr( + models.ModelOverrides, "_overrides", {"gpt-y": {"high": {"reasoning_effort": "high"}}} + ) + monkeypatch.setattr(acp_config, "settings_file_models", lambda coder: ["local/qwen"]) + # No provider API keys, whatever the environment running the tests has + monkeypatch.setattr(acp_config, "keyed_provider_models", lambda: []) + + +# ── UpdateEmitter ────────────────────────────────────────────── + + +def make_emitter(): + updates = [] + return UpdateEmitter(updates.append), updates + + +def texts(updates, kind): + return "".join(u["content"]["text"] for u in updates if u["sessionUpdate"] == kind) + + +def test_reasoning_markers_split_across_chunks(): + emitter, updates = make_emitter() + stream = f"\n{REASONING_START}pondering{REASONING_END}\nThe answer." + for i in range(0, len(stream), 3): + emitter.stream(stream[i : i + 3]) + emitter.stream("", final=True) + + assert texts(updates, "agent_thought_chunk").strip() == "pondering" + assert texts(updates, "agent_message_chunk").strip() == "The answer." + # The newline ahead of the marker must not become its own message + assert updates[0]["sessionUpdate"] == "agent_thought_chunk" + + +def test_console_lines_share_one_fence_closed_by_model_text(): + emitter, updates = make_emitter() + emitter.stream("Intro.") + emitter.block("line one") + emitter.block("line two") + emitter.stream("More prose.") + + text = texts(updates, "agent_message_chunk") + assert text == "Intro.\n\n````\nline one\nline two\n````\n\nMore prose." + + +def test_tool_lifecycle_and_output(): + emitter, updates = make_emitter() + emitter.root = "/proj" + emitter.tool_event("t1", "pending", name="Local--ReadFile", arguments='{"file_path": "a.py"}') + emitter.tool_header("Tool Call: Local • ReadFile") + emitter.block("Arguments: {...}") # panel body is dropped + emitter.tool_footer() + emitter.tool_event("t1", "in_progress") + emitter.block("Viewed a.py") + emitter.tool_event("t1", "completed", result="contents") + + call = updates[0] + assert call["sessionUpdate"] == "tool_call" + assert call["title"] == "ReadFile: a.py" + assert call["kind"] == "read" + assert call["locations"] == [{"path": "/proj/a.py"}] + assert call["rawInput"] == {"file_path": "a.py"} + + statuses = [u.get("status") for u in updates if u["sessionUpdate"] == "tool_call_update"] + assert statuses == ["in_progress", None, "completed"] + content = updates[2]["content"][0]["content"]["text"] + assert "Viewed a.py" in content and "Arguments" not in content + assert texts(updates, "agent_message_chunk") == "" + + +def test_result_shown_when_tool_printed_nothing(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Grep", arguments="{}") + emitter.tool_event("t1", "in_progress") + emitter.tool_event("t1", "completed", result="match.py:3") + + assert "match.py:3" in updates[-2]["content"][0]["content"]["text"] + assert updates[-1]["status"] == "completed" + + +def test_model_text_settles_running_tools(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Command", arguments='{"command": "ls"}') + emitter.tool_event("t1", "in_progress") + emitter.stream("Done.") + + # Not "execute", which would hide Zed's progress spinner while it runs + assert updates[0]["kind"] == "other" + assert { + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "status": "completed", + } in updates + + +def test_finish_turn_fails_open_tools_when_cancelled(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Command", arguments="{}") + emitter.tool_event("t1", "in_progress") + emitter.finish_turn(cancelled=True) + + assert updates[-1] == { + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "status": "failed", + } + + +def test_finish_turn_fails_skipped_tools(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Command", arguments="{}") + emitter.finish_turn() + + assert updates[-1] == { + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "status": "failed", + } + + +def test_todo_list_becomes_plan_and_yield_summary_a_message(): + emitter, updates = make_emitter() + tasks = [ + {"task": "Read", "done": True}, + {"task": "Write", "done": False, "current": True}, + {"task": "Test", "done": False}, + ] + emitter.tool_event("t1", "pending", name="UpdateTodoList", arguments={"tasks": tasks}) + plan = next(u for u in updates if u["sessionUpdate"] == "plan") + assert [e["status"] for e in plan["entries"]] == ["completed", "in_progress", "pending"] + + emitter.tool_event("t2", "pending", name="Yield", arguments={"summary": "All done."}) + emitter.tool_event("t2", "in_progress") + emitter.tool_event("t2", "completed", result="Yielded.") + assert texts(updates, "agent_message_chunk").strip() == "All done." + + +def test_permission_target_prefers_the_obvious_tool(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Command", arguments="{}") + assert emitter.permission_target("Run tools?", None) == ("t1", False) + + emitter.tool_event("t2", "pending", name="Grep", arguments="{}") + tool_id, synthetic = emitter.permission_target("Run tools?", None) + assert synthetic and tool_id.startswith("cecli-confirm-") + + # Rejecting a group prompt fails every tool waiting behind it + emitter.permission_resolved(tool_id, True, allowed=False) + failed = {u["toolCallId"] for u in updates if u.get("status") == "failed"} + assert failed == {tool_id, "t1", "t2"} + + +def test_permission_question_shows_until_answered(): + emitter, updates = make_emitter() + emitter.tool_event("t1", "pending", name="Command", arguments='{"command": "ls"}') + emitter.tool_event("t1", "in_progress") + + # A tool can be asked about twice; each request carries its own question + for question in ("Allow execution of this command?", "Allow this command for the session?"): + prompt = emitter.permission_prompt("t1", question) + assert prompt["content"][0]["content"]["text"] == question + emitter.permission_resolved("t1", False, allowed=True) + assert updates[-1] == { + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "content": [], + } + + # Output printed later replaces the content as usual + emitter.block("a.py") + assert "a.py" in updates[-1]["content"][0]["content"]["text"] + assert emitter.permission_prompt("cecli-confirm-9", "Run?") == {"toolCallId": "cecli-confirm-9"} + + +# ── Prompt conversion ────────────────────────────────────────── + + +def test_prompt_to_text(tmp_path): + root = str(tmp_path) + blocks = [ + {"type": "text", "text": "Explain"}, + {"type": "resource_link", "name": "a.py", "uri": (tmp_path / "a.py").as_uri()}, + { + "type": "resource", + "resource": {"uri": (tmp_path / "b.py").as_uri() + "#L2:3", "text": "x = 1"}, + }, + {"type": "resource", "resource": {"uri": "zed:///rules", "text": "Be terse."}}, + ] + text, paths = prompt_to_text(blocks, root) + + assert text.startswith("Explain a.py") + assert "b.py:\n```\nx = 1\n```" in text + assert "Be terse." in text + assert [os.path.normpath(p) for p in paths] == [str(tmp_path / "a.py"), str(tmp_path / "b.py")] + + +# ── AcpAgent ─────────────────────────────────────────────────── + + +class FakeTransport: + def __init__(self): + self.sent = [] + self._inbox = None + + def attach(self, loop): + self._inbox = asyncio.Queue() + + def detach(self): + pass + + async def receive(self): + return await self._inbox.get() + + def feed(self, msg): + self._inbox.put_nowait(msg) + + def send(self, msg): + self.sent.append(msg) + + def results(self): + return {m["id"]: m for m in self.sent if "method" not in m} + + def updates(self): + return [m["params"]["update"] for m in self.sent if m.get("method") == "session/update"] + + +class StubModel: + def __init__(self, name, accepts=("reasoning_effort",), effort=None, budget=None): + self.name = name + self.accepts_settings = list(accepts) + self.effort = effort + self.budget = budget + + def get_reasoning_effort(self): + return self.effort + + def get_raw_thinking_tokens(self): + return self.budget + + def get_thinking_tokens(self): + return None if self.budget is None else f"{self.budget // 1024}k" + + +class StubCoder: + def __init__(self, root, model=None, edit_format="diff"): + self.root = str(root) + self.abs_fnames = set() + self.abs_read_only_fnames = set() + self.io = SimpleNamespace(output_task=None, yes=None, tool_output=lambda *a, **k: None) + self.interrupt_event = threading.Event() + self.main_model = model or StubModel("gpt-x") + self.agent_model = None + self.edit_format = edit_format + self.args = SimpleNamespace(model="fast", alias=["fast:gpt-x", "smart:big-model"]) + self.repo = None + + def get_active_model(self): + if self.edit_format == "agent" and self.agent_model: + return self.agent_model + return self.main_model + + async def forever(self): + await asyncio.sleep(3600) + + +def make_coder(root): + return StubCoder(root) + + +async def ready_agent(tmp_path): + """An agent past bootstrap, with session/new (id 2) deferred.""" + transport = FakeTransport() + agent = AcpAgent(transport) + agent.cwd = str(tmp_path) + agent.session_id = "s1" + agent._deferred_new.append(2) + agent.start() + return agent, transport + + +async def settle(): + for _ in range(5): + await asyncio.sleep(0) + + +def prompt(request_id, text, session_id="s1"): + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "session/prompt", + "params": {"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}, + } + + +async def test_initialize_and_unknown_method(tmp_path): + agent, transport = await ready_agent(tmp_path) + transport.feed({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + transport.feed({"jsonrpc": "2.0", "id": 9, "method": "session/load", "params": {}}) + await settle() + + result = transport.results()[1]["result"] + assert result["protocolVersion"] == 1 + assert result["agentCapabilities"]["loadSession"] is False + assert transport.results()[9]["error"]["code"] == METHOD_NOT_FOUND + + +async def test_turn_lifecycle(tmp_path): + agent, transport = await ready_agent(tmp_path) + coder = make_coder(tmp_path) + + # Prompts are rejected until the coder is ready (the client has no id yet) + transport.feed(prompt(3, "too early")) + await settle() + assert "error" in transport.results()[3] + + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + session = transport.results()[2]["result"] + assert session["sessionId"] == "s1" + assert [o["id"] for o in session["configOptions"]] == ["mode", "model", "reasoning_effort"] + assert session["modes"]["currentModeId"] == "code" + assert transport.updates()[0]["sessionUpdate"] == "available_commands_update" + names = {c["name"] for c in transport.updates()[0]["availableCommands"]} + assert "add" in names and "exit" not in names + + transport.feed(prompt(4, "hello")) + assert await waiting == "hello" + + agent.emitter.stream("Hi there.", final=True) + assert 4 not in transport.results() + + # The loop asking for input again ends the turn + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + assert transport.results()[4]["result"] == {"stopReason": "end_turn"} + # The turn's updates all precede its response + kinds = [m.get("method") or m.get("id") for m in transport.sent] + assert kinds[-2:] == ["session/update", 4] + assert transport.updates()[-1]["content"]["text"] == "Hi there." + waiting.cancel() + + +async def test_cancel_interrupts_coder(tmp_path): + agent, transport = await ready_agent(tmp_path) + coder = make_coder(tmp_path) + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + transport.feed(prompt(4, "long job")) + await waiting + + coder.io.output_task = asyncio.create_task(coder.forever()) + transport.feed({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": "s1"}}) + await settle() + assert coder.io.output_task.cancelled() + assert coder.interrupt_event.is_set() + + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + assert transport.results()[4]["result"] == {"stopReason": "cancelled"} + waiting.cancel() + + +async def test_permission_roundtrip_through_confirm_ask(tmp_path): + agent, transport = await ready_agent(tmp_path) + io = AcpInputOutput(agent=agent) + waiting = asyncio.create_task(agent.next_input(make_coder(tmp_path))) + await settle() + transport.feed(prompt(4, "do it")) + await waiting + + asking = asyncio.create_task(io.confirm_ask("Run tools?", group_response="Run MCP Tools")) + await settle() + request = next(m for m in transport.sent if m.get("method") == "session/request_permission") + assert {o["optionId"] for o in request["params"]["options"]} == {"y", "a", "s", "n"} + + transport.feed( + { + "jsonrpc": "2.0", + "id": request["id"], + "result": {"outcome": {"outcome": "selected", "optionId": "a"}}, + } + ) + assert await asking is True + # "Yes to all" sticks, so the next group prompt is not sent to the client + assert await io.confirm_ask("Run tools?", group_response="Run MCP Tools") is True + assert sum(m.get("method") == "session/request_permission" for m in transport.sent) == 1 + + +async def test_confirm_outside_a_prompt_declines(tmp_path): + agent, transport = await ready_agent(tmp_path) + io = AcpInputOutput(agent=agent) + assert await io.confirm_ask("Add .cecli* to .gitignore?") is False + assert not any(m.get("method") == "session/request_permission" for m in transport.sent) + + +async def test_usage_report_is_not_shown(tmp_path): + agent, transport = await ready_agent(tmp_path) + io = AcpInputOutput(agent=agent) + waiting = asyncio.create_task(agent.next_input(make_coder(tmp_path))) + await settle() + transport.feed(prompt(4, "hi")) + await waiting + + io.tool_output("18k ◇ 95.4% ↑ 139 ↓ $0.00015", type="usage-report") + io.tool_output("Added a.py to the chat") + agent.emitter.stream("Done.", final=True) + shown = "".join(u["content"]["text"] for u in transport.updates() if "content" in u) + assert "Added a.py" in shown + assert "$0.00015" not in shown + + +async def test_eof_ends_the_input_loop(tmp_path): + agent, transport = await ready_agent(tmp_path) + waiting = asyncio.create_task(agent.next_input(make_coder(tmp_path))) + await settle() + transport.feed(EOF) + with pytest.raises(EOFError): + await waiting + + +async def test_second_session_clears_chat(tmp_path): + agent, transport = await ready_agent(tmp_path) + coder = make_coder(tmp_path) + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + + other = tmp_path / "other" + other.mkdir() + transport.feed( + {"jsonrpc": "2.0", "id": 5, "method": "session/new", "params": {"cwd": str(other)}} + ) + transport.feed( + {"jsonrpc": "2.0", "id": 6, "method": "session/new", "params": {"cwd": str(tmp_path)}} + ) + await settle() + assert transport.results()[5]["error"]["code"] == INVALID_PARAMS + + # The new session runs /clear as an internal turn before it is answered + assert await waiting == "/clear" + assert 6 not in transport.results() + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + new_id = transport.results()[6]["result"]["sessionId"] + assert new_id != "s1" and agent.session_id == new_id + + transport.feed(prompt(7, "stale", session_id="s1")) + await settle() + assert "error" in transport.results()[7] + waiting.cancel() + + +# ── Sub-agents ───────────────────────────────────────────────── + + +def notifications(transport): + """``(sessionId, update)`` for each session/update sent.""" + return [ + (m["params"]["sessionId"], m["params"]["update"]) + for m in transport.sent + if m.get("method") == "session/update" + ] + + +def subagent_states(transport): + return [ + (session_id, update["sessionId"], update["state"]) + for session_id, update in notifications(transport) + if update["sessionUpdate"] == "subagent_update" and "state" in update + ] + + +async def delegating_agent(tmp_path, subagents=True): + """An agent in the middle of a prompt, for a client with or without subagent support.""" + agent, transport = await ready_agent(tmp_path) + capabilities = {"subagents": {}} if subagents else {} + transport.feed( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": capabilities}, + } + ) + coder = make_coder(tmp_path) + coder.uuid = "primary" + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + transport.feed(prompt(4, "delegate this")) + await waiting + transport.sent.clear() + # The agent holds the coder weakly; the caller keeps it alive, as cecli's loop would + return agent, transport, AcpInputOutput(agent=agent), coder + + +async def test_subagent_is_a_child_session(tmp_path): + agent, transport, io, _coder = await delegating_agent(tmp_path) + io.subagent_event( + "w1", "created", parent_uuid="primary", name="worker", description="Does work", root="/w" + ) + io.subagent_event("w1", "message", sender_uuid="primary", text="Fix the tests") + io.subagent_event("w1", "running") + io.stream_output("On it.", final=True, coder_uuid="w1") + io.tool_call_event("t1", "pending", coder_uuid="w1", name="ReadFile", arguments='{"path": "a"}') + io.stream_output("Meanwhile, the parent.", final=True, coder_uuid="primary") + io.subagent_event("w1", "idle", stop_reason="end_turn", error=None) + + sent = notifications(transport) + child = agent.subagents["w1"].session_id + # Announced on the parent's session before anything names the child + assert sent[0] == ( + "s1", + { + "sessionUpdate": "subagent_update", + "sessionId": child, + "capabilities": {"cancel": {}}, + "title": "worker", + "description": "Does work", + }, + ) + # The delegation as the parent sent it, then as the child got it + (out_session, outgoing), (in_session, incoming) = sent[1], sent[2] + assert (out_session, in_session) == ("s1", child) + for message in (outgoing, incoming): + assert message["sessionUpdate"] == "session_message" + assert (message["senderSessionId"], message["recipientSessionId"]) == ("s1", child) + assert message["content"] == [{"type": "text", "text": "Fix the tests"}] + assert outgoing["messageId"] != incoming["messageId"] + + # Each agent's output lands in its own session + child_updates = [update for session_id, update in sent if session_id == child] + assert texts(child_updates, "agent_message_chunk") == "On it." + call = next(u for u in child_updates if u["sessionUpdate"] == "tool_call") + assert call["locations"] == [{"path": "/w/a"}] + parent_updates = [update for session_id, update in sent if session_id == "s1"] + assert texts(parent_updates, "agent_message_chunk") == "Meanwhile, the parent." + + # Going idle settles the child's tools; this one was announced but never ran + assert child_updates[-1]["status"] == "failed" + assert subagent_states(transport) == [ + ("s1", child, {"state": "running"}), + ("s1", child, {"state": "idle", "stopReason": "end_turn"}), + ] + + +async def test_subagents_stay_in_the_parent_session_without_client_support(tmp_path): + agent, transport, io, _coder = await delegating_agent(tmp_path, subagents=False) + io.subagent_event("w1", "created", parent_uuid="primary", name="worker") + io.subagent_event("w1", "message", sender_uuid="primary", text="Fix the tests") + io.subagent_event("w1", "running") + io.stream_output("On it.", final=True, coder_uuid="w1") + + sent = notifications(transport) + assert [(session_id, u["sessionUpdate"]) for session_id, u in sent] == [ + ("s1", "agent_message_chunk") + ] + + +async def test_nested_subagent_reports_to_its_parent(tmp_path): + agent, transport, io, _coder = await delegating_agent(tmp_path) + io.subagent_event("w1", "created", parent_uuid="primary", name="worker") + io.subagent_event("h1", "created", parent_uuid="w1", name="helper") + io.subagent_event("h1", "running") + io.subagent_event("h1", "idle", stop_reason="error", error="boom") + io.subagent_event("w1", "message", sender_uuid="h1", text="The helper agent failed") + io.subagent_event("h1", "removed") + + child, grandchild = agent.subagents["w1"].session_id, agent.subagents["h1"].session_id + sent = notifications(transport) + assert sent[1][0] == child and sent[1][1]["sessionId"] == grandchild + assert subagent_states(transport) == [ + (child, grandchild, {"state": "running"}), + ( + child, + grandchild, + { + "state": "idle", + "stopReason": "error", + "error": {"code": INTERNAL_ERROR, "message": "boom"}, + }, + ), + ] + reply = [(s, u) for s, u in sent if u["sessionUpdate"] == "session_message"] + assert [s for s, _ in reply] == [grandchild, child] + assert all(u["senderSessionId"] == grandchild for _, u in reply) + # Once reaped, it can no longer be cancelled + assert sent[-1] == ( + child, + {"sessionUpdate": "subagent_update", "sessionId": grandchild, "capabilities": None}, + ) + + +def answer(transport, request, option_id): + transport.feed( + { + "jsonrpc": "2.0", + "id": request["id"], + "result": {"outcome": {"outcome": "selected", "optionId": option_id}}, + } + ) + + +async def test_subagent_asks_permission_in_its_own_session(tmp_path): + agent, transport, io, _coder = await delegating_agent(tmp_path) + io.subagent_event("w1", "created", parent_uuid="primary", name="worker") + io.subagent_event("w1", "running") + child = agent.subagents["w1"].session_id + # The parent's turn is over, but the sub-agent works on in the background + agent._finish_turn() + + asking = asyncio.create_task(io.confirm_ask("Run tools?", coder_uuid="w1")) + await settle() + request = next(m for m in transport.sent if m.get("method") == "session/request_permission") + assert request["params"]["sessionId"] == child + answer(transport, request, "y") + assert await asking is True + assert [state["state"] for _, _, state in subagent_states(transport)] == [ + "running", + "requires_action", + "running", + ] + + +async def test_cancelling_a_subagent_leaves_the_rest_running(tmp_path, monkeypatch): + from cecli.helpers.agents.service import AgentService + + cancelled = [] + service = SimpleNamespace(cancel_sub_agent=cancelled.append) + monkeypatch.setattr(AgentService, "get_instance", staticmethod(lambda coder: service)) + + agent, transport, io, _coder = await delegating_agent(tmp_path) + io.subagent_event("w1", "created", parent_uuid="primary", name="worker") + io.subagent_event("w1", "running") + child = agent.subagents["w1"].session_id + asking = asyncio.create_task(io.confirm_ask("Run tools?", coder_uuid="w1")) + await settle() + + # Cancelling the parent's turn leaves a background sub-agent's question open + transport.feed({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": "s1"}}) + await settle() + assert not asking.done() and cancelled == [] + + transport.feed({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": child}}) + await settle() + assert cancelled == ["w1"] + assert await asking is False + + +# ── Config options ───────────────────────────────────────────── + + +def model_groups(option): + """``{group name: [values]}`` of the grouped model picker.""" + return {group["name"]: [v["value"] for v in group["options"]] for group in option["options"]} + + +def test_config_options_list_configured_models(tmp_path): + options = SessionConfig().options(StubCoder(tmp_path)) + mode, model, effort = options + + assert mode["currentValue"] == "code" # any edit format other than a chat mode + assert [v["value"] for v in mode["options"]] == [ + "code", + "ask", + "architect", + "context", + "agent", + "agent-yolo", + ] + + # Startup model and its alias collapse into one entry; tag overrides stay distinct + assert model_groups(model) == {"Configured": ["fast", "smart", "gpt-y:high", "local/qwen"]} + assert model["currentValue"] == "fast" + assert model["options"][0]["options"][1]["description"] == "big-model" + + assert effort["category"] == "thought_level" + assert effort["currentValue"] == "default" + assert [v["value"] for v in effort["options"]] == ["default", "low", "medium", "high"] + + +def test_config_options_follow_the_running_model(tmp_path): + config = SessionConfig() + coder = StubCoder(tmp_path, model=StubModel("other-model", accepts=(), effort="xhigh")) + model = current_value(config.options(coder), "model") + assert model == "other-model" # switched with a typed /model: listed first + + options = config.options(coder) + assert model_groups(options[1])["Configured"][0] == "other-model" + # An effort set elsewhere is shown even though it is not a standard choice + assert options[2]["currentValue"] == "xhigh" + + coder.main_model = StubModel("claude-x", accepts=("thinking_tokens",), budget=2048) + effort = config.options(coder)[2] + assert effort["id"] == "thinking_tokens" + assert effort["currentValue"] == "2k" + # A model's own default budget slots in by size + assert [v["value"] for v in effort["options"]] == ["0", "2k", "4k", "8k", "16k", "32k"] + + coder.main_model = StubModel("plain", accepts=()) + assert [o["id"] for o in config.options(coder)] == ["mode", "model"] + + +def test_model_picker_lists_providers_with_a_key(tmp_path, monkeypatch): + monkeypatch.setattr( + acp_config, + "keyed_provider_models", + lambda: [ + ("openai", "OpenAI", ["openai/gpt-5", "openai/gpt-5-mini"]), + ("anthropic", "Anthropic", ["anthropic/claude-sonnet-4-5", "big-model"]), + ], + ) + coder = StubCoder(tmp_path) + config = SessionConfig() + + model = config.options(coder)[1] + # "big-model" is already listed as the "smart" alias + assert model_groups(model) == { + "Configured": ["fast", "smart", "gpt-y:high", "local/qwen"], + "OpenAI": ["openai/gpt-5", "openai/gpt-5-mini"], + "Anthropic": ["anthropic/claude-sonnet-4-5"], + } + assert config.command(coder, "model", "openai/gpt-5-mini") == "/model openai/gpt-5-mini" + + # The filter narrows the provider lists; configured models always stay + coder.args.acp_models = ["mini", "claude-*-4"] + model = SessionConfig().options(coder)[1] + assert model_groups(model) == { + "Configured": ["fast", "smart", "gpt-y:high", "local/qwen"], + "OpenAI": ["openai/gpt-5-mini"], + "Anthropic": ["anthropic/claude-sonnet-4-5"], + } + + +def test_model_filter_matches_like_models_command(): + assert acp_config.matches_filter("anthropic/claude-sonnet-4-5", ["SONNET"]) + assert acp_config.matches_filter("openrouter/anthropic/claude-opus-4", ["anthropic/*"]) + assert not acp_config.matches_filter("openai/gpt-5", ["claude", "gemini-*"]) + + +def test_config_choices_map_to_commands(tmp_path): + config = SessionConfig() + coder = StubCoder(tmp_path, model=StubModel("gpt-x", effort="high")) + + assert config.command(coder, "mode", "ask") == "/ask" + assert config.command(coder, "mode", "code") is None # already current + assert config.command(coder, "model", "smart") == "/model smart" + assert config.model_choice == "smart" + assert config.command(coder, "reasoning_effort", "default") == "/reasoning-effort none" + assert config.command(coder, "reasoning_effort", "low") == "/reasoning-effort low" + with pytest.raises(ValueError): + config.command(coder, "model", "not-configured") + with pytest.raises(ValueError): + config.command(coder, "thinking_tokens", "8k") # not offered for this model + + coder.main_model = StubModel("claude-x", accepts=("thinking_tokens",), budget=8192) + assert config.command(coder, "thinking_tokens", "0") == "/think-tokens 0" + + +def test_agent_mode_picks_the_agent_model(tmp_path): + config = SessionConfig() + coder = StubCoder(tmp_path, edit_format="agent") + coder.agent_model = StubModel("big-model", effort="low") + + options = config.options(coder) + assert current_value(options, "model") == "smart" + assert current_value(options, "reasoning_effort") == "low" + assert config.command(coder, "model", "fast") == "/agent-model fast" + + +def config_request(request_id, config_id, value, session_id="s1"): + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "session/set_config_option", + "params": {"sessionId": session_id, "configId": config_id, "value": value}, + } + + +async def test_set_config_option_runs_the_cecli_command(tmp_path): + agent, transport = await ready_agent(tmp_path) + waiting = asyncio.create_task(agent.next_input(StubCoder(tmp_path))) + await settle() + + # Unchanged values answer at once; unknown ones are rejected + transport.feed(config_request(3, "mode", "code")) + transport.feed(config_request(4, "mode", "yolo")) + await settle() + assert current_value(transport.results()[3]["result"]["configOptions"], "mode") == "code" + assert transport.results()[4]["error"]["code"] == INVALID_PARAMS + + transport.feed(config_request(5, "mode", "ask")) + assert await waiting == "/ask" + assert 5 not in transport.results() + + # /ask replaces the coder; the answer reflects the new one + waiting = asyncio.create_task(agent.next_input(StubCoder(tmp_path, edit_format="ask"))) + await settle() + options = transport.results()[5]["result"]["configOptions"] + assert current_value(options, "mode") == "ask" + # The client already has these options from the answer + assert not any(u["sessionUpdate"] == "config_option_update" for u in transport.updates()) + waiting.cancel() + + +async def test_typed_commands_update_the_pickers(tmp_path): + agent, transport = await ready_agent(tmp_path) + coder = StubCoder(tmp_path) + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + transport.feed(prompt(3, "/ask")) + await waiting + + waiting = asyncio.create_task(agent.next_input(StubCoder(tmp_path, edit_format="ask"))) + await settle() + kinds = [u["sessionUpdate"] for u in transport.updates()] + assert kinds[-2:] == ["config_option_update", "current_mode_update"] + assert transport.updates()[-1]["currentModeId"] == "ask" + # The pickers change before the prompt's response arrives + assert transport.sent[-1]["id"] == 3 + + # Legacy session/set_mode takes the same path + transport.feed( + { + "jsonrpc": "2.0", + "id": 4, + "method": "session/set_mode", + "params": {"sessionId": "s1", "modeId": "agent"}, + } + ) + assert await waiting == "/agent" + waiting = asyncio.create_task(agent.next_input(StubCoder(tmp_path, edit_format="agent"))) + await settle() + assert transport.results()[4]["result"] == {} + assert transport.updates()[-1] == { + "sessionUpdate": "current_mode_update", + "currentModeId": "agent", + } + waiting.cancel() + + +def test_yolo_mode_toggles_what_yolo_sets(tmp_path): + config = SessionConfig() + coder = StubCoder(tmp_path, edit_format="agent") + coder.io.yes = None + + # From Agent, YOLO is only a settings change: no coder switch needed + assert config.command(coder, "mode", "agent-yolo") is None + assert coder.io.yes is True and coder.args.yes_always_commands is True + assert current_value(config.options(coder), "mode") == "agent-yolo" + + # Back to Agent restores the confirmations + assert config.command(coder, "mode", "agent") is None + assert coder.io.yes is None and coder.args.yes_always_commands is False + + # From another mode, YOLO switches to agent mode too; leaving it turns it off + coder.edit_format = "ask" + assert config.command(coder, "mode", "agent-yolo") == "/agent" + coder.edit_format = "agent" + assert config.command(coder, "mode", "code") == "/code" + assert coder.io.yes is None and coder.args.yes_always_commands is False + + # Typing /ask while in YOLO ends it too, so the picker never hides it + assert config.command(coder, "mode", "agent-yolo") is None + coder.edit_format = "ask" + assert current_value(config.options(coder), "mode") == "ask" + assert coder.io.yes is None and coder.args.yes_always_commands is False + coder.edit_format = "agent" + + # Launched with --yolo, agent mode already shows as YOLO + coder.args.yes_always_commands, coder.io.yes = True, True + assert current_value(config.options(coder), "mode") == "agent-yolo" + assert config.command(coder, "mode", "agent") is None + assert coder.io.yes is None and coder.args.yes_always_commands is False + + +# ── Client MCP servers ───────────────────────────────────────── + +ZED_SERVERS = [ + { + "name": "GitHub", + "command": "/usr/local/bin/github-mcp", + "args": ["stdio"], + "env": [{"name": "TOKEN", "value": "t"}], + }, + {"type": "http", "name": "docs", "url": "https://example.com/mcp", "headers": []}, + {"type": "sse", "name": "events", "url": "https://example.com/sse"}, + {"name": "broken"}, # no command: ignored +] + + +def test_client_mcp_servers_become_cecli_configs(): + configs = to_cecli_configs(ZED_SERVERS) + assert set(configs) == {"github", "docs", "events"} + assert configs["github"] == { + "transport": "stdio", + "command": "/usr/local/bin/github-mcp", + "args": ["stdio"], + "env": {"TOKEN": "t"}, + "name": "GitHub", + } + assert configs["docs"]["transport"] == "http" + assert configs["events"] == { + "transport": "sse", + "url": "https://example.com/sse", + "headers": {}, + "name": "events", + } + + +class FakeMcpManager: + def __init__(self, *names): + self.servers = {n: SimpleNamespace(name=n) for n in names} + self.connected = set(names) + self.calls = [] + + def get_server(self, name): + return self.servers.get(name.lower()) + + @property + def connected_servers(self): + return [self.servers[n] for n in self.connected] + + async def add_server(self, server, connect=False): + self.servers[server.name] = server + self.calls.append(("add", server.name, type(server).__name__)) + if connect: + self.connected.add(server.name) + return True + + async def connect_server(self, name): + self.connected.add(name) + self.calls.append(("connect", name)) + return True + + async def disconnect_server(self, name): + self.connected.discard(name) + self.calls.append(("disconnect", name)) + return True + + +async def test_client_mcp_servers_sync_with_the_manager(): + logged = [] + servers = ClientMcpServers(logged.append) + manager = FakeMcpManager("docs") # cecli's own config already has "docs" + + servers.want(ZED_SERVERS) + await servers.sync(manager) + assert manager.calls == [ + ("add", "github", "McpServer"), + ("add", "events", "SseServer"), + ] + assert any("cecli has one by that name" in line for line in logged) + + # A later session drops one server and keeps the other + manager.calls.clear() + servers.want(ZED_SERVERS[:1]) + await servers.sync(manager) + assert manager.calls == [("disconnect", "events")] + + # Nothing pending: nothing happens + manager.calls.clear() + await servers.sync(manager) + assert manager.calls == [] + + +async def test_session_new_connects_client_mcp_servers_first(tmp_path): + agent, transport = await ready_agent(tmp_path) + transport.feed({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + await settle() + capabilities = transport.results()[1]["result"]["agentCapabilities"] + assert capabilities["mcpCapabilities"] == {"http": True, "sse": True} + + agent.mcp.want(ZED_SERVERS[:1]) # as bootstrap() records the first session/new + coder = StubCoder(tmp_path) + coder.mcp_manager = FakeMcpManager() + waiting = asyncio.create_task(agent.next_input(coder)) + await settle() + assert coder.mcp_manager.calls == [("add", "github", "McpServer")] + assert transport.results()[2]["result"]["sessionId"] == "s1" + waiting.cancel() diff --git a/tests/helpers/test_llms_hyperscalers.py b/tests/helpers/test_llms_hyperscalers.py index 036f16ead6d..e5264af9efc 100644 --- a/tests/helpers/test_llms_hyperscalers.py +++ b/tests/helpers/test_llms_hyperscalers.py @@ -153,6 +153,19 @@ def test_bedrock_endpoint_requires_region(): raise AssertionError("expected ValueError without a region") +def test_bedrock_endpoint_built_from_resolved_api_base(monkeypatch): + # The Converse URL is assembled from resolved["api_base"] verbatim rather + # than a hardcoded public host, so a custom endpoint (VPC endpoint, proxy, + # LocalStack, ...) resolved onto api_base is honored. + monkeypatch.setenv("AWS_REGION_NAME", "us-east-1") + resolved = { + "route": "anthropic.claude-sonnet", + "api_base": "https://bedrock.internal.example.com", + } + url = bedrock_endpoint(resolved) + assert url == ("https://bedrock.internal.example.com/model/anthropic.claude-sonnet/converse") + + # --------------------------------------------------------------------------- # Chat-family wiring: signer hook + extra_query # --------------------------------------------------------------------------- diff --git a/tests/helpers/test_onboarding.py b/tests/helpers/test_onboarding.py index f409dcb34f3..e2fdf5b7278 100644 --- a/tests/helpers/test_onboarding.py +++ b/tests/helpers/test_onboarding.py @@ -62,6 +62,14 @@ def test_get_models_for_provider_openai(): assert "openai/gpt-4o" in models +def test_get_models_for_provider_filters_by_mode(): + every_model = get_models_for_provider("gemini") + chat_models = get_models_for_provider("gemini", modes=("chat",)) + + assert chat_models + assert set(chat_models) < set(every_model) # image generation models are left out + + def test_get_models_for_provider_anthropic_prefixed(): models = get_models_for_provider("anthropic") diff --git a/tests/helpers/workflows/test_config.py b/tests/helpers/workflows/test_config.py index 1aa38b1606f..0d017999634 100644 --- a/tests/helpers/workflows/test_config.py +++ b/tests/helpers/workflows/test_config.py @@ -44,6 +44,22 @@ def _mapping_text(name: str, prompt: str) -> str: return f"name: {name}\ntasks:\n - prompt: {prompt}\n" +def _local_dir_workflow(repo: Path, filename: str, text: str) -> Path: + wdir = repo / ".cecli" / "workflows" + wdir.mkdir(parents=True, exist_ok=True) + path = wdir / filename + path.write_text(text, encoding="utf-8") + return path + + +def _agents_dir_workflow(repo: Path, filename: str, text: str) -> Path: + wdir = repo / ".agents" / "workflows" + wdir.mkdir(parents=True, exist_ok=True) + path = wdir / filename + path.write_text(text, encoding="utf-8") + return path + + # ── Case 1: single-mapping file, defaults ─────────────────────────────────── @@ -455,6 +471,85 @@ def test_bad_local_file_does_not_block_home_files(isolated, caplog): # ── Case 8: definition_hash stability ─────────────────────────────────────── +def test_local_dir_files_are_loaded(isolated): + _local_dir_workflow(isolated["repo"], "pipelines.yml", _mapping_text("local-dir", "check")) + + configs = load_workflows(git_root=isolated["repo"]) + + assert [wf.name for wf in configs] == ["local-dir"] + assert configs[0].source_path == str( + isolated["repo"] / ".cecli" / "workflows" / "pipelines.yml" + ) + + +def test_local_dir_globs_stably_sorted(isolated): + _local_dir_workflow(isolated["repo"], "b_second.yml", _mapping_text("b", "second")) + _local_dir_workflow(isolated["repo"], "a_first.yml", _mapping_text("a", "first")) + + configs = load_workflows(git_root=isolated["repo"]) + + assert [wf.name for wf in configs] == ["a", "b"] + + +def test_local_dir_defaults_to_cwd(isolated): + _local_dir_workflow(isolated["repo"], "here.yml", _mapping_text("here", "cwd")) + + configs = load_workflows() + + assert [wf.name for wf in configs] == ["here"] + + +def test_local_file_wins_over_local_dir(isolated): + _local_dir_workflow(isolated["repo"], "dir.yml", _mapping_text("dup", "dir")) + (isolated["repo"] / WORKFLOW_FILENAMES[0]).write_text( + _mapping_text("dup", "local-file"), encoding="utf-8" + ) + + configs = load_workflows(git_root=isolated["repo"]) + + assert [wf.tasks[0].prompt for wf in configs] == ["local-file"] + + +def test_local_dir_wins_over_merged_config_and_home(isolated): + _local_dir_workflow(isolated["repo"], "dir.yml", _mapping_text("dup", "dir")) + _home_workflow(isolated["wdir"], "a_home.yml", _mapping_text("dup", "home")) + merged = {"workflows": [{"name": "dup", "tasks": [{"prompt": "merged"}]}]} + + configs = load_workflows(merged_config=merged, git_root=isolated["repo"]) + + assert [wf.tasks[0].prompt for wf in configs] == ["dir"] + + +def test_agents_dir_files_are_loaded(isolated): + _agents_dir_workflow(isolated["repo"], "pipelines.yml", _mapping_text("agents-dir", "check")) + + configs = load_workflows(git_root=isolated["repo"]) + + assert [wf.name for wf in configs] == ["agents-dir"] + assert configs[0].source_path == str( + isolated["repo"] / ".agents" / "workflows" / "pipelines.yml" + ) + + +def test_cecli_dir_wins_over_agents_dir(isolated): + _agents_dir_workflow(isolated["repo"], "dup.yml", _mapping_text("dup", "agents")) + _local_dir_workflow(isolated["repo"], "dup.yml", _mapping_text("dup", "cecli")) + + configs = load_workflows(git_root=isolated["repo"]) + + assert [wf.tasks[0].prompt for wf in configs] == ["cecli"] + + +def test_agents_dir_wins_over_merged_config_and_home(isolated): + _agents_dir_workflow(isolated["repo"], "dir.yml", _mapping_text("dup", "agents")) + _home_workflow(isolated["wdir"], "a_home.yml", _mapping_text("dup", "home")) + merged = {"workflows": [{"name": "dup", "tasks": [{"prompt": "merged"}]}]} + + configs = load_workflows(merged_config=merged, git_root=isolated["repo"]) + + assert [wf.tasks[0].prompt for wf in configs] == ["agents"] + + def test_definition_hash_stable_across_key_order(): one = models.WorkflowConfig.from_dict( {"name": "h", "tasks": [{"prompt": "a", "name": "t1"}], "enabled": True} diff --git a/tests/helpers/workflows/test_runner.py b/tests/helpers/workflows/test_runner.py index d691b426252..1d51857109f 100644 --- a/tests/helpers/workflows/test_runner.py +++ b/tests/helpers/workflows/test_runner.py @@ -53,7 +53,14 @@ async def spawn(self, name, prompt=None, parent=None, auto_reap=None, independen def start_generate_task(self, info, user_message): # snapshot of the queue at start proves enqueue-before-start ordering - self.calls.append(("start", user_message, command_queue.list_queue(info.coder))) + self.calls.append( + ( + "start", + user_message, + command_queue.list_queue(info.coder), + info.coder._drain_queue_after_command, + ) + ) info.status = SubAgentStatus.RUNNING async def _run(): @@ -124,13 +131,34 @@ async def test_spawn_without_prompt_then_enqueue_then_start(coder): assert service.calls[0] == ("spawn", "wf:demo", None, coder, False, False) # tasks[1:] were enqueued (in order) BEFORE the first generate started - _, first_text, queue_at_start = service.calls[1] + _, first_text, queue_at_start, drain_after_command = service.calls[1] assert first_text == "first" assert [item["text"] for item in queue_at_start] == ["second", "third"] + assert drain_after_command is True assert result.status == "ok" assert [t.status for t in result.tasks] == ["ok", "ok", "ok"] +@pytest.mark.asyncio +async def test_command_first_task_drains_staged_queue(coder): + """A workflow whose first task is a slash command must drain the rest.""" + worker = make_worker() + worker.received = [] + service = FakeService(worker, _drain) + wf = _wf( + TaskConfig(prompt="/load-mcp github"), + TaskConfig(prompt="triage issues"), + ) + + result = await _execute(service, wf, coder) + + _, first_text, queue_at_start, drain_after_command = service.calls[1] + assert first_text == "/load-mcp github" + assert [item["text"] for item in queue_at_start] == ["triage issues"] + assert drain_after_command is True + assert result.status == "ok" + + # ---------------------------------------------------------------- case 19 -- diff --git a/tests/subagents/test_io_proxy.py b/tests/subagents/test_io_proxy.py index 0b49b1d10e4..04501ab459e 100644 --- a/tests/subagents/test_io_proxy.py +++ b/tests/subagents/test_io_proxy.py @@ -185,3 +185,44 @@ async def test_setattr_forwards_to_target(self): proxy.some_attr = "value" assert target.some_attr == "value" + + @pytest.mark.asyncio + async def test_confirm_ask_auto_approves_unattended_coder(self): + """A ``skip_cli_confirmations`` coder auto-approves without touching the target. + + Regression: unattended ``wf:``/``ws:`` workers used to forward the + confirmation to the shared Textual IO and block on the input wake from a + foreign event loop, freezing the interactive coder. + """ + from cecli.helpers.io_proxy import IOProxy + + target = MagicMock() + target.confirm_ask = AsyncMock(return_value=False) + + coder = MagicMock() + coder.uuid = "test-uuid" + coder.skip_cli_confirmations = True + + proxy = IOProxy(target, coder) + result = await proxy.confirm_ask("Run tools?") + + assert result is True + target.confirm_ask.assert_not_called() + + @pytest.mark.asyncio + async def test_confirm_ask_forwards_for_interactive_coder(self): + """An interactive coder forwards ``confirm_ask`` with coder_uuid injected.""" + from cecli.helpers.io_proxy import IOProxy + + target = MagicMock() + target.confirm_ask = AsyncMock(return_value=True) + + coder = MagicMock() + coder.uuid = "test-uuid" + coder.skip_cli_confirmations = False + + proxy = IOProxy(target, coder) + result = await proxy.confirm_ask("Run tools?") + + assert result is True + target.confirm_ask.assert_awaited_once_with("Run tools?", coder_uuid="test-uuid") diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py index b1d836b79db..f6ee8469a5b 100644 --- a/tests/subagents/test_service.py +++ b/tests/subagents/test_service.py @@ -2,11 +2,15 @@ Tests for cecli/helpers/agents/service.py — AgentService. """ +import asyncio +import threading +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from cecli.helpers.agents.service import ( + DEFAULT_SUMMARY_INTERRUPTED, AgentService, SubAgentInfo, SubAgentStatus, @@ -462,6 +466,95 @@ async def test_invoke_tui_notification(self, service, registry): assert call_args[2] == "reviewer" # name +def make_sub_coder(service, generate): + """A sub-agent coder sharing the parent's io, as IOProxy instances share their target.""" + sub_coder = MagicMock() + sub_coder.tui = None + sub_coder.uuid = "child-uuid" + sub_coder.root = "/proj" + sub_coder.io = service.coder.io + sub_coder.generate = AsyncMock(side_effect=generate) + return sub_coder + + +class TestSubAgentEvents: + """Sub-agent lifecycle reported through ``io.subagent_event``, and cancelling one.""" + + @pytest.fixture(autouse=True) + def patched_coders(self, service, registry): + # After ``service``, which empties the registry + with patch("cecli.coders.Coder") as MockCoder: + with patch("cecli.helpers.conversation.service.ConversationService"): + self.MockCoder = MockCoder + yield + + def events(self, service): + return [ + (call.args[0], call.args[1], call.kwargs) + for call in service.coder.io.subagent_event.call_args_list + ] + + async def test_invoke_reports_the_lifecycle(self, service): + async def finish(user_message, **kwargs): + info = service.sub_agents["child-uuid"] + info.status, info.summary = SubAgentStatus.FINISHED, "all good" + + self.MockCoder.create = AsyncMock(return_value=make_sub_coder(service, finish)) + assert await service.invoke("reviewer", "review this") == "all good" + + events = self.events(service) + assert [(uuid, event) for uuid, event, _ in events] == [ + ("child-uuid", "created"), + ("child-uuid", "message"), + ("child-uuid", "running"), + ("parent-uuid", "message"), + ("child-uuid", "idle"), + ] + created, delegation, _, result, idle = (kwargs for _, _, kwargs in events) + assert created["parent_uuid"] == "parent-uuid" + assert created["name"] == "reviewer" and created["root"] == "/proj" + assert delegation == {"sender_uuid": "parent-uuid", "text": "review this"} + assert result["sender_uuid"] == "child-uuid" and "all good" in result["text"] + assert idle == {"stop_reason": "end_turn", "error": None} + + def start_work(self, service, absorb): + """Invoke a sub-agent that works until cancelled; returns (invoking task, started).""" + started = asyncio.Event() + + async def work(user_message, **kwargs): + started.set() + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + if not absorb: + raise + # Streaming turns a cancellation into a ^C, and generate() returns + + self.MockCoder.create = AsyncMock(return_value=make_sub_coder(service, work)) + return asyncio.create_task(service.invoke("reviewer", "review this")), started + + @pytest.mark.parametrize("absorb", [False, True]) + async def test_cancelling_the_sub_agent_leaves_its_parent_running(self, service, absorb): + invoking, started = self.start_work(service, absorb) + await started.wait() + + assert service.cancel_sub_agent("child-uuid") is True + assert await invoking == DEFAULT_SUMMARY_INTERRUPTED + assert self.events(service)[-1][1:] == ("idle", {"stop_reason": "cancelled", "error": None}) + assert service.cancel_sub_agent("child-uuid") is False + + @pytest.mark.parametrize("absorb", [False, True]) + async def test_cancelling_the_parent_cancels_the_sub_agent_it_waits_for(self, service, absorb): + invoking, started = self.start_work(service, absorb) + await started.wait() + + invoking.cancel() + with pytest.raises(asyncio.CancelledError): + await invoking + await asyncio.gather(service.sub_agents["child-uuid"].generate_task, return_exceptions=True) + assert self.events(service)[-1][1:] == ("idle", {"stop_reason": "cancelled", "error": None}) + + # ================================================================== # # Public API: spawn # ================================================================== # @@ -962,3 +1055,108 @@ async def test_plain_worker_does_not_inherit(self, service): merged = await self._create(service, "worker") assert merged == {"own": 2} + + +# ================================================================== # +# Cross-loop lock safety +# ================================================================== # + + +class TestCrossLoopLocks: + """Spawn/conversation locks must be usable from two different event loops. + + ``asyncio.Lock`` binds to the loop of its first *waiting* acquirer and + raises ``RuntimeError`` ("... is bound to a different event loop") when + awaited from another loop. Spawn, reap and conversation writes reach the + same per-parent lock from both the TUI main (Textual) loop and the coder + worker-thread loop, so the pools use ``threading.Lock``; the guarded + sections are synchronous, so a blocked acquirer simply waits. + """ + + def setup_method(self): + """Isolate the lock pools (class-level, so they leak between tests).""" + AgentService._spawn_locks = {} + AgentService._conversation_locks = {} + + def teardown_method(self): + AgentService._spawn_locks = {} + AgentService._conversation_locks = {} + + def test_lock_pools_are_thread_locks(self): + """The pools hand out ``threading.Lock``, never ``asyncio.Lock``.""" + lock = AgentService._get_lock(AgentService._spawn_locks, "parent-uuid") + assert isinstance(lock, type(threading.Lock())) + assert not isinstance(lock, asyncio.Lock) + # Created lazily, but stable per uuid. + assert AgentService._get_lock(AgentService._spawn_locks, "parent-uuid") is lock + + def test_contended_lock_is_usable_across_event_loops(self): + """Two waiters on their own loops must block, never raise. + + With ``asyncio.Lock`` the *second* waiter (different loop from the one + the lock bound to) raises ``RuntimeError``; with ``threading.Lock`` + both simply wait for the holder to release. + """ + lock = AgentService._get_lock(AgentService._spawn_locks, "parent-uuid") + + entered = threading.Event() + release = threading.Event() + order = [] + errors = [] + + def run_on_own_loop(coro_factory): + """Run the coroutine on a fresh event loop in this thread.""" + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + loop.run_until_complete(coro_factory()) + except BaseException as exc: # noqa: BLE001 - record, do not raise in thread + errors.append(exc) + finally: + asyncio.set_event_loop(None) + loop.close() + + async def holds_lock(): + with lock: + order.append("holder") + entered.set() + # Await *outside* the lock: guarded sections never await. + await asyncio.to_thread(release.wait, 5) + order.append("holder-released") + + def contender_factory(tag): + async def contends(): + with lock: + order.append(tag) + + return contends + + holder = threading.Thread(target=run_on_own_loop, args=(holds_lock,)) + holder.start() + first = second = None + try: + assert entered.wait(timeout=5), "holder never acquired the lock" + + # Waiter #1 blocks on its own loop... + first = threading.Thread(target=run_on_own_loop, args=(contender_factory("c1"),)) + first.start() + time.sleep(0.1) + # ...waiter #2 on a third loop must block too, not raise. + second = threading.Thread(target=run_on_own_loop, args=(contender_factory("c2"),)) + second.start() + time.sleep(0.2) + + assert errors == [], f"cross-loop lock acquisition raised: {errors}" + assert order == ["holder"] + finally: + release.set() + holder.join(timeout=5) + + for thread in (first, second): + if thread is not None: + thread.join(timeout=5) + + assert errors == [] + assert "c1" in order + assert "c2" in order + assert "holder-released" in order diff --git a/tests/unit/test_queues.py b/tests/unit/test_queues.py index 3e75460a254..af2c38f506f 100644 --- a/tests/unit/test_queues.py +++ b/tests/unit/test_queues.py @@ -122,12 +122,14 @@ def setup_method(self): queues._per_coder_queues.clear() queues._input_loop = None queues._input_wake = None + queues._input_wakes.clear() def teardown_method(self): """Reset the global input-loop state after each test.""" queues._per_coder_queues.clear() queues._input_loop = None queues._input_wake = None + queues._input_wakes.clear() def test_wake_with_closed_loop_drops_stale_binding(self): """Waking after the bound loop closed must not raise, and resets state.""" @@ -176,3 +178,65 @@ async def run(): await task asyncio.run(run()) + + def test_foreign_live_loop_does_not_steal_primary_binding(self): + """A consumer on a foreign *live* loop must not steal the primary binding. + + Regression for the scheduler freeze: a scheduled workflow running on the + main (Textual) loop used to call wait_for_input() and rebind the global + wake to its own loop, stranding the primary coder's input pump that was + parked on the worker-thread loop — with no way to recover. + """ + import threading + + ready = threading.Event() + released = threading.Event() + loop_a = {} + + async def park(): + ready.set() + await queues.wait_for_input() + released.set() + + def run_loop_a(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop_a["loop"] = loop + queues.set_input_loop(loop) + loop.create_task(park()) + loop.run_forever() + + thread = threading.Thread(target=run_loop_a, daemon=True) + thread.start() + + try: + assert ready.wait(timeout=5) + assert loop_a["loop"] is queues._input_loop + + async def main(): + primary = queues._input_loop + assert primary is loop_a["loop"] + assert not primary.is_closed() + + task = asyncio.create_task(queues.wait_for_input()) + await asyncio.sleep(0) + + # The foreign loop must NOT rebind the primary wake slot... + assert queues._input_loop is primary + # ...it gets a dedicated wake event instead. + assert asyncio.get_running_loop() in queues._input_wakes + + queues.wake_input_waiters() + await asyncio.wait_for(task, timeout=5) + + asyncio.run(main()) + + # Both the primary (loop A) and the foreign waiter were released. + assert released.wait(timeout=5) + finally: + loop = loop_a.get("loop") + if loop is not None and not loop.is_closed(): + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + if loop is not None and not loop.is_closed(): + loop.close()