diff --git a/api/streaming.py b/api/streaming.py index f8f63e1e..b3bf664e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -586,9 +586,25 @@ def _message_text(value) -> str: return _strip_thinking_markup(str(value or '').strip()) -def _strip_workspace_prefix(text: str) -> str: - """Remove WebUI's model-facing workspace tag from display identity text.""" - return re.sub(r'^\s*\[Workspace:[^\]]+\]\s*', '', str(text or '')).strip() +_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') +_LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*') + + +def _escape_workspace_prefix_path(path: str) -> str: + return str(path or '').replace('\\', '\\\\').replace(']', '\\]') + + +def _workspace_context_prefix(path: str) -> str: + return f"[Workspace::v1: {_escape_workspace_prefix_path(path)}]\n" + + +def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str: + """Remove WebUI-injected workspace tags without eating user-typed text.""" + value = str(text or '') + stripped = _WORKSPACE_PREFIX_RE.sub('', value, count=1) + if include_legacy and stripped == value: + stripped = _LEGACY_WORKSPACE_PREFIX_RE.sub('', value, count=1) + return stripped.strip() def _first_exchange_snippets(messages): @@ -1051,7 +1067,7 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option assistant_text = _strip_thinking_markup(assistant_text or '').strip() if not user_text: return None - user_text = re.sub(r'^\[Workspace:[^\]]+\]\s*', '', user_text) + user_text = _strip_workspace_prefix(user_text) user_text = re.sub(r'\s+', ' ', user_text).strip() assistant_text = re.sub(r'\s+', ' ', assistant_text).strip() combined = f"{user_text} {assistant_text}".strip().lower() @@ -1443,7 +1459,7 @@ def _message_identity(msg): # visible optimistic bubble contains only the human text. Treat them as # the same turn for merge/dedup purposes; otherwise compaction results # render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk"). - text = _strip_workspace_prefix(text) + text = _strip_workspace_prefix(text, include_legacy=True) if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'): return None return ( @@ -1482,7 +1498,12 @@ def _find_current_user_turn(messages, msg_text): if not isinstance(msg, dict) or msg.get('role') != 'user': continue fallback = idx - text = " ".join(_strip_workspace_prefix(_message_text(msg.get('content', ''))).split()) + text = " ".join( + _strip_workspace_prefix( + _message_text(msg.get('content', '')), + include_legacy=True, + ).split() + ) if needle and (needle in text or text in needle): return idx return fallback @@ -2534,15 +2555,15 @@ def _run_agent_streaming( # Prepend workspace context so the agent always knows which directory # to use for file operations, regardless of session age or AGENTS.md defaults. - workspace_ctx = f"[Workspace: {s.workspace}]\n" + workspace_ctx = _workspace_context_prefix(str(s.workspace)) workspace_system_msg = ( f"Active workspace at session start: {s.workspace}\n" - "Every user message is prefixed with [Workspace: /absolute/path] indicating the " + "Every user message is prefixed with [Workspace::v1: /absolute/path] indicating the " "workspace the user has selected in the web UI at the time they sent that message. " "This tag is the single authoritative source of the active workspace and updates " "with every message. It overrides any prior workspace mentioned in this system " "prompt, memory, or conversation history. Always use the value from the most recent " - "[Workspace: ...] tag as your default working directory for ALL file operations: " + "[Workspace::v1: ...] tag as your default working directory for ALL file operations: " "write_file, read_file, search_files, terminal workdir, and patch. " "Never fall back to a hardcoded path when this tag is present." ) diff --git a/static/ui.js b/static/ui.js index 1ceed738..4599dc50 100644 --- a/static/ui.js +++ b/static/ui.js @@ -70,7 +70,7 @@ function _isBacktickFenceClose(line,minLen){ */ function _stripWorkspaceDisplayPrefix(text){ - return String(text||'').replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim(); + return String(text||'').replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'').trim(); } function _renderUserFencedBlocks(text){ const stash=[]; diff --git a/tests/test_issue1913_workspace_prefix_sentinel.py b/tests/test_issue1913_workspace_prefix_sentinel.py new file mode 100644 index 00000000..9f5e03f2 --- /dev/null +++ b/tests/test_issue1913_workspace_prefix_sentinel.py @@ -0,0 +1,34 @@ +from api.streaming import ( + _fallback_title_from_exchange, + _strip_workspace_prefix, + _workspace_context_prefix, +) + + +def test_workspace_prefix_strips_only_versioned_sentinel(): + assert _strip_workspace_prefix("[Workspace::v1: /tmp/project]\nHello") == "Hello" + assert _strip_workspace_prefix("[Workspace: /tmp/project]\nHello") == "[Workspace: /tmp/project]\nHello" + + +def test_workspace_prefix_escapes_paths_with_closing_brackets(): + prefix = _workspace_context_prefix("/tmp/proj-[wip]/src") + + assert prefix == "[Workspace::v1: /tmp/proj-[wip\\]/src]\n" + assert _strip_workspace_prefix(f"{prefix}Continue") == "Continue" + + +def test_legacy_workspace_prefix_only_strips_for_compatibility_callers(): + legacy = "[Workspace: /tmp/project]\nContinue" + + assert _strip_workspace_prefix(legacy) == legacy + assert _strip_workspace_prefix(legacy, include_legacy=True) == "Continue" + + +def test_user_typed_legacy_workspace_prefix_survives_fallback_title(): + title = _fallback_title_from_exchange( + "[Workspace: /tmp/project]\nExplain this literal prefix", + "Sure", + ) + + assert title is not None + assert title.startswith("Workspace tmp/project") diff --git a/tests/test_workspace_display_prefix.py b/tests/test_workspace_display_prefix.py index 98528011..7965362b 100644 --- a/tests/test_workspace_display_prefix.py +++ b/tests/test_workspace_display_prefix.py @@ -16,7 +16,8 @@ def test_workspace_display_prefix_helper_strips_leading_metadata_only(): assert end != -1, "user fenced block renderer not found after prefix stripper" helper = src[start:end] - assert r"^\s*\[Workspace:[^\]]+\]\s*" in helper + assert r"^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*" in helper + assert "[Workspace:[^\\]]+" not in helper assert ".trim()" in helper