fix(prefix-cache): drop the workspace pin at session boundaries; bind session cwd for /context

The pin from the previous commit lives in _SESSION_STATE but nothing cleared it, so a CLI
/new, /resume or /branch (same AIAgent, reset_session_state + _invalidate_system_prompt)
replayed the previous session's git snapshot into the new session's prompt. Clear it in
reset_session_state next to the other session anchors; one invariant test (red without it).

The TUI/Desktop session.context_breakdown RPC ran the prompt builder on the RPC thread
with no session cwd bound, so it re-probed against the backend's cwd and overwrote the
session's pin — one /context between compactions restored the divergence this fix removes.
Bind the session context around the build like the live rebuild in server.py does.

Also: trim _coding_parts' docstring to the WHY, drop the isinstance/len guard on a value only
this function writes, and remove the tests' assertions on the private pin shape.
This commit is contained in:
kshitijk4poor
2026-09-06 16:58:54 +00:00
committed by kshitij
parent 0ed7acb051
commit 311b980bb6
4 changed files with 64 additions and 40 deletions
+6 -12
View File
@@ -540,17 +540,11 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
"""``(prefix, workspace, trailing)`` coding-posture blocks; all empty
without tools or when probing fails (it must never block prompt build).
The workspace block is a LIVE ``git status``/``git log`` probe and it leads the
context tier — ahead of the entire volatile band. Since #98426 the builder runs
again at every compaction boundary, so re-probing here re-emits different bytes on
any session whose repo moved (a commit, one edited or untracked file), pushing the
prefix-cache divergence point in front of skills, memory and the timestamp line and
making #98426's byte-equality keep-prompt fast path unreachable in a coding session.
It also contradicts the block's own "snapshot at session start" wording and the
freeze ``coding_context`` documents. So the first build pins the bytes on the agent,
keyed by the resolved cwd (one gateway serves many cwds, and the per-turn terminal
scope can move it), and later rebuilds replay them instead of shelling out again.
A resumed process has no pin and legitimately re-snapshots at its own session start.
The workspace block is a live git probe that leads the context tier, ahead of the whole
volatile band; re-probing at the compaction rebuild re-emits different bytes for any
repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per
session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and
replayed on rebuilds; ``reset_session_state`` drops the pin at a session boundary.
"""
try:
from agent.coding_context import coding_system_prompt_parts
@@ -560,7 +554,7 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
cwd_key = str(cwd) if cwd is not None else ""
pinned = getattr(agent, "_frozen_workspace_snapshot", None)
# "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes.
replay = pinned[1] if isinstance(pinned, tuple) and len(pinned) == 2 and pinned[0] == cwd_key else None
replay = pinned[1] if pinned is not None and pinned[0] == cwd_key else None
parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model,
valid_tool_names=agent.valid_tool_names, workspace_block=replay)
if replay is None:
+3
View File
@@ -398,6 +398,9 @@ class AIAgent(
# Session boundary: the usage anchor describes the OLD transcript; fall back to full estimation.
self._usage_anchor = None
self._turn_base_usage_anchor = None
# The workspace snapshot is pinned per session (agent/system_prompt.py::_coding_parts); a
# /new, /resume or /branch on the same agent must re-snapshot at its own session start.
self._frozen_workspace_snapshot = None
# Turn counter (added after reset_session_state was first written — #2635)
self._user_turn_count = 0
+50 -28
View File
@@ -70,6 +70,22 @@ class TestPluginRerenderFailOpen(unittest.TestCase):
self.assertEqual(rendered, ())
def _init_repo(path, first_commit):
import subprocess
path.mkdir()
for cmd in (
["git", "init", "-q", "-b", "main"],
["git", "config", "user.email", "t@t"],
["git", "config", "user.name", "t"],
["git", "config", "core.autocrlf", "false"],
):
subprocess.run(cmd, cwd=path, check=True)
(path / "main.py").write_text("print(1)\n")
subprocess.run(["git", "add", "-A"], cwd=path, check=True)
subprocess.run(["git", "commit", "-qm", first_commit], cwd=path, check=True)
return path
class TestCommitAlwaysRebuilds(unittest.TestCase):
"""Source-level contract pins for the commit-site semantics."""
@@ -105,18 +121,7 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-ws-"))
try:
repo = tmp / "proj"
repo.mkdir()
for cmd in (
["git", "init", "-q", "-b", "main"],
["git", "config", "user.email", "t@t"],
["git", "config", "user.name", "t"],
["git", "config", "core.autocrlf", "false"],
):
subprocess.run(cmd, cwd=repo, check=True)
(repo / "main.py").write_text("print(1)\n")
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "init commit"], cwd=repo, check=True)
repo = _init_repo(tmp / "proj", "init commit")
agent = _agent(
load_soul_identity=False,
@@ -145,8 +150,6 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
# First build: pins the snapshot
p1 = build_system_prompt(agent)
self.assertIn("Workspace (snapshot at session start", p1)
self.assertIsNotNone(agent._frozen_workspace_snapshot)
self.assertEqual(agent._frozen_workspace_snapshot[0], str(repo))
# Now repo mutates (agent touched and committed new files)
(repo / "new_file.py").write_text("print(2)\n")
@@ -172,19 +175,8 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-cwd-"))
try:
repo1 = tmp / "r1"; repo1.mkdir()
repo2 = tmp / "r2"; repo2.mkdir()
for r in (repo1, repo2):
for cmd in (
["git", "init", "-q", "-b", "main"],
["git", "config", "user.email", "t@t"],
["git", "config", "user.name", "t"],
["git", "config", "core.autocrlf", "false"],
):
subprocess.run(cmd, cwd=r, check=True)
(r / "main.py").write_text("print(1)\n")
subprocess.run(["git", "add", "-A"], cwd=r, check=True)
subprocess.run(["git", "commit", "-qm", f"init {r.name}"], cwd=r, check=True)
repo1 = _init_repo(tmp / "r1", "init r1")
repo2 = _init_repo(tmp / "r2", "init r2")
agent = _agent(
load_soul_identity=False,
@@ -217,11 +209,41 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
patch("agent.system_prompt.resolve_context_cwd", return_value=repo2):
p2 = build_system_prompt(agent)
self.assertIn(f"init {repo2.name}", p2)
self.assertEqual(agent._frozen_workspace_snapshot[0], str(repo2))
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_session_boundary_drops_the_pin_so_a_new_session_resnapshots(self):
"""A /new, /resume or /branch reuses the AIAgent; the next session must see the live repo."""
import tempfile, shutil, subprocess
from pathlib import Path
from agent.system_prompt import build_system_prompt, invalidate_system_prompt
from run_agent import AIAgent
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-boundary-"))
try:
repo = _init_repo(tmp / "proj", "init commit")
agent = _agent(
load_soul_identity=False, skip_context_files=True, valid_tool_names={"terminal"},
platform="cli", model="gpt-4o", _task_completion_guidance=False,
_parallel_tool_call_guidance=False, _tool_use_enforcement=False, _execution_guidance=False,
_environment_probe=False, _bot_mode_protocol=False, _kanban_worker_guidance="",
pass_session_id=False, session_id="s1", _emit_status=lambda *a, **k: None,
_frozen_workspace_snapshot=None, context_compressor=None, _session_db=None,
_transition_context_engine_session=lambda **kw: None,
)
with patch("agent.prompt_builder.load_soul_md", return_value=""), \
patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \
patch("agent.system_prompt.resolve_context_cwd", return_value=repo):
build_system_prompt(agent)
subprocess.run(["git", "commit", "-qm", "second commit", "--allow-empty"], cwd=repo, check=True)
# The CLI session boundary (cli_session_mixin.new_session) on the same agent object.
AIAgent.reset_session_state(agent)
invalidate_system_prompt(agent)
self.assertIn("second commit", build_system_prompt(agent))
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -1154,11 +1154,16 @@ def _(rid, params: dict, session: dict) -> dict:
"model": _metadata_mirror(session).get("model", "")})
with session["history_lock"]:
history = list(session.get("history", []))
# Bind the session context: on the RPC thread the session cwd is unset, so the prompt build
# inside would key its workspace pin on the backend's cwd and overwrite the session's pin.
tokens = _set_session_context(session["session_key"])
try:
from agent.context_breakdown import compute_session_context_breakdown
return _ok(rid, compute_session_context_breakdown(agent, history))
except Exception as exc:
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
finally:
_clear_session_context(tokens)
# ── pet ──────────────────────────────────────────────────────────────