diff --git a/tests/tui_gateway/test_session_agent_cwd.py b/tests/tui_gateway/test_session_agent_cwd.py new file mode 100644 index 0000000000..46695fae78 --- /dev/null +++ b/tests/tui_gateway/test_session_agent_cwd.py @@ -0,0 +1,184 @@ +"""Workspace changes reach new Codex threads without rebinding memory or prompts.""" + +import socket +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from agent import codex_runtime, runtime_cwd +from agent.transports import codex_app_server_session +from hermes_state import SessionDB +from plugins.memory.honcho import HonchoMemoryProvider +from plugins.memory.honcho.client import HonchoClientConfig +from run_agent import AIAgent +from tools import terminal_tool + + +@pytest.fixture +def workspace_runtime(monkeypatch, tmp_path): + # server imports start a background GitHub update check; disable it before import. + monkeypatch.setattr("hermes_cli.banner.prefetch_update_check", lambda: None) + from tui_gateway import server + + old, new, other = (tmp_path / name for name in ("old-repo", "new-repo", "other-repo")) + for path in (old, new, other): + path.mkdir() + monkeypatch.chdir(other) + monkeypatch.setenv("TERMINAL_CWD", str(other)) + monkeypatch.setattr(server, "_sessions", {}) + monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(server, "_load_cfg", lambda: {}) + monkeypatch.setattr(server, "_emit", lambda *a, **k: None) + monkeypatch.setattr(server, "_session_info", lambda agent, session: {"cwd": session["cwd"]}) + monkeypatch.setattr(server, "_persist_session_git_meta", lambda *a, **k: None) + monkeypatch.setattr("tools.terminal_tool_lifecycle.cleanup_vm", lambda *a: None) + monkeypatch.setattr("model_tools.get_tool_definitions", lambda *a, **k: []) + monkeypatch.setattr("model_tools.check_toolset_requirements", lambda *a, **k: {}) + monkeypatch.setattr("agent.process_bootstrap.OpenAI", MagicMock()) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"memory": {"provider": "honcho"}}) + # Real Honcho initialization/routing, but tools-only lazy mode never creates peers. + config = HonchoClientConfig( + enabled=True, api_key="test-key", session_strategy="per-directory", + recall_mode="tools", init_on_session_start=False, + ) + monkeypatch.setattr(HonchoClientConfig, "from_global_config", lambda *a, **k: config) + providers, agents, starts, network_attempts = [], [], [], [] + + def load_provider(*a, **k): + provider = HonchoMemoryProvider() + providers.append(provider) + return provider + + def blocked_connect(self, address): + network_attempts.append(address) + raise AssertionError(f"unexpected network access: {address}") + + class CodexClient: + def __init__(self, **kwargs): + pass + + def initialize(self, **kwargs): + pass + + def request(self, method, params, **kwargs): + assert method == "thread/start" + starts.append(params["cwd"]) + return {"thread": {"id": "test-thread"}} + + def close(self): + pass + + monkeypatch.setattr("plugins.memory.load_memory_provider", load_provider) + monkeypatch.setattr(socket.socket, "connect", blocked_connect) + monkeypatch.setattr(codex_app_server_session, "CodexAppServerClient", CodexClient) + db = SessionDB(tmp_path / "state.db") + monkeypatch.setattr(server, "_get_db", lambda: db) + + def build(cwd, key="conversation"): + agent = AIAgent( + model="test-model", api_key="test-key", base_url="https://example.invalid/v1", + quiet_mode=True, skip_context_files=True, skip_background_review=True, + save_trajectories=False, platform="gui", session_id=key, session_db=db, + cwd=cwd, + ) + agents.append(agent) + agent._cached_system_prompt = f"Stable prompt for {key}" + return agent + + def move(action, session, cwd): + if action == "project-tool": + server._apply_project_workspace(session["session_key"], str(cwd)) + else: + response = server._methods[action]("move", { + "session_id": "ui-session", "session_key": session["session_key"], "cwd": str(cwd), + }) + assert "error" not in response, response + assert session["cwd"] == str(cwd) + + try: + yield SimpleNamespace(old=old, new=new, other=other, build=build, move=move, + providers=providers, starts=starts, server=server) + finally: + for agent in agents: + codex_runtime._close_codex_session(agent) + agent.close() + db.close() + assert not network_attempts + + +@pytest.mark.parametrize("action", ["project-tool", "session.cwd.set", "session.workspace.move"]) +@pytest.mark.parametrize("restart", [False, True], ids=["first-thread", "restarted-thread"]) +def test_explicit_workspace_moves_reach_new_codex_threads(workspace_runtime, action, restart): + runtime = workspace_runtime + server = runtime.server + agent = runtime.build(str(runtime.old)) + neighbor = runtime.build(str(runtime.other), key="neighbor") + session = {"agent": agent, "session_key": agent.session_id, "cwd": str(runtime.old), "source": "desktop"} + server._sessions["ui-session"] = session + provider = runtime.providers[0] + prompt, memory_key = agent._cached_system_prompt, provider._session_key + assert memory_key == runtime.old.name + existing = None + if restart: + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + assert runtime.starts[-1] == str(runtime.old) + existing = agent._codex_session + runtime.move(action, session, runtime.new) + if restart: + # Moving the workspace must not replace an already-running Codex thread. + codex_runtime._ensure_codex_session(agent) + assert agent._codex_session is existing + codex_runtime._close_codex_session(agent) + tokens = server._set_session_context(agent.session_id) + try: + assert runtime_cwd.resolve_agent_cwd() == runtime.new + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + assert runtime.starts[-1] == str(runtime.new) + finally: + server._clear_session_context(tokens) + assert terminal_tool._task_env_overrides[agent.session_id]["cwd"] == str(runtime.new) + assert neighbor.session_cwd == str(runtime.other) + assert runtime_cwd.resolve_agent_cwd() == runtime.other + assert provider._session_key == memory_key + assert provider._lazy_init_kwargs["cwd"] == str(runtime.old) + assert agent._cached_system_prompt == prompt + + +def test_workspace_move_during_deferred_build_reaches_first_codex_thread(workspace_runtime, monkeypatch): + runtime = workspace_runtime + server = runtime.server + constructed, release, ready = threading.Event(), threading.Event(), threading.Event() + session = {"agent": None, "agent_ready": ready, "session_key": "deferred", + "cwd": str(runtime.old), "source": "desktop"} + server._sessions["ui-session"] = session + + def make_agent(sid, key, **kwargs): + agent = runtime.build(kwargs["cwd_override"], key) + constructed.set() + assert release.wait(timeout=15), "test did not release deferred build" + return agent + + monkeypatch.setattr(server, "_make_agent", make_agent) + monkeypatch.setattr("tui_gateway.entry.ensure_mcp_discovery_started", lambda: None) + monkeypatch.setattr(server, "_wire_session_agent", lambda *a: False) + monkeypatch.setattr(server, "_announce_built_agent", lambda *a: None) + monkeypatch.setattr(server, "_config_model_target", lambda: ("", "")) + server._start_agent_build("ui-session", session) + try: + assert constructed.wait(timeout=15), "agent was not constructed" + runtime.move("session.workspace.move", session, runtime.new) + finally: + release.set() + assert ready.wait(timeout=15), "deferred build did not finish" + assert not session.get("agent_error") + agent = session["agent"] + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + assert runtime.starts[-1] == str(runtime.new) + assert runtime.providers[0]._session_key == runtime.old.name + assert runtime.providers[0]._lazy_init_kwargs["cwd"] == str(runtime.old) + assert agent._cached_system_prompt == f"Stable prompt for {agent.session_id}" diff --git a/tui_gateway/session_workdir.py b/tui_gateway/session_workdir.py index fa0927656c..dd3032d00f 100644 --- a/tui_gateway/session_workdir.py +++ b/tui_gateway/session_workdir.py @@ -195,6 +195,10 @@ def _session_source(session: dict | None) -> str: def _register_session_cwd(session: dict | None) -> None: if not session: return + # Workspace moves must reach lazy/restarted runtimes, not just terminal tools. + # Do not reinitialize memory providers or invalidate the cached system prompt. + if (agent := session.get("agent")) is not None: + agent.session_cwd = session.get("cwd") or None with contextlib.suppress(Exception): from tools.terminal_tool import register_task_env_overrides cwd, cwd_source = _terminal_task_cwd_with_source(session) diff --git a/website/docs/developer-guide/memory-provider-plugin.md b/website/docs/developer-guide/memory-provider-plugin.md index a006eca911..8ddfc64796 100644 --- a/website/docs/developer-guide/memory-provider-plugin.md +++ b/website/docs/developer-guide/memory-provider-plugin.md @@ -123,6 +123,15 @@ context, then scoped `terminal.cwd` (carried internally as `TERMINAL_CWD`), then the launch directory. Construction-time workspace metadata does not require changing the process cwd or rebuilding an existing conversation's system prompt. +Desktop/TUI workspace changes synchronize the live agent's `session_cwd` through +`tui_gateway/session_workdir.py::_register_session_cwd`, including when a deferred +agent is attached after a workspace move. This lets a first or restarted Codex +app-server session use the current workspace instead of its construction-time +cwd. It does not move an already-running Codex thread, reinitialize memory +providers, change an existing Honcho session identity, or invalidate the cached +system prompt. Provider initialization still receives the construction-time +workspace; absent or empty cwd remains unpinned. + ## Required Methods ### Core Lifecycle