fix(acp): pin the session cwd for the turn

An ACP session registered the client's cwd for the *tools*
(`_register_task_cwd` -> `register_task_env_overrides`) but never pinned it
for the *prompt*. `agent/prompt_builder.py` reports
`Current working directory: {resolve_agent_cwd()}`, and `resolve_agent_cwd()`
reads the `_SESSION_CWD` contextvar, which ACP left unset — so it fell back
to `TERMINAL_CWD` / the launch dir.

The system prompt therefore advertised one root (commonly
`~/.hermes/workspace`, or the install tree) while the tools were rooted at the
editor's project. When the model emitted a *relative* path the tools resolved
it correctly; when it emitted an *absolute* path built from the advertised
root, the write landed outside the client's workspace and the turn still
reported success.

Observed in Zed/Buzz-shaped usage: the first prompt in a fresh workspace
creates the file under `~/.hermes/workspace/` and answers "Done." The client's
directory is untouched. Later sessions on the same cwd appear to work once a
session cwd record exists, which makes it look intermittent.

`_run_agent` already calls `set_session_vars(session_key=session_id)` inside
its `contextvars.copy_context()`, and that helper's `cwd` argument exists to
"pin the logical working directory for this context". It simply was not
passed. `gateway/session_context.py` and `tui_gateway/server.py` both already
pass it; ACP was the only surface that did not.

Adds a regression test asserting that the resolved cwd *during the turn* is
the cwd the client passed to `session/new`. It fails without this change
(resolving the install tree instead of the client's project).
This commit is contained in:
Steve Darlow 2026-07-25 17:15:35 -05:00 committed by Teknium
parent 40eebc7d70
commit cca2a2fc8d
2 changed files with 51 additions and 1 deletions

View file

@ -1761,7 +1761,16 @@ class HermesACPAgent(acp.Agent):
clear_session_vars,
set_session_vars,
)
session_tokens = set_session_vars(session_key=session_id)
# ``cwd`` pins the logical working directory for this context,
# which is what the system prompt's "Current working directory"
# line reports (agent/prompt_builder.py -> resolve_agent_cwd).
# Without it the prompt advertises the global Hermes workspace
# while the tools are rooted at the client's project, so the
# model emits absolute paths under ~/.hermes/workspace and the
# edit silently lands outside the editor's workspace.
session_tokens = set_session_vars(
session_key=session_id, cwd=state.cwd,
)
except Exception:
session_tokens = None
clear_session_vars = None # type: ignore[assignment]

View file

@ -1639,6 +1639,47 @@ class TestPrompt:
text and "[plugin appended this]" in text for text in all_texts
), f"expected transformed final to be delivered, got: {all_texts!r}"
@pytest.mark.asyncio
async def test_prompt_pins_the_session_cwd_for_the_turn(self, agent, tmp_path):
"""The turn must resolve the ACP client's cwd, not the Hermes workspace.
``resolve_agent_cwd()`` is what the system prompt reports as "Current
working directory". When it is left unpinned it falls back to
TERMINAL_CWD / the launch dir, so the prompt advertises one root while
the tools are rooted at the editor's project. The model then emits
absolute paths under the advertised root and the edit silently lands
outside the workspace the client asked for.
"""
workspace = tmp_path / "project"
workspace.mkdir()
new_resp = await agent.new_session(cwd=str(workspace))
state = agent.session_manager.get_session(new_resp.session_id)
observed = {}
def capture_cwd(*args, **kwargs):
from agent.runtime_cwd import resolve_agent_cwd
observed["cwd"] = str(resolve_agent_cwd())
return {"final_response": "ok", "messages": []}
state.agent.run_conversation = capture_cwd
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
agent._conn = mock_conn
await agent.prompt(
prompt=[TextContentBlock(type="text", text="hello")],
session_id=new_resp.session_id,
)
assert observed.get("cwd") == str(workspace), (
"the turn resolved "
f"{observed.get('cwd')!r} instead of the ACP client's cwd "
f"{str(workspace)!r}"
)
@pytest.mark.asyncio
async def test_prompt_auto_titles_session(self, agent):