mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-28 18:23:34 +00:00
Paramiko caches its optional invoke import error with the original traceback, retaining the code-execution tool-loading stack and first agent. Clear only the stale traceback while preserving the exception, and add focused regression coverage.
188 lines
5.7 KiB
Python
188 lines
5.7 KiB
Python
"""Regression tests for code execution shell lifecycle behavior.
|
|
|
|
Pagers (more/less) must be disabled in the non-interactive shells created by the
|
|
code execution tool: without user input they block forever and spin at 100% CPU.
|
|
"""
|
|
|
|
import asyncio
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
|
|
from plugins._code_execution.helpers import shell_local, shell_ssh
|
|
from plugins._code_execution.helpers.tty_session import TTYSession
|
|
from plugins._code_execution.tools.code_execution_tool import (
|
|
CodeExecution,
|
|
ShellWrap,
|
|
State,
|
|
_group_multiline_command,
|
|
_is_closed_pty_error,
|
|
)
|
|
|
|
|
|
def test_local_env_disables_pagers_and_preserves_existing():
|
|
env = shell_local.disable_pagers_in_env({"PATH": "/usr/bin", "PAGER": "less"})
|
|
assert env["PAGER"] == "cat"
|
|
assert env["GIT_PAGER"] == "cat"
|
|
# pre-existing keys are preserved
|
|
assert env["PATH"] == "/usr/bin"
|
|
|
|
|
|
def test_local_env_defaults_to_environ():
|
|
env = shell_local.disable_pagers_in_env()
|
|
assert env["PAGER"] == "cat"
|
|
assert env["GIT_PAGER"] == "cat"
|
|
|
|
|
|
def test_local_env_does_not_mutate_input():
|
|
src = {"PATH": "/usr/bin"}
|
|
shell_local.disable_pagers_in_env(src)
|
|
assert src == {"PATH": "/usr/bin"}
|
|
|
|
|
|
def test_ssh_command_disables_pagers():
|
|
assert "GIT_PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND
|
|
assert "PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND
|
|
|
|
|
|
def test_paramiko_import_error_does_not_retain_tool_loading_stack(monkeypatch):
|
|
try:
|
|
raise ImportError("invoke")
|
|
except ImportError as error:
|
|
saved_error = error
|
|
monkeypatch.setattr(shell_ssh.paramiko.config, "invoke_import_error", error)
|
|
|
|
importlib.reload(shell_ssh)
|
|
|
|
assert shell_ssh.paramiko.config.invoke_import_error is saved_error
|
|
assert saved_error.__traceback__ is None
|
|
|
|
|
|
def test_multiline_terminal_commands_are_one_current_shell_compound():
|
|
assert _group_multiline_command("pwd") == "pwd"
|
|
assert _group_multiline_command("cd /tmp\npwd") == "{\ncd /tmp\npwd\n}"
|
|
assert _group_multiline_command("$env:FOO='bar'\n$env:FOO", powershell=True) == (
|
|
". {\n$env:FOO='bar'\n$env:FOO\n}"
|
|
)
|
|
|
|
|
|
def test_exited_tty_process_is_a_recoverable_closed_session():
|
|
assert _is_closed_pty_error(RuntimeError("TTYSpawn process has exited"))
|
|
|
|
|
|
def test_tty_close_kills_term_resistant_process():
|
|
async def run():
|
|
session = TTYSession("bash -lc 'trap \"\" TERM; sleep 30'")
|
|
await session.start()
|
|
await asyncio.wait_for(session.close(), timeout=6)
|
|
assert session._proc is None
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_tty_reports_strict_mode_shell_exit():
|
|
async def run():
|
|
session = TTYSession("/bin/bash --noprofile --norc -i")
|
|
await session.start()
|
|
await session.read_full_until_idle(idle_timeout=0.05, total_timeout=1)
|
|
await session.sendline("{\nset -euo pipefail\nfalse\nprintf 'unreachable\\n'\n}")
|
|
|
|
exit_code = await asyncio.wait_for(session.wait(), timeout=5)
|
|
|
|
assert exit_code != 0
|
|
assert session.is_terminated()
|
|
assert session.get_exit_code() == exit_code
|
|
await session.close()
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_ssh_session_reports_channel_exit_status():
|
|
class FakeChannel:
|
|
closed = False
|
|
|
|
@staticmethod
|
|
def exit_status_ready():
|
|
return True
|
|
|
|
@staticmethod
|
|
def recv_exit_status():
|
|
return 7
|
|
|
|
session = object.__new__(shell_ssh.SSHInteractiveSession)
|
|
session.shell = FakeChannel()
|
|
session.client = SimpleNamespace(
|
|
get_transport=lambda: SimpleNamespace(is_active=lambda: True)
|
|
)
|
|
session._exit_code = None
|
|
|
|
assert session.is_terminated()
|
|
assert session.get_exit_code() == 7
|
|
|
|
|
|
def test_code_execution_returns_immediately_when_shell_exits():
|
|
class FinishedSession:
|
|
async def read_output(self, timeout=0, reset_full_output=False):
|
|
return "nothing to commit, working tree clean\n", "nothing to commit, working tree clean\n"
|
|
|
|
@staticmethod
|
|
def is_terminated():
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_exit_code():
|
|
return 1
|
|
|
|
class FakeAgent:
|
|
agent_name = "test"
|
|
|
|
async def handle_intervention(self):
|
|
return None
|
|
|
|
@staticmethod
|
|
def read_prompt(name, **kwargs):
|
|
if name == "fw.code.shell_exit.md":
|
|
return f"Terminal shell exited{kwargs['status']}. The command has finished."
|
|
if name == "fw.code.info.md":
|
|
return f"[SYSTEM: {kwargs['info']}]"
|
|
raise AssertionError(f"Unexpected prompt: {name}")
|
|
|
|
async def run():
|
|
session = FinishedSession()
|
|
state = State(
|
|
ssh_enabled=False,
|
|
shells={0: ShellWrap(id=0, session=session, running=True)},
|
|
)
|
|
tool = CodeExecution(
|
|
FakeAgent(),
|
|
"code_execution_tool",
|
|
"",
|
|
{"runtime": "terminal", "session": 0},
|
|
"",
|
|
None,
|
|
)
|
|
updates = []
|
|
tool.log = SimpleNamespace(update=lambda **kwargs: updates.append(kwargs))
|
|
|
|
async def prepare_state(*args, **kwargs):
|
|
return state
|
|
|
|
async def set_progress(content):
|
|
return None
|
|
|
|
tool.prepare_state = prepare_state
|
|
tool.set_progress = set_progress
|
|
tool.fix_full_output = lambda output: output
|
|
|
|
response = await tool.get_terminal_output(
|
|
{"prompt_patterns": [], "dialog_patterns": []},
|
|
session=0,
|
|
sleep_time=0,
|
|
)
|
|
|
|
assert "nothing to commit" in response
|
|
assert "exit code 1" in response
|
|
assert "command has finished" in response
|
|
assert not state.shells[0].running
|
|
assert updates[-1]["heading"].endswith(" icon://done_all")
|
|
|
|
asyncio.run(run())
|