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:
Thales Maciel 2026-03-13 01:10:26 -03:00
parent eecfd7a7d7
commit 21a88312b6
22 changed files with 539 additions and 45 deletions

View file

@ -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: