From 898a01b766cb68cb71d2408e871854a366153aad Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 7 Aug 2026 19:42:15 -0700 Subject: [PATCH] Release exp-v0.52.173: don't mutate system prompt on workspace switch (#6683) (#6841) * fix(cache): don't mutate system prompt on workspace switch (#6672) * test(#6672): pin created_workspace freeze + compact round-trip + legacy fallback (closes coverage gap flagged in review) * Release exp-v0.52.173: don't mutate system prompt on workspace switch (#6683) --------- Co-authored-by: webtecnica Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/models.py | 24 ++++++++- api/streaming.py | 15 +++++- ...ssue6672_workspace_switch_system_prompt.py | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue6672_workspace_switch_system_prompt.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f7a779..a2951168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ ### Fixed +- **Switching the workspace mid-session no longer rewrites the system prompt or invalidates the LLM prefix cache.** The active workspace (`s.workspace`) updates on every turn when you switch workspaces via the WebUI header dropdown, and that live value was interpolated into the system prompt (`msg[0]`) — so a mid-session switch mutated `msg[0]` and invalidated the model's prefix cache (APC / Radix Tree) for the entire transcript, silently re-billing and slowing every subsequent turn. Sessions now freeze an immutable `created_workspace` snapshot at creation and interpolate that (never the live workspace) into the system prompt, keeping `msg[0]` byte-stable across switches; the active workspace still reaches the model through the `[Workspace::v1: /path]` tag on the current user turn, so file operations continue to target the workspace you switched to. Legacy sessions with no persisted snapshot fall back to their recorded workspace. Thanks @webtecnica. (#6683, #6672) + - **You can now scroll up to read earlier content while a response is still streaming.** During an in-flight programmatic (auto-follow) scroll, the programmatic-scroll guard returned before the native scroll listener could see the event, so a gentle upward wheel (a small `deltaY` above the ordinary −30 sticky-unpin threshold) was swallowed and live-tail follow kept yanking the reader back to the bottom. A `_freshProgrammaticScrollActive()` guard (a 150 ms validity window on the programmatic-scroll flag, refreshed by every programmatic scroll writer) now lets even a small upward wheel during a fresh programmatic scroll immediately stop auto-follow, so scrolling up mid-stream sticks. The ordinary −30 direct threshold and the touch/moved-up branches are unchanged, and the re-pin-only-at-true-bottom (#4295), gentle-wheel-intent (#4970), and footer-jitter (#4346) behaviors are preserved. Thanks @franksong2702. (#6453, #6414) - **The just-settled activity Worklog no longer flickers/resets when a streamed turn finishes.** On stream completion the transcript was rendered once with the turn's Worklog forced open, then a second whole-transcript rebuild ran immediately just to collapse that Worklog — the second rebuild tore out the DOM the user had just seen and exposed a brief reset/flicker frame. Stream completion now collapses the single settled Worklog **in place** (keeping the canonical first render), deferring row removal to a `requestAnimationFrame` only after the group is hidden and preserving nested detail-disclosure state; the rows stay recoverable from the settled transcript via the existing deferred-row path, and a missing owner/identity safely falls back to the old full render. Error/cancel/disconnect cleanup remains destructive as before. The collapsed Worklog rail's tap target is also enlarged to clear 44px on touch devices. Thanks @franksong2702. (#6454, #6414) diff --git a/api/models.py b/api/models.py index dd3b5b33..914ed16b 100644 --- a/api/models.py +++ b/api/models.py @@ -1187,7 +1187,8 @@ def model_explicit_pick_signature(model, model_provider) -> str: class Session: def __init__(self, session_id: str=None, title: str='Untitled', - workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, + workspace=str(DEFAULT_WORKSPACE), created_workspace=None, + model=DEFAULT_MODEL, model_provider=None, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned: bool=False, archived: bool=False, @@ -1238,6 +1239,21 @@ class Session: self.session_id = session_id or uuid.uuid4().hex[:12] self.title = title self.workspace = str(Path(workspace).expanduser().resolve()) + # #6672: immutable snapshot of the workspace at session creation time. + # s.workspace is updated on every turn when the user switches workspaces + # mid-session via the WebUI header dropdown; interpolating the live + # value into the system prompt would mutate msg[0] and invalidate LLM + # prefix caches (APC/Radix Tree) for the whole transcript. Freeze the + # original workspace here and keep the active workspace out of the + # system prompt (mid-session switches ride on the [Workspace::v1: ...] + # tag appended to the active user turn instead). Legacy sessions + # without a persisted created_workspace fall back to the workspace + # recorded on disk, which is the best available approximation. + self.created_workspace = ( + str(Path(created_workspace).expanduser().resolve()) + if created_workspace + else self.workspace + ) self.model = model self.model_provider = str(model_provider).strip().lower() if model_provider else None # #5979: signature of the model the user DELIBERATELY picked this session @@ -1369,7 +1385,7 @@ class Session: # without parsing the full messages array (which may be 400KB+). # Fields are listed in the order they should appear in the JSON file. METADATA_FIELDS = [ - 'session_id', 'title', 'workspace', 'model', 'model_provider', 'model_explicit_pick_signature', 'created_at', 'updated_at', + 'session_id', 'title', 'workspace', 'created_workspace', 'model', 'model_provider', 'model_explicit_pick_signature', 'created_at', 'updated_at', 'pinned', 'archived', 'project_id', 'profile', 'input_tokens', 'output_tokens', 'estimated_cost', 'cache_read_tokens', 'cache_write_tokens', @@ -1751,6 +1767,10 @@ class Session: # Only emit 'parent_session_id' when set (the /branch fork link, #1342). # Sessions without a fork must not leak None — see test_session_lineage_metadata_api. **({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}), + # #6672: immutable workspace captured at session creation, exposed so + # the UI can distinguish it from the live `workspace` field (which + # updates on mid-session switches without touching the system prompt). + 'created_workspace': getattr(self, 'created_workspace', None) or self.workspace, **({ 'compression_recovery_source_session_id': self.compression_recovery_source_session_id, 'compression_recovery_action': self.compression_recovery_action, diff --git a/api/streaming.py b/api/streaming.py index f83a6d35..3dc62a20 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -9414,8 +9414,16 @@ 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 = _workspace_context_prefix(str(s.workspace)) + # #6672: interpolate the session-CREATION workspace (immutable), never + # the live s.workspace, into the system prompt. s.workspace changes on + # every mid-session workspace switch in the WebUI header; mutating the + # system prompt would rewrite msg[0] and invalidate the LLM prefix + # cache (APC/Radix Tree) for the whole 50k+ token transcript. Active + # switches still reach the model via the [Workspace::v1: ...] tag on + # the current user turn (workspace_ctx above), which lives in msg[-1]. + _session_workspace_frozen = getattr(s, 'created_workspace', None) or str(s.workspace) workspace_system_msg = ( - f"Active workspace at session start: {s.workspace}\n" + f"Active workspace at session start: {_session_workspace_frozen}\n" "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 " @@ -9453,7 +9461,10 @@ def _run_agent_streaming( 'source': 'webui', 'session_id': session_id, 'profile': getattr(s, 'profile', None), - 'workspace': s.workspace, + # #6672: frozen session-creation workspace — see + # workspace_system_msg above. Live workspace switches stay out + # of msg[0] so LLM prefix caches are not invalidated. + 'workspace': _session_workspace_frozen, }, config_data=_cfg, ) diff --git a/tests/test_issue6672_workspace_switch_system_prompt.py b/tests/test_issue6672_workspace_switch_system_prompt.py new file mode 100644 index 00000000..eab7a2e6 --- /dev/null +++ b/tests/test_issue6672_workspace_switch_system_prompt.py @@ -0,0 +1,54 @@ +"""Regression tests for #6672 / PR #6683: switching the workspace mid-session +must NOT mutate the system prompt (msg[0]). + +`Session.workspace` is updated on every turn when the user switches workspaces +via the WebUI header dropdown. Before the fix, that live value was interpolated +into the system prompt, so a mid-session switch rewrote msg[0] and invalidated +the LLM prefix cache (APC/Radix Tree) for the whole transcript. The fix freezes +an immutable `created_workspace` snapshot at session creation and uses it in the +system prompt; live switches ride on the [Workspace::v1: ...] tag on msg[-1]. +""" +import os + +from api.models import Session + + +def test_created_workspace_frozen_at_init(): + s = Session(workspace="/tmp/proj-alpha") + assert s.created_workspace == os.path.realpath(os.path.expanduser("/tmp/proj-alpha")) + # Simulate a mid-session workspace switch (what the WebUI header dropdown does). + s.workspace = os.path.realpath("/tmp/proj-beta") + # The creation snapshot must NOT follow the live switch. + assert s.created_workspace == os.path.realpath("/tmp/proj-alpha") + assert s.created_workspace != s.workspace + + +def test_created_workspace_defaults_to_workspace_when_unset(): + s = Session(workspace="/tmp/proj-gamma") + # No explicit created_workspace passed -> falls back to the workspace. + assert s.created_workspace == os.path.realpath("/tmp/proj-gamma") + + +def test_created_workspace_round_trips_through_compact_and_load(): + s = Session(workspace="/tmp/proj-orig") + s.workspace = os.path.realpath("/tmp/proj-switched") + d = s.compact() + # compact() exposes the frozen creation workspace, distinct from the live one. + assert d["created_workspace"] == os.path.realpath("/tmp/proj-orig") + assert d["workspace"] == os.path.realpath("/tmp/proj-switched") + # Round-trip: reconstruct from the persisted dict and confirm the frozen + # value is preserved (not re-derived from the switched workspace). + s2 = Session(**{k: v for k, v in d.items() if k in _init_kwargs()}) + assert s2.created_workspace == os.path.realpath("/tmp/proj-orig") + + +def test_legacy_session_without_created_workspace_falls_back(): + # A legacy on-disk session has no persisted created_workspace. + s = Session(workspace="/tmp/proj-legacy", created_workspace=None) + assert s.created_workspace == os.path.realpath("/tmp/proj-legacy") + + +def _init_kwargs(): + """Kwargs accepted by Session.__init__, for a safe dict round-trip.""" + import inspect + return set(inspect.signature(Session.__init__).parameters) - {"self"}