Add chat-friendly shell read rendering
Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible. Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested. Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
This commit is contained in:
parent
eecfd7a7d7
commit
21a88312b6
22 changed files with 539 additions and 45 deletions
|
|
@ -37,7 +37,7 @@ def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
|||
assert str(result["stdout"]) == "ok\n"
|
||||
|
||||
|
||||
def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
||||
def test_pyro_create_server_registers_full_profile_and_shell_read_schema(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
|
|
@ -46,13 +46,17 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
)
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server()
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names = asyncio.run(_run())
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
|
||||
shell_read_properties = tool_map["shell_read"]["inputSchema"]["properties"]
|
||||
assert "plain" in shell_read_properties
|
||||
assert "wait_for_idle_ms" in shell_read_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None:
|
||||
|
|
@ -977,6 +981,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
|
|
@ -986,6 +992,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": shell_id,
|
||||
"cursor": cursor,
|
||||
"max_chars": max_chars,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -1097,6 +1105,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
"plain": True,
|
||||
"wait_for_idle_ms": 300,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -1212,6 +1222,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
"plain": True,
|
||||
"wait_for_idle_ms": 300,
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -278,6 +278,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
|
||||
).format_help()
|
||||
assert "Shell output is written to stdout." in workspace_shell_read_help
|
||||
assert "--plain" in workspace_shell_read_help
|
||||
assert "--wait-for-idle-ms" in workspace_shell_read_help
|
||||
|
||||
workspace_shell_write_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
|
||||
|
|
@ -2259,11 +2261,15 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
*,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert shell_id == "shell-123"
|
||||
assert cursor == 0
|
||||
assert max_chars == 1024
|
||||
assert plain is True
|
||||
assert wait_for_idle_ms == 300
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
|
|
@ -2279,6 +2285,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
"next_cursor": 14,
|
||||
"output": "pyro$ pwd\n",
|
||||
"truncated": False,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
|
||||
class OpenParser:
|
||||
|
|
@ -2309,6 +2317,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
shell_id="shell-123",
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
json=False,
|
||||
)
|
||||
|
||||
|
|
@ -2319,6 +2329,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
assert "pyro$ pwd\n" in captured.out
|
||||
assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
assert "plain=True" in captured.err
|
||||
assert "wait_for_idle_ms=300" in captured.err
|
||||
|
||||
|
||||
def test_cli_workspace_shell_write_signal_close_json(
|
||||
|
|
@ -2478,7 +2490,11 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
*,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert plain is False
|
||||
assert wait_for_idle_ms is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
|
|
@ -2494,6 +2510,8 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
"next_cursor": max_chars,
|
||||
"output": "pyro$ pwd\n",
|
||||
"truncated": False,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
|
||||
class OpenParser:
|
||||
|
|
@ -2526,6 +2544,8 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
shell_id="shell-123",
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=False,
|
||||
wait_for_idle_ms=None,
|
||||
json=True,
|
||||
)
|
||||
|
||||
|
|
@ -2655,7 +2675,16 @@ def test_cli_workspace_shell_write_signal_close_human(
|
|||
("shell_command", "kwargs"),
|
||||
[
|
||||
("open", {"cwd": "/workspace", "cols": 120, "rows": 30}),
|
||||
("read", {"shell_id": "shell-123", "cursor": 0, "max_chars": 1024}),
|
||||
(
|
||||
"read",
|
||||
{
|
||||
"shell_id": "shell-123",
|
||||
"cursor": 0,
|
||||
"max_chars": 1024,
|
||||
"plain": False,
|
||||
"wait_for_idle_ms": None,
|
||||
},
|
||||
),
|
||||
("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}),
|
||||
("signal", {"shell_id": "shell-123", "signal": "INT"}),
|
||||
("close", {"shell_id": "shell-123"}),
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,144 @@ def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None:
|
|||
manager_rehydrated.read_shell(workspace_id, second_shell_id)
|
||||
|
||||
|
||||
def test_workspace_read_shell_plain_renders_control_sequences(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager._backend, # noqa: SLF001
|
||||
"read_shell",
|
||||
lambda *args, **kwargs: {
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 0,
|
||||
"next_cursor": 15,
|
||||
"output": "hello\r\x1b[2Kbye\n",
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
read = manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=True,
|
||||
)
|
||||
|
||||
assert read["output"] == "bye\n"
|
||||
assert read["plain"] is True
|
||||
assert read["wait_for_idle_ms"] is None
|
||||
|
||||
|
||||
def test_workspace_read_shell_wait_for_idle_batches_output(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
payloads = [
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 0,
|
||||
"next_cursor": 4,
|
||||
"output": "one\n",
|
||||
"truncated": False,
|
||||
},
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 4,
|
||||
"next_cursor": 8,
|
||||
"output": "two\n",
|
||||
"truncated": False,
|
||||
},
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 8,
|
||||
"next_cursor": 8,
|
||||
"output": "",
|
||||
"truncated": False,
|
||||
},
|
||||
]
|
||||
|
||||
def fake_read_shell(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
del args, kwargs
|
||||
return payloads.pop(0)
|
||||
|
||||
monotonic_values = iter([0.0, 0.05, 0.10, 0.41])
|
||||
monkeypatch.setattr(manager._backend, "read_shell", fake_read_shell) # noqa: SLF001
|
||||
monkeypatch.setattr(time, "monotonic", lambda: next(monotonic_values))
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||
|
||||
read = manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
|
||||
assert read["output"] == "one\ntwo\n"
|
||||
assert read["next_cursor"] == 8
|
||||
assert read["wait_for_idle_ms"] == 300
|
||||
assert read["plain"] is False
|
||||
|
||||
|
||||
def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "bad.tgz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
|
|
@ -3092,6 +3230,24 @@ def test_workspace_stop_and_start_preserve_logs_and_clear_live_state(tmp_path: P
|
|||
assert rerun["stdout"] == "hello from seed\n"
|
||||
|
||||
|
||||
def test_workspace_read_shell_rejects_invalid_wait_for_idle_ms(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
with pytest.raises(ValueError, match="wait_for_idle_ms must be between 1 and 10000"):
|
||||
manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=1024, wait_for_idle_ms=0)
|
||||
|
||||
|
||||
def test_workspace_stop_flushes_guest_filesystem_before_stopping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
36
tests/test_workspace_shell_output.py
Normal file
36
tests/test_workspace_shell_output.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pyro_mcp.workspace_shell_output import render_plain_shell_output
|
||||
|
||||
|
||||
def test_render_plain_shell_output_strips_ansi_osc_and_controls() -> None:
|
||||
raw = "\x1b]0;title\x07\x1b[31mred\x1b[0m\t\x01done"
|
||||
assert render_plain_shell_output(raw) == "red\tdone"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_carriage_return_and_backspace() -> None:
|
||||
raw = "hello\r\x1b[2Kbye\nabc\b\bZ"
|
||||
assert render_plain_shell_output(raw) == "bye\naZ"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_preserves_trailing_newlines() -> None:
|
||||
assert render_plain_shell_output("line one\n") == "line one\n"
|
||||
assert render_plain_shell_output("\n") == "\n"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_line_clear_modes_and_overwrite() -> None:
|
||||
assert render_plain_shell_output("abcde\rab\x1b[1KZ") == " Zde"
|
||||
assert render_plain_shell_output("hello\x1b[2Kx") == "x"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_full_screen_clear() -> None:
|
||||
assert render_plain_shell_output("one\ntwo\x1b[2Jz") == "z"
|
||||
assert render_plain_shell_output("one\ntwo\x1b[3Jz") == "z"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_ignores_incomplete_and_non_csi_escape_sequences() -> None:
|
||||
assert render_plain_shell_output("\x1b") == ""
|
||||
assert render_plain_shell_output("\x1b[") == ""
|
||||
assert render_plain_shell_output("\x1b]title\x1b\\ok") == "ok"
|
||||
assert render_plain_shell_output("a\x1bOPb") == "ab"
|
||||
assert render_plain_shell_output("a\x1bXb") == "ab"
|
||||
Loading…
Add table
Add a link
Reference in a new issue