Fix workspace prefix sentinel handling

This commit is contained in:
Frank Song
2026-05-09 00:29:22 +08:00
committed by nesquena-hermes
parent 6253032b53
commit ccdc055c36
4 changed files with 67 additions and 11 deletions

View File

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

View File

@@ -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=[];

View File

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

View File

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