Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,13 +595,41 @@ def _alibaba_identity_part(agent: Any) -> List[str]:
def _workspace_pin_key() -> str:
"""The directory the workspace probe inspects, which is also the prompt's ``Current working
directory``: a build with no cwd bound (launch dir) and a later one binding that same dir
(TUI ``/compress``) are one workspace, not two."""
(TUI ``/compress``) are one workspace, not two.

Normalized to the resolved real path: a launch-dir key comes from ``os.getcwd()`` (which
reports the physical path — ``/private/var/...`` on macOS) while a bound cwd arrives as the
session spelled it (``/var/...`` through the symlink). Comparing those spellings misses the
pin, the rebuild re-probes git live, and the session-start snapshot is rewritten mid-session
— invalidating the cached prompt prefix for a workspace that never changed."""
try:
return str(resolve_context_cwd() or resolve_agent_cwd())
raw = str(resolve_context_cwd() or resolve_agent_cwd())
return str(Path(raw).resolve()) if raw else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nonblocking] The same spelling comparison remains at the runtime-cwd sink. agent/conversation_loop.py:877-878 (_stored_prompt_matches_runtime) still compares the stored Current working directory to str(resolve_agent_cwd()) raw, and agent/prompt_builder.py:1001 renders that line in the session's spelling.

Probe on this head with real environment hints: build from the launch dir, add an untracked file, then set_session_cwd(<symlink to the repo>) and rebuild. The workspace block now replays, and the first differing byte is 6407 of 6781, the host Current working directory: line. On main it was 4878, the workspace block. _stored_prompt_matches_runtime(agent, p1) is False on both, so a resumed or adopted session still fully rebuilds across a spelling-only change.

Suggested fix in _stored_prompt_matches_runtime: if stored_cwd and Path(stored_cwd).resolve() != resolve_agent_cwd().resolve(): return False. Please confirm whether that belongs here or in a follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Compare existing directories by filesystem identity. Path.resolve() follows symlinks, but on default case-insensitive macOS APFS it preserves the casing supplied by the caller. As a result, .../MixedCase and .../mixedcase refer to the same directory (os.path.samefile() returns true) but still produce different keys here. _coding_parts() then misses the pin and probes Git again.

I reproduced this on the exact head through the real set_session_cwd() path: build in MixedCase, add an untracked file, invalidate the prompt, then bind mixedcase. The rebuilt workspace snapshot changed from Status: clean to Status: 1 untracked. That is the same mid-session snapshot drift this change is meant to prevent.

Please compare live paths with Path.samefile() or an equivalent filesystem-identity check, with a safe fallback when a persisted path is no longer available. Apply the same equivalence in _seed_workspace_pin(), and add a macOS case-alias regression through the real session-cwd resolver.

except OSError: # deleted cwd
return ""


def _same_live_dir(a: Path, b: Path) -> bool:
"""Directory identity that survives spelling aliases resolve() cannot normalize. On a
case-insensitive filesystem (default macOS APFS) resolve() follows symlinks but keeps the
caller's casing, so one directory spelled ``MixedCase`` and ``mixedcase`` yields two
resolved strings — and two pin keys, so the rebuild re-probes git and rewrites the
session-start snapshot mid-session. When both paths exist the filesystem itself
(``samefile``) is the authority; a missing path (deleted cwd, vanished root) falls back
to the string comparison."""
if a == b:
return True
try:
return a.samefile(b)
except OSError:
return False


def _same_pin_key(a: str, b: str) -> bool:
"""Pin-key equality; "" is a real pinned value (no workspace) and only equals itself."""
return a == b or (bool(a) and bool(b) and _same_live_dir(Path(a), Path(b)))


def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]:
"""The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is ``key`` or an
ancestor); "" when the prompt has none; None when it has one for another root."""
Expand All @@ -614,7 +642,7 @@ def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]:
while start >= 0:
block = prompt[start + 2:].split("\n\n", 1)[0]
root = Path(block.split("\n", 2)[1][len("- Root: "):]).resolve()
if root == cwd or root in cwd.parents:
if _same_live_dir(root, cwd) or any(_same_live_dir(root, p) for p in cwd.parents):
return block
start = prompt.find(head, start + 2)
return None
Expand Down Expand Up @@ -647,8 +675,12 @@ def _seed_workspace_pin(agent: Any, key: str) -> None:
if not prompt:
return
stored_cwd = runtime_host_value(prompt, "Current working directory")
if stored_cwd and stored_cwd != key:
return
if stored_cwd:
# Same spelling normalization as _workspace_pin_key: the persisted hints carry the
# cwd as that surface spelled it, which can be a symlink — or, on a case-insensitive
# filesystem, a case-alias — spelling of the same resolved workspace.
if not key or not _same_live_dir(Path(stored_cwd).resolve(), Path(key).resolve()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] resolve() on persisted prompt text raises a second failure family this seam does not contain — RuntimeError (symlink loop), not OSError.

stored_cwd (the persisted Current working directory hint) and the parsed - Root: line are session-DB data. Path(...).resolve() follows symlinks; on a self-referential alias it raises RuntimeError (verified on this repo's floor Python 3.11.11, native Windows: Path(x).resolve() on x -> x raises RuntimeError, which is not an OSError subclass).

Consequence at this seam: _same_live_dir's except OSError -> False never sees a loop, so the docstring's promised fallback ("a missing path … falls back to the string comparison") does not cover it — the exception escapes _seed_workspace_pin into _coding_parts' blanket except Exception: pass, which silently drops the entire coding-context block (workspace snapshot, git state, posture) for that build instead of only missing this pin. Under the per-conversation prompt-caching invariant that is a full prefix rebuild for a workspace that never changed — the exact drift class this PR exists to prevent, reached through persisted bytes that 9fef6b1 taught this seam to resolve().

Reachability: merge base 28e6496 could not hit this at this seam (raw string comparison there); the raise site is introduced by this PR's 9fef6b1. Siblings in the same class at this head, worth covering in the same change: _workspace_pin_key's except OSError: # deleted cwd (pre-existing containment whose comment now under-counts the raise families of the resolve() it wraps) and _persisted_workspace_block's two resolves over persisted bytes.

Repair (smallest complete): normalize the two arguments inside _same_live_dir — try: a, b = a.resolve(), b.resolve() / except (OSError, RuntimeError): return a == b before the equality fast-path, keeping the documented string-comparison fallback as the catch-all — or widen both excepts here and at _workspace_pin_key to (OSError, RuntimeError). Regression contract: a test seeding a pin from a persisted prompt whose Current working directory text is a self-symlink alias asserts _seed_workspace_pin leaves the pin open without raising, and _same_pin_key("", x) still only equals itself; gate it with the platforms marker this head just added so the lane that discriminates actually imports the file.

return
block = _persisted_workspace_block(prompt, key)
# Only a real snapshot is adopted: a prompt without one (built on a surface without the
# coding posture, or with tools off) leaves the pin open so this build captures one.
Expand Down Expand Up @@ -677,7 +709,7 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
_seed_workspace_pin(agent, cwd_key)
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 pinned is not None and pinned[0] == cwd_key else None
replay = pinned[1] if pinned is not None and _same_pin_key(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:
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/982026408@qq.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lihongyuan99
75 changes: 73 additions & 2 deletions tests/agent/test_compaction_prompt_rebuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
back to its last good bytes (fail-open), never silently vanishes
"""
import unittest

import pytest

from types import SimpleNamespace
from unittest.mock import patch

Expand Down Expand Up @@ -252,6 +255,74 @@ def test_binding_the_launch_dir_explicitly_replays_the_pin(self):
os.chdir(old_cwd)
shutil.rmtree(tmp, ignore_errors=True)

def test_symlink_spelling_of_the_launch_dir_replays_the_pin(self):
"""The macOS shape on any POSIX host: os.getcwd() reports the physical dir while the bound
cwd arrives through a symlink (/var -> /private/var). Same workspace, so the pin must hit."""
import os, sys, tempfile, shutil
from pathlib import Path
from agent.system_prompt import build_system_prompt, invalidate_system_prompt

if sys.platform == "win32":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cover the Windows directory-alias case instead of skipping this regression. This skip removes the newly introduced test from native Windows CI, but the same launch-dir/explicit-cwd mismatch is reachable without privileged symlinks: a sibling NTFS junction created with cmd.exe /c mklink /J <temp>\link <temp>\repo is an alternate spelling of the physical repo, and Path.resolve() collapses it. In a native Windows/Python 3.11 probe using a real temporary Git repo and the production set_session_cwd, I built from the physical directory, added an untracked file, invalidated the prompt, then bound the junction. At the exact base (28e6496) the rebuilt workspace block became Status: 1 untracked; at this head (e1b2ed3) it stayed Status: clean and, with the runtime hints held constant, the whole prompt remained byte-equal. The same base/head result held for a fresh agent adopting a persisted prompt. Yet the new test reports skipped on Windows (focused file: 9 passed, 1 skipped), so a Windows-specific regression in either normalization path would go undetected there. Please use a disposable junction on Windows (remove the junction before removing its target) and the symlink on POSIX, then assert replay for both the live and persisted-prompt paths; retain a distinct-directory negative control so path normalization cannot hide a real workspace switch. This is a coverage gap, not a claim that this PR currently fails on Windows.

self.skipTest("directory symlinks need privileges on Windows")
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-symlink-"))
old_cwd = os.getcwd()
try:
repo = _init_repo(tmp / "proj", "init commit")
link = tmp / "link"
link.symlink_to(repo, target_is_directory=True)
os.chdir(repo) # os.getcwd() is the physical spelling
agent = self._pin_agent()
with patch("agent.prompt_builder.load_soul_md", return_value=""), \
patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"):
with patch("agent.system_prompt.resolve_context_cwd", return_value=None):
p1 = build_system_prompt(agent)
self.assertIn("Status: clean", p1)
(repo / "untracked.txt").write_text("wip\n")
invalidate_system_prompt(agent)
with patch("agent.system_prompt.resolve_context_cwd", return_value=link):
self.assertEqual(build_system_prompt(agent), p1)
finally:
os.chdir(old_cwd)
shutil.rmtree(tmp, ignore_errors=True)

@pytest.mark.platforms("macos")
def test_case_alias_spelling_of_the_launch_dir_replays_the_pin(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This regression cannot execute on any CI lane.

The scenario is discriminating only where the filesystem is case-insensitive and case-preserving (macOS APFS: resolve() follows the symlink but keeps the caller's casing) — exactly where _same_live_dir's samefile identity matters. No runner reaches the test:

  • macOS lane (.github/workflows/tests-os.yml) imports only the files emitted by scripts/ci/list_os_marked_tests.py macos, then selects -m "platforms and not integration". This file contains no mark.platforms(...) call, so the selector never lists it — at the current head the list has 242 files and this one is not among them.
  • Linux lane (tests.yml, full suite): alias.samefile(repo) raises FileNotFoundError on a case-sensitive filesystem → caught → skipTest.
  • Windows: the guard two lines below skips, and the marked-files Windows lane would not import an unmarked file anyway.

Consequence: the regression added to guard the macOS finding stays green on every lane without ever running — if _same_live_dir regresses, CI keeps passing and macOS users silently get the mid-session prompt rewrite back. This is the exact footgun the repository's own guidance names: a bare host skip on an unmarked file runs on no host at all.

Evidence at this head (native Windows 11, Python 3.11.11): the focused file is 9 passed, 2 skipped; running this exact test body with only the host guard bypassed shows it executes and passes (NTFS is case-insensitive, so the alias resolves here) — the assertions are sound, the gating is the defect.

Repair: use the repository's OS-gating marker instead of a bare host skip — add import pytest and @pytest.mark.platforms("macos") to this test; the macOS lane then imports the file (verified: a copy with the marker injected is listed by the selector) and the conftest host gate skips it everywhere else. The win32 skip becomes redundant, and its reason string is imprecise — NTFS is case-insensitive; what this test needs is a case-insensitive and case-preserving filesystem. Keep the alias.samefile(repo) self-guard as the case-sensitive-volume fallback.

Regression check: python scripts/ci/list_os_marked_tests.py macos lists this file, and the macOS lane run shows this test executed, not skipped.

"""macOS default APFS is case-insensitive and case-preserving: resolve() follows the
symlink yet keeps the caller's casing, so a directory bound under a differently cased
spelling is the same workspace and the pin must hit. The platforms marker brings the
file into the macOS lane (an unmarked file runs on no OS lane at all); the samefile
self-guard below still skips on a case-sensitive volume, where the alias genuinely
is a second directory."""
import os, tempfile, shutil
from pathlib import Path
from agent.system_prompt import build_system_prompt, invalidate_system_prompt

tmp = Path(tempfile.mkdtemp(prefix="test-pinned-case-alias-"))
old_cwd = os.getcwd()
try:
repo = _init_repo(tmp / "Repo", "init commit")
alias = repo.with_name(repo.name.lower())
try:
alias_same = alias.samefile(repo)
except OSError:
alias_same = False # case-sensitive filesystem: the alias does not exist
if not alias_same:
self.skipTest("case-sensitive filesystem: the alias is a different directory")
os.chdir(repo) # os.getcwd() reports the on-disk casing
agent = self._pin_agent()
with patch("agent.prompt_builder.load_soul_md", return_value=""), \
patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"):
with patch("agent.system_prompt.resolve_context_cwd", return_value=None):
p1 = build_system_prompt(agent)
self.assertIn("Status: clean", p1)
(repo / "untracked.txt").write_text("wip\n")
invalidate_system_prompt(agent)
with patch("agent.system_prompt.resolve_context_cwd", return_value=alias):
self.assertEqual(build_system_prompt(agent), p1)
finally:
os.chdir(old_cwd)
shutil.rmtree(tmp, ignore_errors=True)

def test_agent_that_did_not_build_the_prompt_replays_the_persisted_snapshot(self):
"""Resume / gateway / TUI shape: a fresh agent rebuilds (compaction, a first /compress) after
the repo moved and replays the snapshot its session row already holds — unless that prompt
Expand Down Expand Up @@ -279,7 +350,7 @@ def env(cwd):
with patch("agent.prompt_builder.load_soul_md", return_value=""), env(other), \
patch("agent.system_prompt.resolve_context_cwd", return_value=other):
moved = self._pin_agent(_cached_system_prompt=None, _session_db=db)
self.assertIn(f"- Root: {other}", build_system_prompt(moved))
self.assertIn(f"- Root: {other.resolve()}", build_system_prompt(moved))
finally:
shutil.rmtree(tmp, ignore_errors=True)

Expand All @@ -300,7 +371,7 @@ def test_persisted_prompt_without_a_snapshot_does_not_pin_an_empty_one(self):
return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {repo}"), \
patch("agent.system_prompt.resolve_context_cwd", return_value=repo):
resumed = self._pin_agent(_cached_system_prompt=None, _session_db=db)
self.assertIn(f"- Root: {repo}", build_system_prompt(resumed))
self.assertIn(f"- Root: {repo.resolve()}", build_system_prompt(resumed))
finally:
shutil.rmtree(tmp, ignore_errors=True)

Expand Down