2276 lines
85 KiB
Python
2276 lines
85 KiB
Python
"""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)."
|
|
)
|