* 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:
4 files changed
+91
-4
No files matched your search
@@ -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
@@ -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
@@ -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"}
|
||||
Reference in new issue
Block a user