From f2c5b16ae366938e7e60e8d6b7af1789310d653b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 12:00:06 +0000 Subject: [PATCH] fix(agent): keep project-root cwd when the default workspace is filled in Job tools still receive the default alias. That fill-in is marked so cwd resolution can ignore it and keep the single-workspace project root at the SDK boundary. Co-authored-by: celanwang --- .../agent_wrapper/base_agent_wrapper.py | 23 ++++++-- tests/unit/test_cc_agent_wrapper.py | 6 +- tests/unit/test_codex_agent_wrapper.py | 58 +++++++++++++++++++ tests/unit/test_workspace_ownership.py | 2 + 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/reme/components/agent_wrapper/base_agent_wrapper.py b/reme/components/agent_wrapper/base_agent_wrapper.py index 150f3d53..4d110099 100644 --- a/reme/components/agent_wrapper/base_agent_wrapper.py +++ b/reme/components/agent_wrapper/base_agent_wrapper.py @@ -70,17 +70,20 @@ class BaseAgentWrapper(BaseComponent): return workspaces[alias].workspace_path def _call_cwd(self, kwargs: dict[str, Any]) -> Path: - """Working directory for this call: explicit cwd config wins, then the bound workspace. + """Working directory for this call. - A relative configured cwd resolves against the bound workspace; - an absolute path keeps its explicit meaning. + An explicit cwd wins. A relative one resolves against the bound + workspace; an absolute path keeps its explicit meaning. Otherwise an + explicitly selected workspace selects that workspace root. The default + alias injected for job tools is not an explicit selection, so a + single-workspace call keeps the project root. """ if self._cwd: cwd = Path(self._cwd) if cwd.is_absolute(): return cwd return self._call_workspace_path(kwargs) / cwd - if kwargs.get("workspace"): + if kwargs.get("workspace") and kwargs.get("_workspace_explicit", True): return self._call_workspace_path(kwargs) return self.cwd @@ -240,10 +243,18 @@ class BaseAgentWrapper(BaseComponent): return resolved def _merged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: - """Merge component defaults with call-time kwargs; call-time values win.""" + """Merge component defaults with call-time kwargs; call-time values win. + + An omitted workspace becomes the default alias so job tools keep an + explicit binding. ``_workspace_explicit`` records whether the caller or + the component config selected that alias; cwd resolution uses it to + keep the historical project root when the alias was only filled in. + """ merged = {**self.kwargs, **kwargs} - if (workspaces := _context_workspaces(self.app_context)) and not merged.get("workspace"): + explicit_workspace = bool(merged.get("workspace")) + if (workspaces := _context_workspaces(self.app_context)) and not explicit_workspace: merged["workspace"] = next(iter(workspaces)) + merged["_workspace_explicit"] = explicit_workspace if "output_schema" in merged: merged["output_schema"] = self._normalize_output_schema(merged["output_schema"]) return merged diff --git a/tests/unit/test_cc_agent_wrapper.py b/tests/unit/test_cc_agent_wrapper.py index 8fdbefbb..a2b28ae1 100644 --- a/tests/unit/test_cc_agent_wrapper.py +++ b/tests/unit/test_cc_agent_wrapper.py @@ -387,7 +387,11 @@ async def test_agentscope_compact_session_forces_and_persists_compression(tmp_pa await wrapper.compact_session("session-1") - assert observed["build"] == (None, {"resume": "session-1", "workspace": "default"}) + build_inputs, build_kwargs = observed["build"] + assert build_inputs is None + # Default-alias fill-in is marked so cwd resolution can ignore it. + assert build_kwargs.pop("_workspace_explicit") is False + assert build_kwargs == {"resume": "session-1", "workspace": "default"} assert observed["config"].trigger_ratio == 1e-9 assert observed["state"] is FakeAgent.state diff --git a/tests/unit/test_codex_agent_wrapper.py b/tests/unit/test_codex_agent_wrapper.py index dbd6f294..4874b4e5 100644 --- a/tests/unit/test_codex_agent_wrapper.py +++ b/tests/unit/test_codex_agent_wrapper.py @@ -14,6 +14,7 @@ import pytest from openai_codex.generated.v2_all import TokenUsageBreakdown from pydantic import BaseModel +from reme.components import ApplicationContext from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper from reme.components.job import BackgroundJob from reme.components.outbound_proxy import FixedHttpOutboundProxy @@ -721,6 +722,63 @@ async def test_reply_normalizes_schema_and_reuses_persistent_client(tmp_path, mo assert close_count == 1 +@pytest.mark.asyncio +async def test_reply_keeps_project_root_cwd_without_explicit_workspace(tmp_path, monkeypatch): + """A single-workspace reply delivers the project root to the SDK. + + The default alias is still injected for job tools. Naming that alias, or + choosing another workspace, selects that workspace directory instead. + """ + project = tmp_path / "project" + workspace = project / ".reme" + context = ApplicationContext(workspace_dir=str(workspace)) + wrapper = CodexAgentWrapper( + app_context=context, + project_path="..", + auth_mode="oauth", + release_threads=False, + ) + observed = {"thread": [], "turn": []} + + class FakeThread: + id = "thread-1" + + async def run(self, _inputs, **kwargs): + observed["turn"].append(kwargs["cwd"]) + return _TurnResult(final_response="ok") + + class FakeCodex: + def __init__(self, _config): + pass + + async def account(self): + return SimpleNamespace(account=SimpleNamespace()) + + async def close(self): + return None + + async def thread_start(self, **kwargs): + observed["thread"].append(kwargs["cwd"]) + return FakeThread() + + monkeypatch.setattr("reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex", FakeCodex) + + merged = wrapper._merged_kwargs({}) # pylint: disable=protected-access + assert merged["workspace"] == "default" + assert wrapper._resolve_injected_job_kwargs(merged)["workspace"] == "default" # pylint: disable=protected-access + + await wrapper.reply("hello") + await wrapper.reply("again", workspace="default") + await wrapper.close() + + project_cwd = str(wrapper.project_path) + workspace_cwd = str(context.workspaces["default"].workspace_path) + assert wrapper.cwd == wrapper.project_path + assert project_cwd != workspace_cwd + assert observed["thread"] == [project_cwd, workspace_cwd] + assert observed["turn"] == [project_cwd, workspace_cwd] + + @pytest.mark.asyncio @pytest.mark.parametrize("schema", [{}, _StructuredModel]) async def test_reply_stream_rejects_output_schema(tmp_path, schema): diff --git a/tests/unit/test_workspace_ownership.py b/tests/unit/test_workspace_ownership.py index beaa0440..2f7ce6c6 100644 --- a/tests/unit/test_workspace_ownership.py +++ b/tests/unit/test_workspace_ownership.py @@ -89,6 +89,8 @@ def test_call_cwd_resolves_relative_against_bound_workspace(tmp_path): default_wrapper = CodexAgentWrapper(app_context=context) assert default_wrapper._call_cwd({"workspace": "beta"}) == tmp_path / "beta" assert default_wrapper._call_cwd({}) != tmp_path / "beta" # project path, not a workspace + # Filling in the default alias for job tools does not switch a relative cwd. + assert wrapper._call_cwd(wrapper._merged_kwargs({})) == tmp_path / "alpha" / "relative" / "sub" def test_dream_integration_lock_is_workspace_scoped(tmp_path):