""" Unit tests for :class:`omnigent.terminals.TerminalRegistry`. Covers the registry's lifecycle invariants directly, without going through the ``sys_terminal_*`` tools. The tools are tested separately in ``tests/tools/builtins/test_sys_terminal.py``; these tests pin down the registry's contract so a future tool refactor can't silently regress the lifecycle. All tests that need a running tmux are skipped when tmux is missing. A handful of pure-bookkeeping tests (close/cleanup of unknown ids, empty list, active_conversation_ids) run regardless. """ from __future__ import annotations import asyncio import shutil import threading from collections.abc import AsyncIterator from pathlib import Path from unittest.mock import AsyncMock import pytest from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec from omnigent.inner.terminal import TerminalCreateResult, TerminalInstance from omnigent.terminals import TerminalRegistry from omnigent.terminals import registry as registry_mod from omnigent.terminals.registry import TerminalListEntry, conversation_link_for_id # ── Pure bookkeeping (no tmux) ──────────────────────────────── def test_active_conversation_ids_empty_on_fresh_registry() -> None: """ A freshly-constructed registry reports no active conversations. What breaks if this fails: shutdown/cleanup paths that iterate ``active_conversation_ids()`` would surprise themselves with state from prior runs (registry is supposed to be a clean singleton at startup). """ reg = TerminalRegistry() assert reg.active_conversation_ids() == [] def test_get_returns_none_for_unknown_triple() -> None: """ ``get`` is total — never raises for unknown ids. Returns ``None`` so callers can use it as a presence check. """ reg = TerminalRegistry() assert reg.get("conv_nope", "bash", "s1") is None def test_list_for_conversation_returns_empty_for_unknown_id() -> None: """ Listing a conversation that never registered terminals must return ``[]`` (not raise, not return ``None``). The ``sys_terminal_list`` tool serializes the result as JSON; an empty list keeps the LLM-facing shape stable. """ reg = TerminalRegistry() assert reg.list_for_conversation("conv_nope") == [] def test_conversation_link_for_id_uses_relative_path_by_default() -> None: """ Conversation links stay relative when no Omnigent origin is known. This is the embedded/test-runner fallback: there is no stable hostname to show in the tmux status bar, but the path still points at the conversation route when rendered by the Omnigent web UI. """ assert conversation_link_for_id("conv with/slash") == "/c/conv%20with%2Fslash" def test_conversation_link_for_id_uses_base_url_when_provided() -> None: """ Conversation links include the hostname when the runner knows one. This pins the out-of-process runner path: ``RUNNER_SERVER_URL`` is threaded into :class:`TerminalRegistry`, so managed tmux status bars show a complete URL instead of only ``/c/``. """ assert ( conversation_link_for_id( "conv with/slash", base_url="http://127.0.0.1:8000/", ) == "http://127.0.0.1:8000/c/conv%20with%2Fslash" ) def test_conversation_link_for_id_maps_workspace_hosted_server_to_ui_mount( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """ Workspace-hosted runners link to the SPA mount, not the API mount. The runner threads ``RUNNER_SERVER_URL`` — the API proxy base (``/api/2.0/omnigent``) — into the registry. A naive ``{base}/c/`` would put the JSON API path in the tmux status bar; the link must instead land on the ``/omnigent`` SPA mount and carry the ``?o=`` selector ``omnigent login`` recorded, exactly like the CLI's ``Web UI:`` line. Pins parity with :func:`omnigent.conversation_browser.conversation_url`. :param tmp_path: Pytest tmp dir for the stubbed auth-token file. :param monkeypatch: Pytest monkeypatch fixture. """ from omnigent.cli_auth import store_databricks_auth monkeypatch.setattr( "omnigent.cli_auth._token_file_path", lambda: tmp_path / "auth_tokens.json", ) server = "https://example.databricks.com/api/2.0/omnigent" store_databricks_auth( server, "https://example.databricks.com", org_id="2850744067564480", ) assert ( conversation_link_for_id("conv_abc123", base_url=server) == "https://example.databricks.com/omnigent/c/conv_abc123?o=2850744067564480" ) async def test_close_unknown_triple_returns_false() -> None: """ Closing a never-launched (or already-closed) terminal returns ``False`` and does not raise. Idempotent close is the contract — workflow finally-blocks and LLM-driven cleanup both depend on it. """ reg = TerminalRegistry() assert await reg.close("conv_nope", "bash", "s1") is False async def test_cleanup_conversation_unknown_id_is_noop() -> None: """ ``cleanup_conversation`` on an id with no terminals must return without raising — workflow finally-blocks always fire, even when the workflow never launched a terminal. """ reg = TerminalRegistry() # Should not raise. await reg.cleanup_conversation("conv_nope") async def test_shutdown_on_empty_registry_is_noop() -> None: """Shutdown of an empty registry is a no-op.""" reg = TerminalRegistry() await reg.shutdown() assert reg.active_conversation_ids() == [] async def test_launch_replaces_stale_running_entry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """``launch`` verifies a cached running entry before returning it.""" class _FlagTerminal(TerminalInstance): """Terminal instance whose liveness is controlled by a flag.""" alive: bool = True closed: bool = False async def launch(self, *, cwd: Path | None = None) -> None: del cwd self.running = True async def is_alive(self) -> bool: if not self.alive: self.running = False return self.alive async def close(self) -> None: self.closed = True self.running = False reg = TerminalRegistry() stale = _FlagTerminal( name="shell", session_key="s1", socket_path=tmp_path / "stale.sock", private_dir=tmp_path / "stale", running=True, ) stale.alive = False created = _FlagTerminal( name="shell", session_key="s1", socket_path=tmp_path / "created.sock", private_dir=tmp_path / "created", running=False, ) reg._by_conversation["conv_x"] = {("shell", "s1"): stale} reg._instance_locks[("conv_x", "shell", "s1")] = threading.Lock() def _fake_create_terminal_instance(*_args: object, **_kwargs: object) -> TerminalCreateResult: return TerminalCreateResult(instance=created, cwd=tmp_path) monkeypatch.setattr(registry_mod, "create_terminal_instance", _fake_create_terminal_instance) result = await reg.launch( "conv_x", "shell", "s1", TerminalEnvSpec(command="bash"), ) assert result is created assert stale.closed is True assert reg.get("conv_x", "shell", "s1") is created def test_transfer_moves_terminal_without_closing_tmux(tmp_path: Path) -> None: """ Terminal transfer changes ownership without touching the instance. This is the load-bearing invariant for native Claude ``/clear``: moving ``claude/main`` from the old Omnigent conversation to the fresh one must not call ``close()``, because closing kills the tmux pane that still contains the live Claude process. """ reg = TerminalRegistry() instance = TerminalInstance( name="claude", session_key="main", socket_path=tmp_path / "claude.sock", private_dir=tmp_path / "claude", running=True, ) lock = threading.Lock() reg._by_conversation["conv_old"] = {("claude", "main"): instance} reg._instance_locks[("conv_old", "claude", "main")] = lock moved = reg.transfer("conv_old", "conv_new", "claude", "main") assert moved is True assert reg.get("conv_old", "claude", "main") is None assert reg.get("conv_new", "claude", "main") is instance assert instance.running is True assert reg.get_instance_lock("conv_old", "claude", "main") is None assert reg.get_instance_lock("conv_new", "claude", "main") is lock assert reg.active_conversation_ids() == ["conv_new"] def test_transfer_rejects_target_collision_without_moving_source(tmp_path: Path) -> None: """ Transfer refuses to overwrite an existing target terminal. A collision means two live panes would claim the same ``(session, terminal, key)`` resource. The registry must fail loud and leave both original owners intact. """ reg = TerminalRegistry() source = TerminalInstance( name="claude", session_key="main", socket_path=tmp_path / "source.sock", private_dir=tmp_path / "source", running=True, ) target = TerminalInstance( name="claude", session_key="main", socket_path=tmp_path / "target.sock", private_dir=tmp_path / "target", running=True, ) reg._by_conversation["conv_old"] = {("claude", "main"): source} reg._by_conversation["conv_new"] = {("claude", "main"): target} with pytest.raises(RuntimeError, match="already exists"): reg.transfer("conv_old", "conv_new", "claude", "main") assert reg.get("conv_old", "claude", "main") is source assert reg.get("conv_new", "claude", "main") is target # ── Real-tmux lifecycle tests ───────────────────────────────── pytestmark_tmux = pytest.mark.skipif( shutil.which("tmux") is None, reason="tmux not installed; registry lifecycle tests need a real tmux on PATH", ) @pytest.fixture def bash_spec(tmp_path: Path) -> TerminalEnvSpec: """A minimal :class:`TerminalEnvSpec` with sandbox=none anchored at tmp_path. :param tmp_path: Pytest's tmpdir — the working directory for the spawned tmux. Sandbox is forced off so the test doesn't depend on bwrap / macOS sandbox availability. :returns: A :class:`TerminalEnvSpec` ready to launch. """ return TerminalEnvSpec( command="bash", os_env=OSEnvSpec( type="caller_process", cwd=str(tmp_path), sandbox=OSEnvSandboxSpec(type="none"), ), ) @pytest.fixture async def cleanup(reg_with_tmux: TerminalRegistry) -> AsyncIterator[None]: """ Tear down every terminal at test exit. Must come AFTER the registry fixture so its teardown runs before pytest discards the registry. :param reg_with_tmux: The registry fixture. :yields: ``None`` — value isn't consumed. """ yield await reg_with_tmux.shutdown() @pytest.fixture def reg_with_tmux() -> TerminalRegistry: """Fresh registry for each tmux-backed test.""" return TerminalRegistry() @pytestmark_tmux async def test_launch_get_close_round_trip( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, cleanup: None, ) -> None: """ ``launch`` registers the instance, ``get`` finds it, ``close`` removes it. The most basic happy path through the registry. """ del cleanup # consumed for teardown side-effect instance = await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) assert instance.running, ( "Instance should be running after launch; if not, tmux spawn failed silently." ) # Same instance is returned on get. assert reg_with_tmux.get("conv_a", "bash", "s1") is instance closed = await reg_with_tmux.close("conv_a", "bash", "s1") assert closed is True, ( "Close should return True for a live instance. False " "would mean the registry forgot the entry between " "launch and close." ) # And the entry is gone. assert reg_with_tmux.get("conv_a", "bash", "s1") is None @pytestmark_tmux async def test_launch_idempotent_returns_existing_instance( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, cleanup: None, ) -> None: """ Launching the same triple twice returns the SAME instance (no second tmux spawned). Required so the LLM's retries on transient errors don't leak orphan tmux sessions. """ del cleanup first = await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) second = await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) assert first is second, ( "Idempotent launch must return the existing instance. If " "first is not second, the registry is spawning a fresh " "tmux on every launch — a leak." ) @pytestmark_tmux async def test_distinct_session_keys_get_distinct_instances( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, cleanup: None, ) -> None: """ Two different session_keys for the same terminal name produce two independent :class:`TerminalInstance` objects with distinct sockets. Validates the (name, session_key) keying. """ del cleanup s1 = await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) s2 = await reg_with_tmux.launch("conv_a", "bash", "s2", bash_spec) assert s1 is not s2 assert s1.socket_path != s2.socket_path, ( "Distinct session keys must spawn distinct tmux sockets. " "If they match, the registry collapsed (name, key) to " "name-only." ) @pytestmark_tmux async def test_distinct_conversations_isolated( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, cleanup: None, ) -> None: """ Two conversations launching ``bash:s1`` get two distinct instances. Conversations must not share terminal state. """ del cleanup inst_a = await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) inst_b = await reg_with_tmux.launch("conv_b", "bash", "s1", bash_spec) assert inst_a is not inst_b # Cross-conversation get returns None — no leakage. assert reg_with_tmux.get("conv_a", "bash", "s1") is inst_a assert reg_with_tmux.get("conv_b", "bash", "s1") is inst_b @pytestmark_tmux async def test_list_for_conversation_returns_only_owners_terminals( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, cleanup: None, ) -> None: """ ``list_for_conversation`` returns only the requested conversation's terminals — no leakage of other conversations' sessions. """ del cleanup await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) await reg_with_tmux.launch("conv_a", "bash", "s2", bash_spec) await reg_with_tmux.launch("conv_b", "bash", "s1", bash_spec) listed_a = reg_with_tmux.list_for_conversation("conv_a") listed_b = reg_with_tmux.list_for_conversation("conv_b") a_keys = {(e.terminal_name, e.session_key) for e in listed_a} b_keys = {(e.terminal_name, e.session_key) for e in listed_b} assert a_keys == {("bash", "s1"), ("bash", "s2")} assert b_keys == {("bash", "s1")} @pytestmark_tmux async def test_cleanup_conversation_closes_all_owners_terminals( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, ) -> None: """ ``cleanup_conversation`` closes every terminal owned by the conversation and drops the per-conversation slot. Other conversations' terminals are untouched. What breaks if this fails: workflow exit doesn't release tmux sessions; orphans accumulate across the Omnigent process lifetime. """ await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) await reg_with_tmux.launch("conv_a", "bash", "s2", bash_spec) inst_b = await reg_with_tmux.launch("conv_b", "bash", "s1", bash_spec) await reg_with_tmux.cleanup_conversation("conv_a") # conv_a slot is gone — both entries cleared. assert reg_with_tmux.list_for_conversation("conv_a") == [] assert reg_with_tmux.get("conv_a", "bash", "s1") is None assert reg_with_tmux.get("conv_a", "bash", "s2") is None # conv_b is untouched — isolation invariant. assert reg_with_tmux.get("conv_b", "bash", "s1") is inst_b # Cleanup the surviving instance. await reg_with_tmux.cleanup_conversation("conv_b") @pytestmark_tmux async def test_close_after_cleanup_returns_false( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, ) -> None: """ Closing a terminal that ``cleanup_conversation`` already removed returns ``False`` — no double-close errors. """ await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) await reg_with_tmux.cleanup_conversation("conv_a") # Subsequent close on the now-cleared entry: idempotent False. assert await reg_with_tmux.close("conv_a", "bash", "s1") is False @pytestmark_tmux async def test_shutdown_clears_all_conversations( reg_with_tmux: TerminalRegistry, bash_spec: TerminalEnvSpec, ) -> None: """ ``shutdown`` closes every terminal in every conversation and leaves the registry empty. Drives the FastAPI lifespan shutdown path. """ await reg_with_tmux.launch("conv_a", "bash", "s1", bash_spec) await reg_with_tmux.launch("conv_b", "bash", "s1", bash_spec) assert sorted(reg_with_tmux.active_conversation_ids()) == ["conv_a", "conv_b"] await reg_with_tmux.shutdown() assert reg_with_tmux.active_conversation_ids() == [] # Confirm the per-conversation slot is also gone (not just # emptied with a stale key). assert reg_with_tmux.list_for_conversation("conv_a") == [] assert reg_with_tmux.list_for_conversation("conv_b") == [] # ── Additional pure-bookkeeping tests (no tmux) ───────────── def test_get_instance_lock_returns_none_for_unknown_triple() -> None: """ ``get_instance_lock`` returns ``None`` for a triple that was never registered. Callers treat ``None`` as "not running" and surface an error to the LLM. """ reg = TerminalRegistry() assert reg.get_instance_lock("conv_nope", "bash", "s1") is None def test_get_instance_lock_returns_lock_after_manual_registration(tmp_path: Path) -> None: """ After manually inserting an instance and its lock (as ``launch`` would), ``get_instance_lock`` returns the same lock object. """ reg = TerminalRegistry() lock = threading.Lock() instance = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) reg._by_conversation["conv_a"] = {("bash", "s1"): instance} reg._instance_locks[("conv_a", "bash", "s1")] = lock assert reg.get_instance_lock("conv_a", "bash", "s1") is lock def test_transfer_nonexistent_source_returns_false() -> None: """ Transferring from a conversation that has no terminals returns ``False`` without raising. This is the expected path when the source conversation was already cleaned up. """ reg = TerminalRegistry() assert reg.transfer("no_such_conv", "target_conv", "bash", "s1") is False def test_transfer_nonexistent_terminal_in_source_returns_false(tmp_path: Path) -> None: """ Transferring a specific terminal that doesn't exist in the source conversation returns ``False``. The source conversation itself exists but the named terminal does not. """ reg = TerminalRegistry() instance = TerminalInstance( name="other", session_key="s1", socket_path=tmp_path / "other.sock", private_dir=tmp_path / "other", running=True, ) reg._by_conversation["conv_a"] = {("other", "s1"): instance} assert reg.transfer("conv_a", "conv_b", "bash", "s1") is False def test_transfer_cleans_up_empty_source_slot(tmp_path: Path) -> None: """ After transferring the last terminal from a conversation, the source conversation's slot is removed from ``_by_conversation`` entirely — not left as an empty dict. """ reg = TerminalRegistry() instance = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) lock = threading.Lock() reg._by_conversation["conv_old"] = {("bash", "s1"): instance} reg._instance_locks[("conv_old", "bash", "s1")] = lock reg.transfer("conv_old", "conv_new", "bash", "s1") assert "conv_old" not in reg._by_conversation assert reg.active_conversation_ids() == ["conv_new"] def test_conversation_link_for_id_treats_whitespace_only_as_no_base() -> None: """ A base_url of whitespace-only is treated the same as ``None`` — falls back to a relative path. This guards against config entries that are accidentally blank. """ assert conversation_link_for_id("conv_x", base_url=" ") == "/c/conv_x" def test_list_for_conversation_returns_terminal_list_entries(tmp_path: Path) -> None: """ ``list_for_conversation`` returns :class:`TerminalListEntry` dataclass instances with the correct fields populated. """ reg = TerminalRegistry() inst = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) reg._by_conversation["conv_a"] = {("bash", "s1"): inst} entries = reg.list_for_conversation("conv_a") assert len(entries) == 1 entry = entries[0] assert isinstance(entry, TerminalListEntry) assert entry.terminal_name == "bash" assert entry.session_key == "s1" assert entry.instance is inst def test_list_for_conversation_snapshot_isolation(tmp_path: Path) -> None: """ The list returned by ``list_for_conversation`` is a snapshot: mutating the returned list does not affect the registry's internal state. """ reg = TerminalRegistry() inst = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) reg._by_conversation["conv_a"] = {("bash", "s1"): inst} entries = reg.list_for_conversation("conv_a") entries.clear() # mutate the returned list # Internal state should be unchanged. assert len(reg.list_for_conversation("conv_a")) == 1 def test_conversation_link_for_id_method_delegates_to_module_function() -> None: """ The instance method ``TerminalRegistry.conversation_link_for_id`` delegates to the module-level function with the registry's configured base URL. """ reg = TerminalRegistry(conversation_link_base_url=None) assert reg.conversation_link_for_id("conv_abc") == "/c/conv_abc" reg2 = TerminalRegistry(conversation_link_base_url="http://localhost:6767") link = reg2.conversation_link_for_id("conv_abc") assert link.startswith("http://localhost:6767") assert "conv_abc" in link async def test_close_removes_instance_lock(tmp_path: Path) -> None: """ After ``close``, the per-instance lock is removed so ``get_instance_lock`` returns ``None``. Subsequent tool calls that try to acquire the lock see "not running" rather than operating on a closed tmux session. """ reg = TerminalRegistry() instance = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) instance.close = AsyncMock() # type: ignore[method-assign] lock = threading.Lock() reg._by_conversation["conv_a"] = {("bash", "s1"): instance} reg._instance_locks[("conv_a", "bash", "s1")] = lock result = await reg.close("conv_a", "bash", "s1") assert result is True assert reg.get_instance_lock("conv_a", "bash", "s1") is None instance.close.assert_awaited_once() async def test_close_with_timeout_still_returns_true(tmp_path: Path) -> None: """ If ``instance.close()`` times out, ``close`` still returns ``True`` (the instance was found and removal from the registry succeeded). The timeout is logged but does not propagate. """ reg = TerminalRegistry() instance = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bash.sock", private_dir=tmp_path / "bash", running=True, ) async def _hang_forever() -> None: await asyncio.sleep(999) instance.close = _hang_forever # type: ignore[method-assign] reg._by_conversation["conv_a"] = {("bash", "s1"): instance} reg._instance_locks[("conv_a", "bash", "s1")] = threading.Lock() # The close should not hang — it uses asyncio.wait_for with _CLOSE_TIMEOUT_S. # We patch _CLOSE_TIMEOUT_S to a tiny value so the test finishes quickly. import omnigent.terminals.registry as reg_mod original = reg_mod._CLOSE_TIMEOUT_S reg_mod._CLOSE_TIMEOUT_S = 0.01 try: result = await reg.close("conv_a", "bash", "s1") finally: reg_mod._CLOSE_TIMEOUT_S = original assert result is True assert reg.get("conv_a", "bash", "s1") is None async def test_cleanup_conversation_tolerates_close_exception(tmp_path: Path) -> None: """ ``cleanup_conversation`` swallows exceptions from individual ``instance.close()`` calls and continues closing the remaining terminals. This ensures a wedged terminal cannot block cleanup of its siblings. """ reg = TerminalRegistry() good_instance = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "good.sock", private_dir=tmp_path / "good", running=True, ) good_instance.close = AsyncMock() # type: ignore[method-assign] bad_instance = TerminalInstance( name="bash", session_key="s2", socket_path=tmp_path / "bad.sock", private_dir=tmp_path / "bad", running=True, ) async def _explode() -> None: raise RuntimeError("tmux gone") bad_instance.close = _explode # type: ignore[method-assign] reg._by_conversation["conv_a"] = { ("bash", "s1"): good_instance, ("bash", "s2"): bad_instance, } reg._instance_locks[("conv_a", "bash", "s1")] = threading.Lock() reg._instance_locks[("conv_a", "bash", "s2")] = threading.Lock() # Should not raise despite bad_instance.close() exploding. await reg.cleanup_conversation("conv_a") assert reg.active_conversation_ids() == [] # The good instance's close was still called. good_instance.close.assert_awaited_once() async def test_shutdown_tolerates_close_exception(tmp_path: Path) -> None: """ ``shutdown`` swallows exceptions from individual ``instance.close()`` calls, just like ``cleanup_conversation``. A stuck instance in one conversation must not prevent cleanup of instances in other conversations. """ reg = TerminalRegistry() good = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "good.sock", private_dir=tmp_path / "good", running=True, ) good.close = AsyncMock() # type: ignore[method-assign] bad = TerminalInstance( name="bash", session_key="s1", socket_path=tmp_path / "bad.sock", private_dir=tmp_path / "bad", running=True, ) async def _explode() -> None: raise RuntimeError("tmux gone") bad.close = _explode # type: ignore[method-assign] reg._by_conversation["conv_a"] = {("bash", "s1"): bad} reg._by_conversation["conv_b"] = {("bash", "s1"): good} reg._instance_locks[("conv_a", "bash", "s1")] = threading.Lock() reg._instance_locks[("conv_b", "bash", "s1")] = threading.Lock() await reg.shutdown() assert reg.active_conversation_ids() == [] good.close.assert_awaited_once() def test_multiple_terminals_per_conversation(tmp_path: Path) -> None: """ A single conversation can hold multiple terminals with different names and session keys. ``list_for_conversation`` returns all of them and ``get`` retrieves each individually. """ reg = TerminalRegistry() instances = {} for name, key in [("bash", "s1"), ("bash", "s2"), ("python", "s1")]: inst = TerminalInstance( name=name, session_key=key, socket_path=tmp_path / f"{name}_{key}.sock", private_dir=tmp_path / f"{name}_{key}", running=True, ) instances[(name, key)] = inst reg._by_conversation["conv_a"] = dict(instances) entries = reg.list_for_conversation("conv_a") assert len(entries) == 3 entry_keys = {(e.terminal_name, e.session_key) for e in entries} assert entry_keys == {("bash", "s1"), ("bash", "s2"), ("python", "s1")} for (name, key), inst in instances.items(): assert reg.get("conv_a", name, key) is inst