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 <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-07 19:42:15 -07:00
1 parent a818be1ed4
commit 898a01b766
4 files changed
+91 -4

No files matched your search

+2
View File
@@ -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)
+22 -2
View File
@@ -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,
+13 -2
View File
@@ -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,
)
@@ -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"}