Files
openai--openai-agents-python/tests/sandbox/capabilities/test_shell_capability.py
T
2026-07-13 12:39:17 +08:00

892 lines
29 KiB
Python

from __future__ import annotations
import io
import uuid
from pathlib import Path
from typing import Any, cast
import pytest
from agents.sandbox import Manifest, SandboxPathGrant
from agents.sandbox.capabilities import Shell, ShellToolSet
from agents.sandbox.capabilities.tools import (
ExecCommandArgs,
ExecCommandTool,
WriteStdinArgs,
WriteStdinTool,
)
from agents.sandbox.capabilities.tools.shell_tool import _resolve_shell
from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, PtySessionNotFoundError
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.session.pty_types import PtyExecUpdate
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExecResult, User
from agents.tool import FunctionTool
from agents.tool_context import ToolContext
from tests.utils.factories import TestSessionState
class _ShellSession(BaseSandboxSession):
def __init__(self, manifest: Manifest) -> None:
self.state = TestSessionState(
manifest=manifest,
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
)
self.exec_calls: list[tuple[str, float | None, bool | list[str]]] = []
self.exec_users: list[str | None] = []
async def start(self) -> None:
return None
async def stop(self) -> None:
return None
async def shutdown(self) -> None:
return None
async def running(self) -> bool:
return True
async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
_ = (path, user)
raise AssertionError("read() should not be called")
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
_ = (path, data, user)
raise AssertionError("write() should not be called")
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
_ = command
_ = timeout
raise AssertionError("_exec_internal() should not be called directly")
async def exec(
self,
*command: str | Path,
timeout: float | None = None,
user: str | User | None = None,
shell: bool | list[str] = False,
) -> ExecResult:
self.exec_users.append(user.name if isinstance(user, User) else user)
rendered_command = " ".join(str(part) for part in command)
self.exec_calls.append((rendered_command, timeout, shell))
return ExecResult(
stdout=f"stdout: {rendered_command}".encode(),
stderr=f"stderr: {rendered_command}".encode(),
exit_code=7,
)
async def persist_workspace(self) -> io.IOBase:
return io.BytesIO()
async def hydrate_workspace(self, data: io.IOBase) -> None:
_ = data
class _TimeoutShellSession(_ShellSession):
async def exec(
self,
*command: str | Path,
timeout: float | None = None,
user: str | User | None = None,
shell: bool | list[str] = False,
) -> ExecResult:
_ = (command, user, shell)
raise ExecTimeoutError(command=("sleep 30",), timeout_s=timeout)
class _OutputShellSession(_ShellSession):
def __init__(
self,
manifest: Manifest,
*,
stdout: bytes,
stderr: bytes,
exit_code: int = 7,
) -> None:
super().__init__(manifest)
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
async def exec(
self,
*command: str | Path,
timeout: float | None = None,
user: str | User | None = None,
shell: bool | list[str] = False,
) -> ExecResult:
self.exec_users.append(user.name if isinstance(user, User) else user)
rendered_command = " ".join(str(part) for part in command)
self.exec_calls.append((rendered_command, timeout, shell))
return ExecResult(stdout=self.stdout, stderr=self.stderr, exit_code=self.exit_code)
class _PtyShellSession(_ShellSession):
def __init__(self, manifest: Manifest) -> None:
super().__init__(manifest)
self._next_session_id = 1337
self._live_sessions: set[int] = set()
self.last_exec_yield_time_s: float | None = None
self.last_exec_user: str | None = None
self.last_write_yield_time_s: float | None = None
def supports_pty(self) -> bool:
return True
async def pty_exec_start(
self,
*command: str | Path,
timeout: float | None = None,
shell: bool | list[str] = True,
user: str | User | None = None,
tty: bool = False,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
_ = (command, timeout, shell, tty, max_output_tokens)
self.last_exec_user = user.name if isinstance(user, User) else user
self.last_exec_yield_time_s = yield_time_s
session_id = self._next_session_id
self._next_session_id += 1
self._live_sessions.add(session_id)
return PtyExecUpdate(
process_id=session_id,
output=b"",
exit_code=None,
original_token_count=None,
)
async def pty_write_stdin(
self,
*,
session_id: int,
chars: str,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
_ = max_output_tokens
self.last_write_yield_time_s = yield_time_s
if session_id not in self._live_sessions:
raise PtySessionNotFoundError(session_id=session_id)
self._live_sessions.discard(session_id)
return PtyExecUpdate(
process_id=None,
output=chars.encode("utf-8", errors="replace"),
exit_code=0,
original_token_count=None,
)
class _PtyNoStdinShellSession(_PtyShellSession):
async def pty_write_stdin(
self,
*,
session_id: int,
chars: str,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
_ = (chars, yield_time_s, max_output_tokens)
if session_id not in self._live_sessions:
raise PtySessionNotFoundError(session_id=session_id)
raise RuntimeError("stdin is not available for this process")
class _PtyUnexpectedStdinErrorShellSession(_PtyShellSession):
async def pty_write_stdin(
self,
*,
session_id: int,
chars: str,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
_ = (session_id, chars, yield_time_s, max_output_tokens)
raise RuntimeError("unexpected stdin failure")
class _PtyTransportFailingShellSession(_OutputShellSession):
def __init__(
self,
manifest: Manifest,
*,
stdout: bytes = b"",
stderr: bytes = b"",
exit_code: int = 0,
transport_context: dict[str, object] | None = None,
) -> None:
super().__init__(manifest, stdout=stdout, stderr=stderr, exit_code=exit_code)
self.transport_context = transport_context or {}
self.exec_call_count = 0
def supports_pty(self) -> bool:
return True
async def exec(
self,
*command: str | Path,
timeout: float | None = None,
user: str | User | None = None,
shell: bool | list[str] = False,
) -> ExecResult:
self.exec_call_count += 1
return await super().exec(*command, timeout=timeout, user=user, shell=shell)
async def pty_exec_start(
self,
*command: str | Path,
timeout: float | None = None,
shell: bool | list[str] = True,
user: str | User | None = None,
tty: bool = False,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
_ = (timeout, shell, user, tty, yield_time_s, max_output_tokens)
raise ExecTransportError(
command=command,
context=self.transport_context,
cause=RuntimeError("connection closed while reading HTTP status line"),
)
def _patch_shell_tool_clock(
monkeypatch: pytest.MonkeyPatch,
*,
chunk_id: str,
start: float,
end: float,
) -> None:
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
lambda: uuid.UUID(chunk_id),
)
times = iter([start, end])
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
lambda: next(times),
)
class TestShellCapability:
def test_resolve_shell_uses_plain_sh_when_login_is_false(self) -> None:
assert _resolve_shell(None, login=False) == ["sh", "-c"]
def test_tools_requires_bound_session(self) -> None:
capability = Shell()
with pytest.raises(ValueError, match="Shell capability is not bound to a SandboxSession"):
capability.tools()
def test_tools_exposes_exec_command_function_tool_after_bind(self) -> None:
capability = Shell()
capability.bind(_ShellSession(Manifest(root="/workspace")))
tools = capability.tools()
assert len(tools) == 1
assert isinstance(tools[0], ExecCommandTool)
assert isinstance(tools[0], FunctionTool)
assert tools[0].name == "exec_command"
def test_tools_exposes_write_stdin_for_pty_sessions(self) -> None:
capability = Shell()
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
tools = capability.tools()
assert len(tools) == 2
assert isinstance(tools[0], ExecCommandTool)
assert isinstance(tools[1], WriteStdinTool)
assert tools[0].name == "exec_command"
assert tools[1].name == "write_stdin"
def test_configure_tools_can_customize_shell_approvals_after_clone(self) -> None:
async def exec_command_needs_approval(
_ctx: Any, params: dict[str, Any], _call_id: str
) -> bool:
return str(params["cmd"]).startswith("rm ")
async def write_stdin_needs_approval(
_ctx: Any, params: dict[str, Any], _call_id: str
) -> bool:
return str(params["chars"]) == "\u0003"
def configure_tools(toolset: ShellToolSet) -> None:
toolset.exec_command.needs_approval = exec_command_needs_approval
assert toolset.write_stdin is not None
toolset.write_stdin.needs_approval = write_stdin_needs_approval
capability = Shell(configure_tools=configure_tools).clone()
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
tools = capability.tools()
exec_command_tool = cast(ExecCommandTool, tools[0])
write_stdin_tool = cast(WriteStdinTool, tools[1])
assert cast(object, exec_command_tool.needs_approval) is exec_command_needs_approval
assert cast(object, write_stdin_tool.needs_approval) is write_stdin_needs_approval
def test_configure_tools_can_observe_missing_write_stdin_on_non_pty_session(self) -> None:
saw_missing_write_stdin = False
def configure_tools(toolset: ShellToolSet) -> None:
nonlocal saw_missing_write_stdin
saw_missing_write_stdin = toolset.write_stdin is None
capability = Shell(configure_tools=configure_tools)
capability.bind(_ShellSession(Manifest(root="/workspace")))
tools = capability.tools()
assert saw_missing_write_stdin is True
assert len(tools) == 1
assert isinstance(tools[0], ExecCommandTool)
def test_configure_tools_can_replace_exec_command_tool(self) -> None:
replacement_exec_command: ExecCommandTool | None = None
def configure_tools(toolset: ShellToolSet) -> None:
nonlocal replacement_exec_command
replacement_exec_command = ExecCommandTool(
session=toolset.exec_command.session,
needs_approval=True,
)
toolset.exec_command = replacement_exec_command
capability = Shell(configure_tools=configure_tools)
capability.bind(_ShellSession(Manifest(root="/workspace")))
tools = capability.tools()
exec_command_tool = cast(ExecCommandTool, tools[0])
assert replacement_exec_command is not None
assert exec_command_tool is replacement_exec_command
assert exec_command_tool.needs_approval is True
@pytest.mark.asyncio
async def test_instructions_match_sandbox_shell_guidance(self) -> None:
capability = Shell()
instructions = await capability.instructions(Manifest(root="/workspace"))
assert (
instructions == "When using the shell:\n"
"- Use `exec_command` for shell execution.\n"
"- If available, use `write_stdin` to interact with or poll running sessions.\n"
"- To interrupt a long-running process via `write_stdin`, start it with "
"`tty=true` and send Ctrl-C (`\\u0003`).\n"
"- Prefer `rg` and `rg --files` for text/file discovery when available.\n"
"- Avoid using Python scripts just to print large file chunks."
)
@pytest.mark.asyncio
async def test_exec_command_tool_runs_commands_with_source_output_format(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _ShellSession(Manifest(root="/workspace"))
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
times = iter([100.0, 100.25])
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
lambda: next(uuids),
)
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
lambda: next(times),
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd", yield_time_ms=1500).model_dump_json(),
)
assert session.exec_calls == [("pwd", 1.5, True)]
assert (
output == "Chunk ID: 123456\n"
"Wall time: 0.2500 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stdout: pwd\n"
"stderr: pwd"
)
@pytest.mark.asyncio
async def test_exec_command_tool_runs_as_bound_user(self) -> None:
capability = Shell()
session = _ShellSession(Manifest(root="/workspace"))
capability.bind(session)
capability.bind_run_as(User(name="sandbox-user"))
tool = cast(FunctionTool, capability.tools()[0])
await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
assert session.exec_users == ["sandbox-user"]
@pytest.mark.asyncio
async def test_exec_command_tool_includes_original_token_count_when_truncating(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _ShellSession(Manifest(root="/workspace"))
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
times = iter([200.0, 200.5])
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
lambda: next(uuids),
)
monkeypatch.setattr(
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
lambda: next(times),
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd", yield_time_ms=1500, max_output_tokens=2).model_dump_json(),
)
assert (
output == "Chunk ID: 123456\n"
"Wall time: 0.5000 seconds\n"
"Process exited with code 7\n"
"Original token count: 6\n"
"Output:\n"
"Total output lines: 2\n\n"
"stdo…4 tokens truncated… pwd"
)
@pytest.mark.asyncio
async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _ShellSession(Manifest(root="/workspace"))
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
_patch_shell_tool_clock(
monkeypatch,
chunk_id="87654321876543218765432187654321",
start=300.0,
end=300.125,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(
cmd="pwd",
workdir="src/project",
shell="/bin/bash",
login=False,
).model_dump_json(),
)
assert session.exec_calls == [
("cd /workspace/src/project && pwd", 10.0, ["/bin/bash", "-c"])
]
assert (
output == "Chunk ID: 876543\n"
"Wall time: 0.1250 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stdout: cd /workspace/src/project && pwd\n"
"stderr: cd /workspace/src/project && pwd"
)
@pytest.mark.asyncio
async def test_exec_command_tool_allows_extra_path_grant_workdir(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _ShellSession(
Manifest(
root="/workspace",
extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),),
)
)
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
_patch_shell_tool_clock(
monkeypatch,
chunk_id="11111111111111111111111111111111",
start=310.0,
end=310.25,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(
cmd="pwd",
workdir="/tmp",
shell="/bin/bash",
login=False,
).model_dump_json(),
)
assert session.exec_calls == [("cd /tmp && pwd", 10.0, ["/bin/bash", "-c"])]
assert (
output == "Chunk ID: 111111\n"
"Wall time: 0.2500 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stdout: cd /tmp && pwd\n"
"stderr: cd /tmp && pwd"
)
@pytest.mark.asyncio
async def test_exec_command_tool_uses_pty_when_supported(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _PtyShellSession(Manifest(root="/workspace"))
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
_patch_shell_tool_clock(
monkeypatch,
chunk_id="abcdef12abcdef12abcdef12abcdef12",
start=400.0,
end=400.05,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
)
assert session.last_exec_yield_time_s == 0.0
assert (
output == "Chunk ID: abcdef\n"
"Wall time: 0.0500 seconds\n"
"Process running with session ID 1337\n"
"Output:\n"
""
)
@pytest.mark.asyncio
async def test_exec_command_tool_starts_pty_as_bound_user(self) -> None:
capability = Shell()
session = _PtyShellSession(Manifest(root="/workspace"))
capability.bind(session)
capability.bind_run_as(User(name="sandbox-user"))
tool = cast(FunctionTool, capability.tools()[0])
await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
)
assert session.last_exec_user == "sandbox-user"
@pytest.mark.asyncio
async def test_exec_command_tool_formats_timeout_without_exit_code(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = Shell()
session = _TimeoutShellSession(Manifest(root="/workspace"))
capability.bind(session)
tool = cast(FunctionTool, capability.tools()[0])
_patch_shell_tool_clock(
monkeypatch,
chunk_id="fedcba98fedcba98fedcba98fedcba98",
start=500.0,
end=500.005,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="sleep 30", yield_time_ms=5).model_dump_json(),
)
assert (
output == "Chunk ID: fedcba\n"
"Wall time: 0.0050 seconds\n"
"Output:\n"
"Command timed out after 0.005 seconds."
)
@pytest.mark.asyncio
async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_transport_error(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tool = ExecCommandTool(
session=_PtyTransportFailingShellSession(
Manifest(root="/workspace"),
stdout=b"fallback ok",
transport_context={"stage": "open_pipe", "retry_safe": True},
)
)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="44444444444444444444444444444444",
start=510.0,
end=510.1,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
assert "PTY transport failed before the interactive session opened" in output
assert "Process exited with code 0" in output
assert "Process running with session ID" not in output
assert "fallback ok" in output
@pytest.mark.asyncio
async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None:
tool = ExecCommandTool(
session=_PtyTransportFailingShellSession(
Manifest(root="/workspace"),
transport_context={"stage": "open_pipe", "retry_safe": True, "tty": True},
)
)
with pytest.raises(ExecTransportError):
await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd", tty=True).model_dump_json(),
)
@pytest.mark.asyncio
async def test_exec_command_tool_does_not_fall_back_for_non_retry_safe_transport_errors(
self,
) -> None:
tool = ExecCommandTool(
session=_PtyTransportFailingShellSession(
Manifest(root="/workspace"),
transport_context={"stage": "open_pipe"},
)
)
with pytest.raises(ExecTransportError):
await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
@pytest.mark.asyncio
async def test_exec_command_tool_uses_stdout_only_when_stderr_is_empty(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tool = ExecCommandTool(
session=_OutputShellSession(
Manifest(root="/workspace"),
stdout=b"stdout only\n",
stderr=b"",
)
)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="11111111111111111111111111111111",
start=600.0,
end=600.1,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
assert (
output == "Chunk ID: 111111\n"
"Wall time: 0.1000 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stdout only\n"
)
@pytest.mark.asyncio
async def test_exec_command_tool_uses_stderr_only_when_stdout_is_empty(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tool = ExecCommandTool(
session=_OutputShellSession(
Manifest(root="/workspace"),
stdout=b"",
stderr=b"stderr only\n",
)
)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="22222222222222222222222222222222",
start=700.0,
end=700.1,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
assert (
output == "Chunk ID: 222222\n"
"Wall time: 0.1000 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stderr only\n"
)
@pytest.mark.asyncio
async def test_exec_command_tool_does_not_insert_extra_newline_when_stdout_already_has_one(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tool = ExecCommandTool(
session=_OutputShellSession(
Manifest(root="/workspace"),
stdout=b"stdout line\n",
stderr=b"stderr line\n",
)
)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="33333333333333333333333333333333",
start=800.0,
end=800.1,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
ExecCommandArgs(cmd="pwd").model_dump_json(),
)
assert (
output == "Chunk ID: 333333\n"
"Wall time: 0.1000 seconds\n"
"Process exited with code 7\n"
"Output:\n"
"stdout line\n"
"stderr line\n"
)
@pytest.mark.asyncio
async def test_write_stdin_tool_writes_and_finishes_session(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _PtyShellSession(Manifest(root="/workspace"))
session._live_sessions.add(1337)
tool = WriteStdinTool(session=session)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="55555555555555555555555555555555",
start=900.0,
end=900.2,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
WriteStdinArgs(session_id=1337, chars="hello").model_dump_json(),
)
assert (
output == "Chunk ID: 555555\n"
"Wall time: 0.2000 seconds\n"
"Process exited with code 0\n"
"Output:\n"
"hello"
)
@pytest.mark.asyncio
async def test_write_stdin_tool_rejects_non_pty_sessions(self) -> None:
tool = WriteStdinTool(session=_ShellSession(Manifest(root="/workspace")))
with pytest.raises(
RuntimeError, match="write_stdin is not available for non-PTY sandboxes"
):
await tool.on_invoke_tool(
cast(ToolContext[object], None),
WriteStdinArgs(session_id=1337).model_dump_json(),
)
@pytest.mark.asyncio
async def test_write_stdin_tool_formats_unknown_session_error(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tool = WriteStdinTool(session=_PtyShellSession(Manifest(root="/workspace")))
_patch_shell_tool_clock(
monkeypatch,
chunk_id="66666666666666666666666666666666",
start=910.0,
end=910.1,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
WriteStdinArgs(session_id=9999).model_dump_json(),
)
assert (
output == "Chunk ID: 666666\n"
"Wall time: 0.1000 seconds\n"
"Process exited with code 1\n"
"Output:\n"
"write_stdin failed: PTY session not found: 9999"
)
@pytest.mark.asyncio
async def test_write_stdin_tool_formats_missing_stdin_error(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _PtyNoStdinShellSession(Manifest(root="/workspace"))
session._live_sessions.add(1337)
tool = WriteStdinTool(session=session)
_patch_shell_tool_clock(
monkeypatch,
chunk_id="77777777777777777777777777777777",
start=920.0,
end=920.05,
)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
WriteStdinArgs(session_id=1337).model_dump_json(),
)
assert (
output == "Chunk ID: 777777\n"
"Wall time: 0.0500 seconds\n"
"Process exited with code 1\n"
"Output:\n"
"stdin is not available for this process. Start the command with `tty=true` in "
"`exec_command` before using `write_stdin`."
)
@pytest.mark.asyncio
async def test_write_stdin_tool_reraises_unexpected_runtime_error(self) -> None:
tool = WriteStdinTool(
session=_PtyUnexpectedStdinErrorShellSession(Manifest(root="/workspace"))
)
with pytest.raises(RuntimeError, match="unexpected stdin failure"):
await tool.on_invoke_tool(
cast(ToolContext[object], None),
WriteStdinArgs(session_id=1337).model_dump_json(),
)