diff --git a/nanobot/agent/tools/exec_session.py b/nanobot/agent/tools/exec_session.py index 16210409e..6598ff66b 100644 --- a/nanobot/agent/tools/exec_session.py +++ b/nanobot/agent/tools/exec_session.py @@ -22,7 +22,8 @@ from nanobot.agent.tools.schema import ( DEFAULT_YIELD_MS = 1000 MAX_YIELD_MS = 30_000 DEFAULT_WAIT_FOR_MS = 10_000 -MAX_WAIT_FOR_MS = 120_000 +DEFAULT_UNTIL_EXIT_MS = 600_000 +MAX_WAIT_FOR_MS = 600_000 DEFAULT_MAX_OUTPUT_CHARS = 10_000 MAX_OUTPUT_CHARS = 50_000 OUTPUT_DRAIN_GRACE_S = 0.1 @@ -495,51 +496,39 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str: @tool_parameters( tool_parameters_schema( - session_id=StringSchema("Session id returned by exec when yield_time_ms is used."), - chars=StringSchema( - "Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.", + session_id=StringSchema("Session ID returned by exec."), + input=StringSchema( + "Text to send to stdin; omit to poll output.", nullable=True, ), close_stdin=BooleanSchema( - description="Close stdin after writing chars. Useful for commands waiting for EOF.", + description="Close stdin after sending input.", default=False, ), terminate=BooleanSchema( - description="Terminate the running exec session.", + description="Terminate the session; use alone.", default=False, ), - yield_time_ms=IntegerSchema( - description="Milliseconds to wait before returning recent output (default 1000, max 30000).", - minimum=0, - maximum=MAX_YIELD_MS, - ), wait_for=StringSchema( - "Optional text to wait for in output before returning. " - "Useful for interactive commands and dev servers.", + "Return when this text appears in output.", + min_length=1, nullable=True, ), - wait_timeout_ms=IntegerSchema( - description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).", + until_exit=BooleanSchema( + description="Wait for the process to exit.", + default=False, + ), + timeout_ms=IntegerSchema( + description="Maximum wait: 1s normally, 10s for wait_for, 10m for until_exit.", minimum=0, maximum=MAX_WAIT_FOR_MS, nullable=True, ), - max_output_chars=IntegerSchema( - description="Maximum output characters to return from this poll (default 10000, max 50000).", - minimum=1000, - maximum=MAX_OUTPUT_CHARS, - ), - max_output_tokens=IntegerSchema( - description="Compatibility alias for max_output_chars. The current runtime uses a character budget.", - minimum=1000, - maximum=MAX_OUTPUT_CHARS, - nullable=True, - ), required=["session_id"], ) ) -class WriteStdinTool(Tool): - """Write to or poll a running exec session.""" +class ExecSessionTool(Tool): + """Interact with or wait for a running exec session.""" _scopes = {"core", "subagent"} config_key = "exec" @@ -571,98 +560,103 @@ class WriteStdinTool(Tool): @property def name(self) -> str: - return "write_stdin" + return "exec_session" @property def description(self) -> str: - return ( - "Interact with a running exec session created by exec with " - "yield_time_ms. Use chars='' to poll without writing, chars to send " - "stdin, close_stdin=true to send EOF, or terminate=true to stop the " - "process. Use wait_for with wait_timeout_ms for dev servers, test " - "watchers, and prompts where you need to wait for expected output. " - "Do not use this to start new commands; start them with exec." - ) + return "Manage a session returned by exec." async def execute( # pyright: ignore[reportIncompatibleMethodOverride] self, session_id: str, - chars: str | None = None, + input: str | None = None, close_stdin: bool = False, terminate: bool = False, - yield_time_ms: int | None = None, wait_for: str | None = None, - wait_timeout_ms: int | None = None, - max_output_chars: int | None = None, - max_output_tokens: int | None = None, + until_exit: bool = False, + timeout_ms: int | None = None, **kwargs: Any, ) -> str: try: - if max_output_chars is None: - max_output_chars = max_output_tokens - output_limit = clamp_session_int( - max_output_chars, - DEFAULT_MAX_OUTPUT_CHARS, - 1000, - MAX_OUTPUT_CHARS, - ) - if wait_for: - return await self._wait_for_output( - session_id=session_id, - chars=chars, - close_stdin=close_stdin, - terminate=terminate, - wait_for=wait_for, - wait_timeout_ms=clamp_session_int( - wait_timeout_ms, - DEFAULT_WAIT_FOR_MS, - 0, - MAX_WAIT_FOR_MS, - ), - max_output_chars=output_limit, + if wait_for == "": + return ToolResult.error("Error: wait_for must not be empty.") + if wait_for is not None and until_exit: + return ToolResult.error( + "Error: wait_for and until_exit are mutually exclusive." ) - poll = await self._manager.write( + if terminate: + if any( + ( + input is not None, + close_stdin, + wait_for is not None, + until_exit, + timeout_ms is not None, + ) + ): + return ToolResult.error("Error: terminate must be used alone.") + poll = await self._manager.write( + session_id=session_id, + chars=None, + close_stdin=False, + terminate=True, + yield_time_ms=0, + max_output_chars=DEFAULT_MAX_OUTPUT_CHARS, + owner_session_key=current_request_session_key(), + ) + result = format_session_poll(session_id, poll) + return ToolResult.error(result) if poll.timed_out else result + + default_timeout_ms = ( + DEFAULT_UNTIL_EXIT_MS + if until_exit + else DEFAULT_WAIT_FOR_MS + if wait_for is not None + else DEFAULT_YIELD_MS + ) + return await self._wait( session_id=session_id, - chars=chars, + input=input, close_stdin=close_stdin, - terminate=terminate, - yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS), - max_output_chars=output_limit, - owner_session_key=current_request_session_key(), + wait_for=wait_for, + until_exit=until_exit, + timeout_ms=clamp_session_int( + timeout_ms, + default_timeout_ms, + 0, + MAX_WAIT_FOR_MS, + ), ) - result = format_session_poll(session_id, poll) - return ToolResult.error(result) if poll.timed_out else result except KeyError: return ToolResult.error(f"Error: exec session not found: {session_id!r}") except Exception as exc: - return ToolResult.error(f"Error writing to exec session: {exc}") + return ToolResult.error(f"Error managing exec session: {exc}") - async def _wait_for_output( + async def _wait( self, *, session_id: str, - chars: str | None, + input: str | None, close_stdin: bool, - terminate: bool, - wait_for: str, - wait_timeout_ms: int, - max_output_chars: int, + wait_for: str | None, + until_exit: bool, + timeout_ms: int, ) -> str: - deadline = time.monotonic() + (wait_timeout_ms / 1000) - aggregate = _BoundedOutputBuffer(max_output_chars) + deadline = time.monotonic() + (timeout_ms / 1000) + aggregate = _BoundedOutputBuffer(DEFAULT_MAX_OUTPUT_CHARS) upstream_truncated = 0 search_overlap = "" first = True - poll: _SessionPoll | None = None + matched = False while True: remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) - step_ms = min(500, remaining_ms) + step_ms = min(MAX_YIELD_MS if until_exit else 500, remaining_ms) poll = await self._manager.write( session_id=session_id, - chars=chars if first else None, + chars=input if first else None, close_stdin=close_stdin if first else False, - terminate=terminate if first else False, + terminate=False, yield_time_ms=step_ms, max_output_chars=MAX_OUTPUT_CHARS, owner_session_key=current_request_session_key(), @@ -671,20 +665,25 @@ class WriteStdinTool(Tool): upstream_truncated += poll.truncated_chars if poll.output: aggregate.append(poll.output) - searchable = search_overlap + poll.output - if wait_for in searchable: - poll.output, aggregate_truncated = aggregate.drain() - poll.truncated_chars = upstream_truncated + aggregate_truncated - result = format_session_poll(session_id, poll) - return ToolResult.error(result) if poll.timed_out else result - overlap_chars = max(0, len(wait_for) - 1) - search_overlap = searchable[-overlap_chars:] if overlap_chars else "" - if poll.done or remaining_ms <= 0: + if wait_for is not None: + searchable = search_overlap + poll.output + matched = wait_for in searchable + overlap_chars = len(wait_for) - 1 + search_overlap = searchable[-overlap_chars:] if overlap_chars else "" + + expired = time.monotonic() >= deadline + has_activity = wait_for is None and not until_exit and bool(poll.output) + if poll.done or matched or has_activity or expired: poll.output, aggregate_truncated = aggregate.drain() poll.truncated_chars = upstream_truncated + aggregate_truncated result = format_session_poll(session_id, poll) - if wait_for not in poll.output: + if wait_for is not None and not matched: result += f"\nWait target not observed: {wait_for!r}" + elif until_exit and not poll.done: + result += ( + f"\nWait timed out after {timeout_ms / 1000:g}s; " + "session remains active." + ) return ToolResult.error(result) if poll.timed_out else result @@ -722,12 +721,7 @@ class ListExecSessionsTool(Tool): @property def description(self) -> str: - return ( - "List active long-running exec sessions, including session_id, cwd, " - "elapsed time, idle time, remaining timeout, and command preview. " - "Use this to recover a session_id after context shifts before " - "polling, writing stdin, or terminating with write_stdin." - ) + return "List active exec sessions." @property def read_only(self) -> bool: diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 34054012d..d4c2f2ebf 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -70,7 +70,7 @@ class ToolRegistry: def has(self, name: str) -> bool: """Check if a tool is registered.""" - return name in self._tools + return self.get(name) is not None @staticmethod def _schema_name(schema: dict[str, Any]) -> str: @@ -113,7 +113,7 @@ class ToolRegistry: params: Any, ) -> tuple[Tool | None, Any, str | None]: """Resolve, cast, and validate one tool call.""" - tool = self._tools.get(name) + tool = self.get(name) if not tool: suggestion = self._suggest_name(str(name)) hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else "" @@ -209,4 +209,4 @@ class ToolRegistry: return len(self._tools) def __contains__(self, name: str) -> bool: - return name in self._tools + return self.has(name) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 54881fe23..c247e43d1 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -122,55 +122,37 @@ class _PreparedCommand: working_dir=StringSchema("Optional working directory for the command"), workdir=StringSchema("Compatibility alias for working_dir"), timeout=IntegerSchema( - description=( - "Timeout in seconds. Increase for long-running commands " - "like compilation or installation (default 60, max 600)." - ), + description="Hard timeout in seconds (default 60, max 600).", minimum=1, maximum=600, ), shell=StringSchema( ( - "Override the Windows shell only when needed. Omit to use " - "PowerShell by default (pwsh when available, else powershell). " - "Pass 'cmd' only for cmd.exe syntax or cmd built-ins." + "Shell override; omit for PowerShell, or pass 'cmd' for cmd.exe." if _IS_WINDOWS - else "Override the Unix shell only when needed. Omit to use " - "bash by default. Pass 'sh' for POSIX sh or 'zsh' for " - "zsh-specific syntax." + else "Shell override; omit for bash, or pass 'sh' or 'zsh'." ), nullable=True, ), login=BooleanSchema( - description="Whether to run bash/zsh with login shell semantics (default false).", + description="Run bash/zsh as a login shell.", default=False, nullable=True, ), yield_time_ms=IntegerSchema( - description=( - "Optional milliseconds to wait before returning output. " - "When set, a still-running command returns a session_id that " - "can be polled or written to with write_stdin. Omit this field " - "to keep one-shot exec behavior." - ), + description="Return after this many milliseconds if still running; omit to wait for exit.", minimum=0, maximum=MAX_YIELD_MS, nullable=True, ), max_output_chars=IntegerSchema( - description=( - "Maximum output characters to return when yield_time_ms is used " - "(default 10000, max 50000)." - ), + description="Session output limit in characters (default 10000, max 50000).", minimum=1000, maximum=MAX_OUTPUT_CHARS, nullable=True, ), max_output_tokens=IntegerSchema( - description=( - "Compatibility alias for max_output_chars. The current runtime " - "uses a character budget." - ), + description="Compatibility alias for max_output_chars.", minimum=1000, maximum=MAX_OUTPUT_CHARS, nullable=True, @@ -283,26 +265,7 @@ class ExecTool(Tool): @property def description(self) -> str: - platform_note = ( - "On Windows, use PowerShell syntax by default; pass shell='cmd' " - "only for cmd-specific commands. " - if _IS_WINDOWS - else "On Unix, commands run through bash by default; pass shell='sh' " - "or shell='zsh' when needed. " - ) - return ( - "Execute a shell command and return its output. " - "Use this for tests, builds, package commands, git commands, and " - "other process execution. Prefer read_file/find_files/grep for " - "inspection and apply_patch/write_file/edit_file for file changes " - "instead of cat, shell find/grep, echo, or sed. " - "Use -y or --yes flags to avoid interactive prompts. " - f"{platform_note}" - "For long-running or interactive commands, pass yield_time_ms; " - "if the command keeps running, exec returns a session_id that can " - "be polled or written to with write_stdin. Output is truncated at " - "10 000 chars; timeout defaults to 60s." - ) + return "Execute a shell command." @property def exclusive(self) -> bool: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 8c919c3a5..a277fc995 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -82,6 +82,111 @@ def _json_object(value: object) -> dict[str, Any]: return cast(dict[str, Any], value) +# TODO(0.3.2): Remove the write_stdin replay migration after 0.3.1. +def _migrate_legacy_exec_arguments(container: dict[str, Any]) -> bool: + raw_arguments = cast(object, container.get("arguments")) + encoded = isinstance(raw_arguments, str) + if encoded: + try: + decoded: object = json.loads(raw_arguments) + except json.JSONDecodeError: + return False + else: + decoded = raw_arguments + if not isinstance(decoded, dict): + return False + + arguments = cast(dict[str, Any], decoded) + changed = False + if "chars" in arguments: + if "input" not in arguments: + arguments["input"] = arguments["chars"] + arguments.pop("chars") + changed = True + + wait_key = ( + "wait_timeout_ms" + if arguments.get("wait_for") or arguments.get("until_exit") + else "yield_time_ms" + ) + if "timeout_ms" not in arguments and wait_key in arguments: + arguments["timeout_ms"] = arguments[wait_key] + for key in ("yield_time_ms", "wait_timeout_ms", "max_output_chars", "max_output_tokens"): + if key in arguments: + arguments.pop(key) + changed = True + + if changed: + container["arguments"] = ( + json.dumps(arguments, ensure_ascii=False, separators=(",", ":")) + if encoded + else arguments + ) + return changed + + +def _migrate_legacy_exec_tool_call(value: object) -> bool: + if not isinstance(value, dict): + return False + tool_call = cast(dict[str, Any], value) + function_value = cast(object, tool_call.get("function")) + function = ( + cast(dict[str, Any], function_value) + if isinstance(function_value, dict) + else tool_call + ) + name = function.get("name") + if name not in {"write_stdin", "exec_session"}: + return False + + changed = name == "write_stdin" + if changed: + function["name"] = "exec_session" + return _migrate_legacy_exec_arguments(function) or changed + + +def _migrate_legacy_exec_message(message: dict[str, Any]) -> bool: + changed = False + if message.get("name") == "write_stdin": + message["name"] = "exec_session" + changed = True + tool_calls = cast(object, message.get("tool_calls")) + if isinstance(tool_calls, list): + for tool_call in cast(list[object], tool_calls): + changed = _migrate_legacy_exec_tool_call(tool_call) or changed + return changed + + +def _migrate_legacy_exec_session_records( + messages: list[dict[str, Any]], + metadata: dict[str, Any], +) -> bool: + changed = False + for message in messages: + changed = _migrate_legacy_exec_message(message) or changed + + checkpoint_value = cast(object, metadata.get(_RUNTIME_CHECKPOINT_KEY)) + if not isinstance(checkpoint_value, dict): + return changed + checkpoint = cast(dict[str, Any], checkpoint_value) + assistant = cast(object, checkpoint.get("assistant_message")) + if isinstance(assistant, dict): + changed = _migrate_legacy_exec_message(cast(dict[str, Any], assistant)) or changed + pending = cast(object, checkpoint.get("pending_tool_calls")) + if isinstance(pending, list): + for tool_call in cast(list[object], pending): + changed = _migrate_legacy_exec_tool_call(tool_call) or changed + completed = cast(object, checkpoint.get("completed_tool_results")) + if isinstance(completed, list): + for result in cast(list[object], completed): + if isinstance(result, dict): + result_data = cast(dict[str, Any], result) + if result_data.get("name") == "write_stdin": + result_data["name"] = "exec_session" + changed = True + return changed + + def _is_provider_state_record_line(line: str) -> bool: """Recognize the canonical private record without decoding its opaque payload.""" return _PROVIDER_STATE_RECORD_PREFIX_RE.match(line) is not None @@ -1085,6 +1190,8 @@ class JsonlSessionStore: provider_state=provider_state, ) self._overlay_runtime_checkpoint_unlocked(session, path) + if _migrate_legacy_exec_session_records(session.messages, session.metadata): + session.provider_state = None return session except _SESSION_DATA_ERRORS as e: logger.warning("Failed to load session {}: {}", key, e) @@ -1180,6 +1287,8 @@ class JsonlSessionStore: provider_state=provider_state, ) self._overlay_runtime_checkpoint_unlocked(session, path) + if _migrate_legacy_exec_session_records(session.messages, session.metadata): + session.provider_state = None return session except _SESSION_DATA_ERRORS as e: logger.warning("Repair failed for session {}: {}", key, e) @@ -1454,6 +1563,7 @@ class JsonlSessionStore: continue else: messages.append(data) + _migrate_legacy_exec_session_records(messages, metadata) return { "key": stored_key or key, "created_at": created_at, diff --git a/nanobot/templates/agent/tool_contract.md b/nanobot/templates/agent/tool_contract.md index 6302c50ea..690154c05 100644 --- a/nanobot/templates/agent/tool_contract.md +++ b/nanobot/templates/agent/tool_contract.md @@ -48,13 +48,9 @@ ## Process Execution -- Use `exec` for tests, builds, package commands, git commands, and other process execution. -- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits. -- Use non-interactive flags such as `-y` or `--yes` when available. -- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated. -- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`. -- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session. -- Use `list_exec_sessions` to recover active session IDs after context shifts. +- Use `exec` for processes, not file inspection or editing. +- For interaction or early output, set `yield_time_ms` and continue with `exec_session` (`until_exit=true` when no further input is needed). +- Use `list_exec_sessions` to recover session IDs. ## CLI App Attachments diff --git a/tests/agent/test_exec_session_isolation.py b/tests/agent/test_exec_session_isolation.py index 9d929ae3b..7fbdfc718 100644 --- a/tests/agent/test_exec_session_isolation.py +++ b/tests/agent/test_exec_session_isolation.py @@ -29,7 +29,7 @@ def test_agent_loops_do_not_share_exec_session_managers(tmp_path): ) exec_a = loop_a.tools.get("exec") - stdin_a = loop_a.tools.get("write_stdin") + stdin_a = loop_a.tools.get("exec_session") list_a = loop_a.tools.get("list_exec_sessions") exec_b = loop_b.tools.get("exec") diff --git a/tests/session/test_session_store.py b/tests/session/test_session_store.py index a64a9c626..58777848e 100644 --- a/tests/session/test_session_store.py +++ b/tests/session/test_session_store.py @@ -1,3 +1,4 @@ +import json from unittest.mock import MagicMock import nanobot.session as session_api @@ -164,6 +165,108 @@ def test_runtime_checkpoint_does_not_rewrite_long_session(tmp_path) -> None: assert restored.provider_state.payload == {"response_id": "private-response"} +def test_load_migrates_legacy_write_stdin_history(tmp_path) -> None: + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:legacy-exec") + session.messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "write_stdin", + "arguments": json.dumps( + { + "session_id": "abc", + "chars": "yes\n", + "wait_for": "ready", + "wait_timeout_ms": 5000, + "yield_time_ms": 0, + "max_output_tokens": 1000, + } + ), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call-1", + "name": "write_stdin", + "content": "ready", + }, + ] + session.provider_state = ProviderConversationState( + kind="openai_responses", + provider="openai:test", + model="test-model", + version=1, + payload={"response_id": "legacy-response"}, + ) + manager.save(session) + + restored = SessionManager(tmp_path).get_or_create(session.key) + function = restored.messages[0]["tool_calls"][0]["function"] + arguments = json.loads(function["arguments"]) + + assert function["name"] == "exec_session" + assert arguments == { + "session_id": "abc", + "wait_for": "ready", + "input": "yes\n", + "timeout_ms": 5000, + } + assert restored.messages[1]["name"] == "exec_session" + assert restored.provider_state is None + + +def test_load_migrates_legacy_write_stdin_runtime_checkpoint(tmp_path) -> None: + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:legacy-checkpoint") + session.add_message("user", "continue") + manager.save(session) + legacy_call = { + "id": "call-1", + "type": "function", + "function": { + "name": "write_stdin", + "arguments": {"session_id": "abc", "chars": "", "yield_time_ms": 1000}, + }, + } + session.metadata["runtime_checkpoint"] = { + "phase": "awaiting_tools", + "assistant_message": { + "role": "assistant", + "content": "", + "tool_calls": [legacy_call], + }, + "completed_tool_results": [], + "pending_tool_calls": [legacy_call], + } + session.provider_state = ProviderConversationState( + kind="openai_responses", + provider="openai:test", + model="test-model", + version=1, + payload={"response_id": "legacy-response"}, + ) + manager.save_runtime_checkpoint(session) + + restored = SessionManager(tmp_path).get_or_create(session.key) + checkpoint = restored.metadata["runtime_checkpoint"] + pending_function = checkpoint["pending_tool_calls"][0]["function"] + + assert pending_function == { + "name": "exec_session", + "arguments": {"session_id": "abc", "input": "", "timeout_ms": 1000}, + } + assert checkpoint["assistant_message"]["tool_calls"][0]["function"] == pending_function + assert restored.provider_state is None + + def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:completed") diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index 3162453d3..24ac42928 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.agent.tools.exec_session import ExecSessionManager, WriteStdinTool +from nanobot.agent.tools.exec_session import ExecSessionManager, ExecSessionTool from nanobot.agent.tools.shell import ExecTool _WINDOWS_ENV_KEYS = { @@ -905,18 +905,18 @@ class TestWindowsRealExec: if "session_id:" in result: session_id = result.split("session_id:", 1)[1].splitlines()[0].strip() - poll_result = await WriteStdinTool(manager=manager).execute( + poll_result = await ExecSessionTool(manager=manager).execute( session_id=session_id, - chars="", + input="", wait_for="café λ 你好", - wait_timeout_ms=120_000, + timeout_ms=120_000, ) result += "\n" + poll_result if "Process running." in poll_result: - final_result = await WriteStdinTool(manager=manager).execute( + final_result = await ExecSessionTool(manager=manager).execute( session_id=session_id, - chars="", - yield_time_ms=30_000, + input="", + timeout_ms=30_000, ) result += "\n" + final_result assert "Process running." not in final_result diff --git a/tests/tools/test_exec_session_tools.py b/tests/tools/test_exec_session_tools.py index c076d5beb..69d215392 100644 --- a/tests/tools/test_exec_session_tools.py +++ b/tests/tools/test_exec_session_tools.py @@ -17,8 +17,8 @@ from nanobot.agent.tools.context import RequestContext, bind_request_context, re from nanobot.agent.tools.exec_session import ( MAX_OUTPUT_CHARS, ExecSessionManager, + ExecSessionTool, ListExecSessionsTool, - WriteStdinTool, _BoundedOutputBuffer, _SessionPoll, _truncate_output, @@ -65,18 +65,16 @@ def _session_id(output: str) -> str: async def _poll_if_running( initial: str, - tool: WriteStdinTool, + tool: ExecSessionTool, *, - yield_time_ms: int = 2000, - max_output_tokens: int | None = None, + timeout_ms: int = 2000, ) -> tuple[str, str]: if "session_id:" not in initial: return initial, initial final = await tool.execute( session_id=_session_id(initial), - chars="", - yield_time_ms=yield_time_ms, - max_output_tokens=max_output_tokens, + input="", + timeout_ms=timeout_ms, ) return f"{initial}\n{final}", final @@ -111,7 +109,7 @@ def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_pa async def run() -> tuple[str, str]: manager = ExecSessionManager() tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) initial = await tool.execute(command="echo hello", yield_time_ms=1000) return await _poll_if_running(initial, stdin_tool) @@ -127,7 +125,7 @@ def test_exec_session_yield_returns_when_process_finishes_early(tmp_path): async def run() -> tuple[str, str, float]: manager = ExecSessionManager() tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _python_command("import time; time.sleep(0.1); print('done')") started = time.monotonic() initial = await tool.execute(command=command, yield_time_ms=1200) @@ -142,29 +140,6 @@ def test_exec_session_yield_returns_when_process_finishes_early(tmp_path): assert elapsed < 4.0 -def test_exec_session_accepts_max_output_tokens_alias(tmp_path): - async def run() -> tuple[str, str]: - manager = ExecSessionManager() - tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) - command = _python_command("print('A' * 2000)") - initial = await tool.execute( - command=command, - yield_time_ms=1000, - max_output_tokens=1000, - ) - return await _poll_if_running( - initial, - stdin_tool, - max_output_tokens=1000, - ) - - result, final = asyncio.run(run()) - - assert "chars truncated" in result - assert "Exit code: 0" in final - - def test_bounded_output_buffer_keeps_head_tail_and_exact_drop_count(): buffer = _BoundedOutputBuffer(10) @@ -219,37 +194,36 @@ def test_exec_session_bounds_unpolled_stdout_and_stderr(tmp_path): assert truncated_chars > 390000 -def test_write_stdin_wait_for_keeps_aggregate_within_output_budget(): +def test_exec_session_wait_for_keeps_aggregate_within_output_budget(): async def run() -> str: manager = SimpleNamespace( write=AsyncMock(side_effect=[ - _SessionPoll(output="HEAD" + "a" * 596, done=False, exit_code=None), - _SessionPoll(output="b" * 600, done=False, exit_code=None), - _SessionPoll(output="c" * 590 + "TARGET", done=False, exit_code=None), + _SessionPoll(output="HEAD" + "a" * 5996, done=False, exit_code=None), + _SessionPoll(output="b" * 6000, done=False, exit_code=None), + _SessionPoll(output="c" * 5994 + "TARGET", done=False, exit_code=None), ]) ) - tool = WriteStdinTool(manager=manager) - return await tool._wait_for_output( + tool = ExecSessionTool(manager=manager) + return await tool._wait( session_id="session", - chars=None, + input=None, close_stdin=False, - terminate=False, wait_for="TARGET", - wait_timeout_ms=1000, - max_output_chars=1000, + until_exit=False, + timeout_ms=1000, ) result = asyncio.run(run()) assert result.startswith("HEAD") - assert "TARGET" in result - assert "(796 chars truncated from output)" in result - assert len(result) < 1100 + assert "Wait target not observed" not in result + assert "(8,000 chars truncated from output)" in result + assert len(result) < 10100 -def test_write_stdin_wait_for_searches_before_response_truncation(): +def test_exec_session_wait_for_searches_before_response_truncation(): async def run() -> tuple[str, list[int]]: - output = "A" * 1500 + "TARGET" + "B" * 1500 + output = "A" * 15000 + "TARGET" + "B" * 15000 observed_limits: list[int] = [] async def write( @@ -273,15 +247,14 @@ def test_write_stdin_wait_for_searches_before_response_truncation(): ) manager = SimpleNamespace(write=AsyncMock(side_effect=write)) - tool = WriteStdinTool(manager=manager) - result = await tool._wait_for_output( + tool = ExecSessionTool(manager=manager) + result = await tool._wait( session_id="session", - chars=None, + input=None, close_stdin=False, - terminate=False, wait_for="TARGET", - wait_timeout_ms=1000, - max_output_chars=1000, + until_exit=False, + timeout_ms=1000, ) return result, observed_limits @@ -289,8 +262,8 @@ def test_write_stdin_wait_for_searches_before_response_truncation(): assert observed_limits == [MAX_OUTPUT_CHARS] assert "Wait target not observed" not in result - assert "(2,006 chars truncated from output)" in result - assert len(result) < 1100 + assert "(20,006 chars truncated from output)" in result + assert len(result) < 10100 def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path): @@ -334,7 +307,7 @@ def test_exec_can_continue_with_stdin(tmp_path): async def run() -> tuple[str, str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _python_command( "import sys; print('ready', flush=True); " "line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)" @@ -343,7 +316,7 @@ def test_exec_can_continue_with_stdin(tmp_path): try: initial = await exec_tool.execute(command=command, yield_time_ms=500) sid = _session_id(initial) - result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000) + result = await stdin_tool.execute(session_id=sid, input="ping\n", timeout_ms=1000) observed, final = await _poll_if_running(result, stdin_tool) return initial, observed, final finally: @@ -358,11 +331,11 @@ def test_exec_can_continue_with_stdin(tmp_path): assert "Elapsed:" in result -def test_write_stdin_can_close_stdin(tmp_path): +def test_exec_session_can_close_stdin(tmp_path): async def run() -> tuple[str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _python_command( "import sys; print('ready', flush=True); " "data=sys.stdin.read(); print('got:' + data, flush=True)" @@ -372,9 +345,9 @@ def test_write_stdin_can_close_stdin(tmp_path): sid = _session_id(initial) result = await stdin_tool.execute( session_id=sid, - chars="payload", + input="payload", close_stdin=True, - yield_time_ms=1500, + timeout_ms=1500, ) return initial, result @@ -385,11 +358,11 @@ def test_write_stdin_can_close_stdin(tmp_path): assert "Exit code: 0" in result -def test_write_stdin_can_terminate_session(tmp_path): +def test_exec_session_can_terminate_session(tmp_path): async def run() -> tuple[str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _waiting_shell_command("ready") initial = await exec_tool.execute(command=command, yield_time_ms=100) @@ -397,13 +370,11 @@ def test_write_stdin_can_terminate_session(tmp_path): waited = await stdin_tool.execute( session_id=sid, wait_for="ready", - wait_timeout_ms=10000, - yield_time_ms=0, + timeout_ms=10000, ) result = await stdin_tool.execute( session_id=sid, terminate=True, - yield_time_ms=0, ) return initial + waited, result @@ -413,35 +384,11 @@ def test_write_stdin_can_terminate_session(tmp_path): assert "Exit code:" in result -def test_write_stdin_accepts_max_output_tokens_alias(tmp_path): - async def run() -> tuple[str, str, str]: - manager = ExecSessionManager() - exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) - command = _waiting_shell_command("A" * 2000) - - initial = await exec_tool.execute(command=command, yield_time_ms=0) - sid = _session_id(initial) - poll = await stdin_tool.execute( - session_id=sid, - wait_for="\n", - wait_timeout_ms=10000, - max_output_tokens=1000, - ) - cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) - return initial, poll, cleanup - - initial, poll, cleanup = asyncio.run(run()) - assert "Process running" in initial - assert "chars truncated" in poll - assert "Session terminated." in cleanup - - -def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path): +def test_exec_session_preserves_completed_session_output_until_polled(tmp_path): async def run() -> tuple[str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _python_command( "import time; print('ready', flush=True); " "time.sleep(0.1); print('done', flush=True)" @@ -450,7 +397,7 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path): initial = await exec_tool.execute(command=command, yield_time_ms=50) sid = _session_id(initial) await asyncio.wait_for(manager._sessions[sid].process.wait(), timeout=2) - final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0) + final = await stdin_tool.execute(session_id=sid, input="", timeout_ms=0) return initial, final initial, final = asyncio.run(run()) @@ -460,23 +407,162 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path): assert "Exit code: 0" in final -def test_write_stdin_can_wait_for_expected_output(tmp_path): +def test_exec_session_until_exit_waits_for_silent_process(tmp_path): + async def run() -> tuple[str, str]: + manager = ExecSessionManager() + exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) + session_tool = ExecSessionTool(manager=manager) + command = _python_command("import time; time.sleep(0.2); print('done', flush=True)") + + initial = await exec_tool.execute(command=command, yield_time_ms=0) + final = await session_tool.execute( + session_id=_session_id(initial), + until_exit=True, + timeout_ms=2000, + ) + return initial, final + + initial, final = asyncio.run(run()) + + assert "Process running" in initial + assert "done" in final + assert "Exit code: 0" in final + assert "Process running" not in final + + +def test_exec_session_until_exit_aggregates_output_and_reports_nonzero_exit(tmp_path): + async def run() -> tuple[str, str]: + manager = ExecSessionManager() + exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) + session_tool = ExecSessionTool(manager=manager) + command = _python_command( + "import sys,time; print('first', flush=True); time.sleep(0.1); " + "print('second', flush=True); time.sleep(0.1); sys.exit(7)" + ) + + initial = await exec_tool.execute(command=command, yield_time_ms=0) + final = await session_tool.execute( + session_id=_session_id(initial), + until_exit=True, + timeout_ms=2000, + ) + return initial, final + + initial, final = asyncio.run(run()) + + output = initial + final + assert "first" in output + assert "second" in output + assert "Exit code: 7" in final + + +def test_exec_session_until_exit_timeout_keeps_session_active(tmp_path): async def run() -> tuple[str, str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + session_tool = ExecSessionTool(manager=manager) + initial = await exec_tool.execute( + command=_python_command("import time; time.sleep(0.3); print('done', flush=True)"), + yield_time_ms=0, + ) + sid = _session_id(initial) + timed_wait = await session_tool.execute( + session_id=sid, + until_exit=True, + timeout_ms=20, + ) + final = await session_tool.execute( + session_id=sid, + until_exit=True, + timeout_ms=2000, + ) + return initial, timed_wait, final + + initial, timed_wait, final = asyncio.run(run()) + + assert "Process running" in initial + assert "Process running" in timed_wait + assert "Wait timed out after 0.02s; session remains active." in timed_wait + assert "done" in final + assert "Exit code: 0" in final + + +def test_exec_session_until_exit_can_be_cancelled_without_losing_session(tmp_path): + async def run() -> tuple[str, str]: + manager = ExecSessionManager() + exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) + session_tool = ExecSessionTool(manager=manager) + list_tool = ListExecSessionsTool(manager=manager) + initial = await exec_tool.execute( + command=_python_command("import time; time.sleep(5)"), + yield_time_ms=0, + ) + sid = _session_id(initial) + wait_task = asyncio.create_task( + session_tool.execute( + session_id=sid, + until_exit=True, + timeout_ms=2000, + ) + ) + await asyncio.sleep(0.05) + wait_task.cancel() + with pytest.raises(asyncio.CancelledError): + await wait_task + listing = await list_tool.execute() + cleanup = await session_tool.execute(session_id=sid, terminate=True) + return listing, cleanup + + listing, cleanup = asyncio.run(run()) + + assert "running" in listing + assert "Session terminated." in cleanup + + +def test_exec_session_rejects_conflicting_wait_conditions(): + async def run() -> str: + return await ExecSessionTool().execute( + session_id="unused", + wait_for="ready", + until_exit=True, + ) + + result = asyncio.run(run()) + + assert result == "Error: wait_for and until_exit are mutually exclusive." + assert is_tool_error_result(result) + + +def test_exec_session_rejects_terminate_with_other_actions(): + async def run() -> str: + return await ExecSessionTool().execute( + session_id="unused", + input="quit\n", + terminate=True, + ) + + result = asyncio.run(run()) + + assert result == "Error: terminate must be used alone." + assert is_tool_error_result(result) + + +def test_exec_session_can_wait_for_expected_output(tmp_path): + async def run() -> tuple[str, str, str]: + manager = ExecSessionManager() + exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _waiting_shell_command("booting", delayed="ready") initial = await exec_tool.execute(command=command, yield_time_ms=100) sid = _session_id(initial) waited = await stdin_tool.execute( session_id=sid, - chars="\n", + input="\n", wait_for="ready", - wait_timeout_ms=1000, - yield_time_ms=0, + timeout_ms=1000, ) - cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) + cleanup = await stdin_tool.execute(session_id=sid, terminate=True) return initial, waited, cleanup initial, waited, cleanup = asyncio.run(run()) @@ -488,11 +574,11 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path): assert "Session terminated." in cleanup -def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path): +def test_exec_session_wait_for_reports_timeout_without_killing_session(tmp_path): async def run() -> tuple[str, str, str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _waiting_shell_command("booting", delayed="ready") initial = await exec_tool.execute(command=command, yield_time_ms=0) @@ -500,18 +586,16 @@ def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path): # Synchronize on an stdin-gated marker before exercising the immediate timeout below. ready = await stdin_tool.execute( session_id=sid, - chars="\n", + input="\n", wait_for="ready", - wait_timeout_ms=10000, - yield_time_ms=0, + timeout_ms=10000, ) waited = await stdin_tool.execute( session_id=sid, wait_for="never-ready", - wait_timeout_ms=0, - yield_time_ms=0, + timeout_ms=0, ) - cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) + cleanup = await stdin_tool.execute(session_id=sid, terminate=True) return initial, ready, waited, cleanup initial, ready, waited, cleanup = asyncio.run(run()) @@ -538,11 +622,11 @@ def test_exec_session_mode_reuses_exec_safety_guard(tmp_path): assert "blocked by deny pattern" in result -def test_write_stdin_reports_missing_session(tmp_path): +def test_exec_session_reports_missing_session(tmp_path): manager = ExecSessionManager() - tool = WriteStdinTool(manager=manager) + tool = ExecSessionTool(manager=manager) - result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars="")) + result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", input="")) assert result == "Error: exec session not found: 'missing\\nExit code: 0'" assert is_tool_error_result(result) @@ -553,13 +637,13 @@ def test_list_exec_sessions_reports_running_commands(tmp_path): manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) list_tool = ListExecSessionsTool(manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _waiting_shell_command("ready") initial = await exec_tool.execute(command=command, yield_time_ms=500) sid = _session_id(initial) listing = await list_tool.execute() - cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) + cleanup = await stdin_tool.execute(session_id=sid, terminate=True) return sid, listing, cleanup sid, listing, cleanup = asyncio.run(run()) @@ -577,7 +661,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path): manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) list_tool = ListExecSessionsTool(manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) command = _python_command( "import time; print('ready', flush=True); time.sleep(5)" ) @@ -599,7 +683,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path): ) try: other_listing = await list_tool.execute() - other_write = await stdin_tool.execute(session_id=sid, yield_time_ms=0) + other_write = await stdin_tool.execute(session_id=sid, timeout_ms=0) finally: reset_request_context(token_b) @@ -607,7 +691,7 @@ def test_exec_sessions_are_scoped_to_request_session_key(tmp_path): RequestContext(channel="cli", chat_id="a", session_key="cli:a") ) try: - cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) + cleanup = await stdin_tool.execute(session_id=sid, terminate=True) finally: reset_request_context(token_a) @@ -666,7 +750,7 @@ def test_exec_session_manager_shutdown_terminates_child_processes(tmp_path): ) manager = ExecSessionManager() tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) - stdin_tool = WriteStdinTool(manager=manager) + stdin_tool = ExecSessionTool(manager=manager) initial = await tool.execute(command=_python_command(parent_code), yield_time_ms=500) observed = current = initial deadline = time.monotonic() + 5 @@ -675,8 +759,8 @@ def test_exec_session_manager_shutdown_terminates_child_processes(tmp_path): await asyncio.sleep(0.05) current = await stdin_tool.execute( session_id=_session_id(initial), - chars="", - yield_time_ms=0, + input="", + timeout_ms=0, ) observed += f"\n{current}" assert "ready" in observed diff --git a/tests/tools/test_tool_descriptions.py b/tests/tools/test_tool_descriptions.py index ae32ae40d..13a326ebe 100644 --- a/tests/tools/test_tool_descriptions.py +++ b/tests/tools/test_tool_descriptions.py @@ -1,8 +1,7 @@ import sys -from unittest.mock import patch from nanobot.agent.tools.apply_patch import ApplyPatchTool -from nanobot.agent.tools.exec_session import ListExecSessionsTool, WriteStdinTool +from nanobot.agent.tools.exec_session import ExecSessionTool, ListExecSessionsTool from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.search import FindFilesTool, GrepTool from nanobot.agent.tools.shell import ExecTool @@ -26,13 +25,10 @@ def test_coding_tool_descriptions_steer_editing_priority() -> None: assert "prefer apply_patch" in write_file -def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None: +def test_coding_tool_descriptions_steer_discovery() -> None: read_file = ReadFileTool().description.lower() find_files = FindFilesTool().description.lower() grep = GrepTool().description.lower() - exec_tool = ExecTool().description.lower() - write_stdin = WriteStdinTool().description.lower() - list_sessions = ListExecSessionsTool().description.lower() assert "find_files/list_dir first" in read_file assert "before editing" in read_file @@ -41,38 +37,39 @@ def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None: assert "prefer it over shell find/ls" in find_files assert "prefer this over shell grep" in grep - assert "tests, builds" in exec_tool - assert "prefer read_file/find_files/grep" in exec_tool - assert "apply_patch/write_file/edit_file" in exec_tool - assert "yield_time_ms" in exec_tool - assert "do not use this to start new commands" in write_stdin - assert "wait_for" in write_stdin - assert "recover a session_id" in list_sessions +def test_exec_tool_descriptions_are_concise() -> None: + assert ExecTool().description == "Execute a shell command." + assert ExecSessionTool().description == "Manage a session returned by exec." + assert ListExecSessionsTool().description == "List active exec sessions." + + exec_parameters = ExecTool().parameters["properties"] + assert "omit to wait for exit" in exec_parameters["yield_time_ms"]["description"] + + session_parameters = ExecSessionTool().parameters["properties"] + assert set(session_parameters) == { + "session_id", + "input", + "close_stdin", + "terminate", + "wait_for", + "until_exit", + "timeout_ms", + } + assert session_parameters["until_exit"]["description"] == "Wait for the process to exit." + assert "wait_for" in session_parameters["timeout_ms"]["description"] + assert "until_exit" in session_parameters["timeout_ms"]["description"] -def test_exec_tool_shell_guidance_matches_platform() -> None: - with patch("nanobot.agent.tools.shell._IS_WINDOWS", False): - unix_description = ExecTool().description.lower() - assert "on unix" in unix_description - assert "powershell" not in unix_description - assert "cmd-specific" not in unix_description - - with patch("nanobot.agent.tools.shell._IS_WINDOWS", True): - windows_description = ExecTool().description.lower() - assert "powershell syntax" in windows_description - assert "shell='cmd'" in windows_description - +def test_exec_shell_parameter_guidance_matches_platform() -> None: shell_parameter = ExecTool().parameters["properties"]["shell"]["description"].lower() if sys.platform == "win32": - assert "override the windows shell only when needed" in shell_parameter - assert "omit to use powershell by default" in shell_parameter + assert "omit for powershell" in shell_parameter assert "powershell" in shell_parameter assert "cmd" in shell_parameter - assert "unix" not in shell_parameter + assert "bash" not in shell_parameter else: - assert "override the unix shell only when needed" in shell_parameter - assert "omit to use bash by default" in shell_parameter - assert "unix" in shell_parameter + assert "omit for bash" in shell_parameter + assert "zsh" in shell_parameter assert "powershell" not in shell_parameter assert "cmd" not in shell_parameter diff --git a/tests/tools/test_tool_loader.py b/tests/tools/test_tool_loader.py index f31e9dbdb..c6f3de044 100644 --- a/tests/tools/test_tool_loader.py +++ b/tests/tools/test_tool_loader.py @@ -92,7 +92,7 @@ def test_discover_finds_concrete_tools(): assert "CliAppsTool" in class_names assert "MessageTool" in class_names assert "SpawnTool" in class_names - assert "WriteStdinTool" in class_names + assert "ExecSessionTool" in class_names def test_discover_excludes_abstract_and_mcp(): @@ -160,8 +160,15 @@ def test_loader_wires_shared_exec_session_manager(tmp_path): ToolLoader().load(ctx, registry) assert registry.get("exec")._session_manager is manager - assert registry.get("write_stdin")._manager is manager + assert registry.get("exec_session")._manager is manager + assert registry.get("write_stdin") is None assert registry.get("list_exec_sessions")._manager is manager + definition_names = { + definition["function"]["name"] + for definition in registry.get_definitions() + } + assert "exec_session" in definition_names + assert "write_stdin" not in definition_names # --- Task 4: _FsTool.create() --- @@ -419,7 +426,7 @@ def test_loader_registers_same_tools_as_old_hardcoded(): expected = { "read_file", "write_file", "edit_file", "list_dir", - "find_files", "grep", "exec", "write_stdin", "list_exec_sessions", + "find_files", "grep", "exec", "exec_session", "list_exec_sessions", "web_search", "web_fetch", "message", "spawn", "cron", } diff --git a/webui/src/components/thread/activity/generic-tool-model.ts b/webui/src/components/thread/activity/generic-tool-model.ts index 35befc2ef..64beab555 100644 --- a/webui/src/components/thread/activity/generic-tool-model.ts +++ b/webui/src/components/thread/activity/generic-tool-model.ts @@ -240,6 +240,8 @@ function activityLabel( return statusCopy(status, "Starting long task", "Started long task", "Could not start long task"); case "update_goal": return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task"); + // TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1. + case "exec_session": case "write_stdin": return statusCopy(status, "Continuing command", "Continued command", "Could not continue command"); case "list_exec_sessions": @@ -291,6 +293,8 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s return safeText(fieldValue(trace, "ui_summary")); case "update_goal": return safeText(fieldValue(trace, "action")); + // TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1. + case "exec_session": case "write_stdin": return compactIdentifier(fieldValue(trace, "session_id")); case "screenshot": diff --git a/webui/src/tests/generic-tool-model.test.ts b/webui/src/tests/generic-tool-model.test.ts index 2abb63fa9..70833d8f6 100644 --- a/webui/src/tests/generic-tool-model.test.ts +++ b/webui/src/tests/generic-tool-model.test.ts @@ -29,7 +29,8 @@ describe("generic tool activity semantics", () => { ['cron({"action":"remove","name":"Daily digest"})', "Removed automation", "Daily digest"], ['create_goal({"objective":"private objective","ui_summary":"Benchmark memory"})', "Started long task", "Benchmark memory"], ['update_goal({"action":"complete","recap":"private recap"})', "Updated long task", "complete"], - ['write_stdin({"session_id":"session-1234567890-secret","chars":"private input"})', "Continued command", "session…ecret"], + ['exec_session({"session_id":"session-1234567890-secret","until_exit":true})', "Continued command", "session…ecret"], + ['write_stdin({"session_id":"legacy-1234567890-secret","chars":"private input"})', "Continued command", "legacy-…ecret"], ['list_exec_sessions({})', "Checked running commands", ""], ['screenshot({"path":"artifacts/home.png"})', "Captured screenshot", ""], ['third_party_sync({"token":"secret","payload":"private payload"})', "Completed Third party sync", ""],