diff --git a/reme/components/agent_wrapper/base_agent_wrapper.py b/reme/components/agent_wrapper/base_agent_wrapper.py index 48ff158f..150f3d53 100644 --- a/reme/components/agent_wrapper/base_agent_wrapper.py +++ b/reme/components/agent_wrapper/base_agent_wrapper.py @@ -242,6 +242,8 @@ class BaseAgentWrapper(BaseComponent): def _merged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Merge component defaults with call-time kwargs; call-time values win.""" merged = {**self.kwargs, **kwargs} + if (workspaces := _context_workspaces(self.app_context)) and not merged.get("workspace"): + merged["workspace"] = next(iter(workspaces)) 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 a0db6ebc..8fdbefbb 100644 --- a/tests/unit/test_cc_agent_wrapper.py +++ b/tests/unit/test_cc_agent_wrapper.py @@ -387,7 +387,7 @@ 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"}) + assert observed["build"] == (None, {"resume": "session-1", "workspace": "default"}) assert observed["config"].trigger_ratio == 1e-9 assert observed["state"] is FakeAgent.state diff --git a/tests/unit/test_workspace_ownership.py b/tests/unit/test_workspace_ownership.py index d388673a..beaa0440 100644 --- a/tests/unit/test_workspace_ownership.py +++ b/tests/unit/test_workspace_ownership.py @@ -9,6 +9,7 @@ import pytest from reme.application import Application from reme.components import ApplicationContext from reme.components.agent_wrapper.as_agent_wrapper import AsAgentWrapper +from reme.components.agent_wrapper.cc_agent_wrapper import CcAgentWrapper from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper from reme.components.component_registry import R from reme.components.job import BaseJob @@ -63,6 +64,17 @@ async def test_default_binding_reaches_real_agent_tool_call(tmp_path): await tool._func(workspace="beta") +@pytest.mark.parametrize("wrapper_cls", [AsAgentWrapper, CcAgentWrapper, CodexAgentWrapper]) +def test_agent_entry_resolves_default_workspace_for_job_tools(tmp_path, wrapper_cls): + context = _context(tmp_path) + wrapper = wrapper_cls(app_context=context) + + merged = wrapper._merged_kwargs({}) + assert merged["workspace"] == "alpha" + assert wrapper._resolve_injected_job_kwargs(merged)["workspace"] == "alpha" + assert wrapper._merged_kwargs({"workspace": "beta"})["workspace"] == "beta" + + def test_call_cwd_resolves_relative_against_bound_workspace(tmp_path): context = _context(tmp_path) absolute = tmp_path / "elsewhere" diff --git a/tests/unit/test_workspace_phase3.py b/tests/unit/test_workspace_phase3.py index b91a2527..38ef75cb 100644 --- a/tests/unit/test_workspace_phase3.py +++ b/tests/unit/test_workspace_phase3.py @@ -5,6 +5,7 @@ import asyncio import json import os +from dataclasses import dataclass from types import SimpleNamespace import pytest @@ -675,6 +676,80 @@ async def test_codex_bridge_reads_resident_parent_state(tmp_path): await wrapper.close() +@pytest.mark.asyncio +async def test_codex_reply_defaults_workspace_for_real_job_tools(tmp_path, monkeypatch): + from fastmcp import Client + from fastmcp.client import StdioTransport + + @dataclass + class TurnResult: + """Serializable fake turn result.""" + + final_response: str + usage: object = None + + class FakeThread: + """Execute the configured job tool through the real STDIO bridge.""" + + def __init__(self, thread_id, config): + self.id = thread_id + self.config = config + + async def run(self, _inputs, **_kwargs): + server = next(iter(self.config["mcp_servers"].values())) + transport = StdioTransport( + command=server["command"], + args=server["args"], + cwd=server["cwd"], + keep_alive=False, + env=dict(os.environ), + ) + arguments = {"path": f"daily/reply-{self.id}.md", "content": self.id} + if self.id == "thread-override": + arguments["workspace"] = "beta" + async with Client(transport, timeout=None, init_timeout=30) as client: + await client.call_tool("write_note", arguments) + return TurnResult(final_response="done") + + class FakeCodex: + """Create threads with deterministic IDs.""" + + def __init__(self): + self.next_id = 0 + + async def thread_start(self, **kwargs): + self.next_id += 1 + thread_id = "thread-override" if self.next_id == 3 else f"thread-{self.next_id}" + return FakeThread(thread_id, kwargs["config"]) + + app = _codex_test_app(tmp_path) + app.context.jobs["write_note"].kwargs["workspace"] = "beta" + wrapper = CodexAgentWrapper(app_context=app.context, release_threads=False) + codex = FakeCodex() + + async def get_codex(): + return codex + + monkeypatch.setattr(wrapper, "_get_codex", get_codex) + async with app: + server, task = await _start_resident_http(app) + try: + await wrapper.reply("default", job_tools=["write_note"]) + assert (tmp_path / "alpha" / "daily" / "reply-thread-1.md").is_file() + assert not (tmp_path / "beta" / "daily" / "reply-thread-1.md").exists() + + await wrapper.reply("explicit", job_tools=["write_note"], workspace="alpha") + assert (tmp_path / "alpha" / "daily" / "reply-thread-2.md").is_file() + assert not (tmp_path / "beta" / "daily" / "reply-thread-2.md").exists() + + with pytest.raises(Exception): + await wrapper.reply("override", job_tools=["write_note"]) + assert not (tmp_path / "beta" / "daily" / "reply-thread-override.md").exists() + finally: + await _stop_resident_http(server, task) + await wrapper.close() + + @pytest.mark.asyncio @pytest.mark.parametrize("named_alias", [False, True]) async def test_codex_legacy_bridge_preserves_single_workspace_binding(tmp_path, named_alias):