fix(memory): resolve Trae cwd metadata from user reminders (#1737) (#1887)

## Description

Project memory routing misses Trae Desktop workspaces when Trae sends
the cwd inside a user-message `<system-reminder>` block. The existing
resolver already understands `cwd:` once the text reaches
`ProjectResolver`, but `extract_system_prompt()` only reads top-level
system fields and `role == "system"` messages, so the Trae metadata is
dropped before routing can use it. This adds a narrow fallback that
scans user-message text only when no system prompt was found and only
returns that text when it contains one of the existing cwd prefixes.
Closes #1737.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Extended `extract_system_prompt()` with a cwd-prefix-gated
user-message fallback for OpenAI-compatible payloads that carry
environment metadata in text blocks.
- Kept top-level `system` and `role == "system"` precedence unchanged,
so regular system prompt routing still wins over user fallback content.
- Added focused storage-router tests for the Trae `<system-reminder>`
payload shape, ordinary user text without cwd, system-message
precedence, and a non-user cwd spoof boundary.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_memory_storage_router.py -v`)
- [x] Linting passes (`uv run ruff check
headroom/memory/storage_router.py tests/test_memory_storage_router.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_memory_storage_router.py -v
26 passed in 0.23s

uv run ruff check headroom/memory/storage_router.py tests/test_memory_storage_router.py
All checks passed!

uv run ruff format headroom/memory/storage_router.py tests/test_memory_storage_router.py --check
2 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, no live Trae client required
for the unit-level payload regression.
- Exact command / steps: run the focused storage-router pytest against a
request body shaped like the issue's Trae payload, with
`messages[0].role == "user"` and a text block containing
`<system-reminder>` plus `cwd:
S:\workspace-zhuangxiu\decorate-offer-api`.
- Observed result: the extracted prompt reaches `ProjectResolver`, and
the resolved display name is `decorate-offer-api`; ordinary user text
without cwd still returns an empty prompt; an explicit system message
still wins over a user cwd fallback.
- Not tested: live Trae Desktop network capture and full-suite CI, which
remain outside this focused routing fix.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

`CHANGELOG.md` is unchanged because this repo generates release notes
from conventional commits. Type checking is not part of the focused
local proof for this Python-only storage-router change. The fix is
intentionally scoped to request prompt extraction and does not add
Trae-specific branches to OpenAI handlers.
This commit is contained in:
Rod Boev 2026-07-08 18:49:21 -04:00 committed by GitHub
parent 1c947b1103
commit 3e85eb1880
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 82 additions and 0 deletions

View file

@ -448,4 +448,25 @@ def extract_system_prompt(body: Mapping[str, Any]) -> str:
if parts: if parts:
return "\n".join(parts) return "\n".join(parts)
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") != "user":
continue
content = msg.get("content")
user_text: str | None = None
if isinstance(content, str):
user_text = content
elif isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
if parts:
user_text = "\n".join(parts)
if user_text and any(prefix in user_text for prefix in _CWD_PREFIXES):
return user_text
return "" return ""

View file

@ -155,6 +155,67 @@ def test_extract_system_prompt_missing_returns_empty() -> None:
assert extract_system_prompt({"messages": []}) == "" assert extract_system_prompt({"messages": []}) == ""
def test_extract_system_prompt_user_reminder_with_cwd_reaches_resolver() -> None:
body = {
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"<system-reminder>\n\n"
"The maximum number of terminals is 5.\n\n"
"<available_terminal>\n"
"- terminal_id: 9\n"
"- cwd: S:\\workspace-zhuangxiu\\decorate-offer-api\n"
"</available_terminal>\n\n"
"</system-reminder>"
),
}
],
}
]
}
prompt = extract_system_prompt(body)
assert "cwd:" in prompt
resolved = ProjectResolver().resolve(_ctx(system_prompt=prompt))
assert resolved is not None
_, display = resolved
assert "decorate-offer-api" in display
def test_extract_system_prompt_ordinary_user_text_returns_empty() -> None:
body = {"messages": [{"role": "user", "content": "Hello, can you help me refactor this?"}]}
assert extract_system_prompt(body) == ""
def test_extract_system_prompt_system_message_beats_user_cwd_fallback() -> None:
body = {
"messages": [
{"role": "system", "content": "Working directory: /system/project"},
{"role": "user", "content": "cwd: /user/project\nDo the thing."},
]
}
prompt = extract_system_prompt(body)
resolved = ProjectResolver().resolve(_ctx(system_prompt=prompt))
assert prompt == "Working directory: /system/project"
assert resolved is not None
_, display = resolved
assert display == "project"
def test_extract_system_prompt_cwd_in_non_user_message_returns_empty() -> None:
body = {"messages": [{"role": "assistant", "content": "cwd: /spoof/project"}]}
assert extract_system_prompt(body) == ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# BackendRouter path-layout tests (no real backend I/O — we stub the class). # BackendRouter path-layout tests (no real backend I/O — we stub the class).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------