fix(agent): resolve default workspace at wrapper entry

This commit is contained in:
celanwang 2026-10-30 16:09:50 +08:00 • committed by Cursor Agent
parent 637909ec7a
commit b5d0bbbc20
No known key found for this signature in database
4 changed files with 90 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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):