fix(tui): synchronize runtime cwd after workspace moves

This commit is contained in:
outpoints
2026-09-04 16:08:14 -07:00
committed by Teknium
parent 9e6c79f5be
commit 86b3695248
3 changed files with 197 additions and 0 deletions
+184
View File
@@ -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}"
+4
View File
@@ -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)
@@ -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