diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 6cf1c1a26c..9bd52f03fc 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -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: diff --git a/run_agent.py b/run_agent.py index fc1fe0711e..37b25fd214 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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 diff --git a/tests/test_compaction_prompt_rebuild.py b/tests/test_compaction_prompt_rebuild.py index 432eda9fb4..eabe8e7235 100644 --- a/tests/test_compaction_prompt_rebuild.py +++ b/tests/test_compaction_prompt_rebuild.py @@ -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() diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 078f321769..b85d53e6ba 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -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 ──────────────────────────────────────────────────────────────