diff --git a/agent/context_references.py b/agent/context_references.py index dea5fd4c3a5ed..8981aa472f507 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -19,6 +19,7 @@ REFERENCE_PATTERN = re.compile( rf"(?diff|staged)\b|(?Pfile|folder|git|url):(?P{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))" ) TRAILING_PUNCTUATION = ",.;!?" +_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""") _SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh") _SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",) _SENSITIVE_HOME_FILES = ( @@ -60,6 +61,21 @@ class ContextReferenceResult: blocked: bool = False +def format_reference_value(value: str) -> str: + """Quote a reference value so ``REFERENCE_PATTERN`` reads it back whole. + + The unquoted alternative in the pattern is ``\\S+``, so a path containing a + space parses as a truncated ref with the tail left behind as loose text. + Mirrors ``formatRefValue`` in the desktop's directive-text.tsx. + """ + if not _NEEDS_QUOTING.search(value): + return value + for quote in ("`", '"', "'"): + if quote not in value: + return f"{quote}{value}{quote}" + return value + + def parse_context_references(message: str) -> list[ContextReference]: refs: list[ContextReference] = [] if not message: diff --git a/tests/agent/test_context_references.py b/tests/agent/test_context_references.py index 1fab3ef1b74e8..51f66fb937afa 100644 --- a/tests/agent/test_context_references.py +++ b/tests/agent/test_context_references.py @@ -445,3 +445,25 @@ async def test_canonical_guard_fails_closed_when_lookup_raises(tmp_path: Path, m "credential deny-list" in warning or "sensitive credential" in warning for warning in result.warnings ) + + +@pytest.mark.parametrize( + "value", + [ + "/tmp/plain.png", + "/Users/me/Library/Application Support/Hermes/composer-images/a.png", + r"C:\Users\John Doe\Pictures\cat.png", + "/tmp/report (final).pdf", + "/tmp/it's here.png", + '/tmp/say "hi".png', + ], +) +def test_format_reference_value_round_trips_through_the_parser(value): + """Whatever the path contains, the formatted ref must parse back whole — + an unquoted value stops at the first space and strands the tail as text.""" + from agent.context_references import REFERENCE_PATTERN, format_reference_value + + match = REFERENCE_PATTERN.search(f"@file:{format_reference_value(value)}") + + assert match is not None + assert match.group("value").strip("`\"'") == value diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fcf22be117c9c..99c811c484d68 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -13245,6 +13245,21 @@ def test_build_persist_message_with_image_refs_without_text_is_refs_only(monkeyp assert server._build_persist_message_with_image_refs("", [str(img)]) == f"@image:{img}" +def test_build_persist_message_quotes_paths_containing_spaces(tmp_path): + """The unquoted alternative in the directive pattern is ``\\S+``, so a path + with a space parses as a truncated ref with the tail left as loose text. + Desktop composer images live in the app's userData dir, which on macOS is + ``~/Library/Application Support/...`` — a space every time.""" + img_dir = tmp_path / "Application Support" / "Hermes" / "composer-images" + img_dir.mkdir(parents=True) + img = img_dir / "cat.png" + img.write_bytes(b"png") + + result = server._build_persist_message_with_image_refs("what is this?", [str(img)]) + + assert result == f"@image:`{img}`\nwhat is this?" + + def test_prompt_submit_passes_persist_user_message_to_agent(monkeypatch): """#70720: _run_prompt_submit must forward the (image-ref-aware) persisted user message to run_conversation via persist_user_message, so the gateway diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9f55c63c58f14..22b3764f7fcbc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5749,8 +5749,10 @@ def _build_persist_message_with_image_refs(user_text: str, image_paths: list[str persisted as-is (it silently breaks image rendering after a full restart, and reorders image/text on live session-switch reconciliation). """ + from agent.context_references import format_reference_value + text = user_text or "" - refs = "\n".join(f"@image:{p}" for p in image_paths if Path(p).exists()) + refs = "\n".join(f"@image:{format_reference_value(p)}" for p in image_paths if Path(p).exists()) if not refs: return text return f"{refs}\n{text}" if text else refs @@ -11015,9 +11017,7 @@ def _run_prompt_submit( "conversation_history": list(history), "stream_callback": _stream, "persist_user_message": ( - _build_persist_message_with_image_refs(prompt, images) - if images - else prompt + _build_persist_message_with_image_refs(prompt, images) if images else prompt ), } try: