diff --git a/coding-agent/src/coding_agent/ui/session_manager.py b/coding-agent/src/coding_agent/ui/session_manager.py index 2ac09107f..165fa540a 100644 --- a/coding-agent/src/coding_agent/ui/session_manager.py +++ b/coding-agent/src/coding_agent/ui/session_manager.py @@ -52,6 +52,7 @@ logger = logging.getLogger(__name__) _CHECKPOINT_SESSION_CONFIG_KEY = "session_restart_config" +_DEFAULT_EXECUTION_BINDING = object() T = TypeVar("T") @@ -97,8 +98,9 @@ async def complete(self, messages: list[dict[str, Any]]) -> str: class Session: """A managed agent session. - Note: ``repo_path`` is legacy/backward-compat metadata only; - ``execution_binding`` is the authoritative workspace contract. + Note: ``execution_binding`` is the authoritative workspace contract. + ``repo_path`` remains backward-compatible metadata and supplies the + default local workspace root when no explicit execution binding is provided. """ id: str @@ -106,11 +108,9 @@ class Session: last_activity: datetime wire: LocalWire = field(init=False) approval_store: ApprovalStore = field(default_factory=ApprovalStore) - repo_path: Path | None = None # legacy/backward-compat metadata only - execution_binding: ExecutionBinding = field( - default_factory=lambda: LocalExecutionBinding( - workspace_root=str(Path.cwd().resolve()) - ) + repo_path: Path | None = None # legacy metadata; seeds default local binding + execution_binding: ExecutionBinding = field( # type: ignore[assignment] + default=cast(ExecutionBinding, _DEFAULT_EXECUTION_BINDING), ) approval_policy: ApprovalPolicy = ApprovalPolicy.AUTO provider: Any | None = None @@ -131,6 +131,15 @@ class Session: approval_coordinator: ApprovalCoordinator = field(init=False) def __post_init__(self) -> None: + if self.execution_binding is cast(ExecutionBinding, _DEFAULT_EXECUTION_BINDING): + workspace_root = ( + str(self.repo_path.resolve()) + if self.repo_path is not None + else str(Path.cwd().resolve()) + ) + self.execution_binding = LocalExecutionBinding( + workspace_root=workspace_root + ) self.wire = LocalWire(self.id) self.approval_coordinator = ApprovalCoordinator(self.approval_store) @@ -148,8 +157,8 @@ def to_store_data(self) -> dict[str, Any]: "id": self.id, "created_at": self.created_at.isoformat(), "last_activity": self.last_activity.isoformat(), - # repo_path is legacy/backward-compat metadata; execution_binding is - # the authoritative workspace contract. + # repo_path remains backward-compatible metadata and seeds the + # default local binding when execution_binding is omitted. "repo_path": None if self.repo_path is None else str(self.repo_path), "execution_binding": self.execution_binding.to_dict(), "approval_policy": self.approval_policy.value, @@ -187,7 +196,9 @@ def from_store_data(cls, data: dict[str, Any]) -> Session: execution_binding = ExecutionBinding.from_dict(binding_raw) else: workspace_root = ( - repo_path_raw if repo_path_raw is not None else str(Path.cwd()) + str(Path(repo_path_raw).resolve()) + if repo_path_raw is not None + else str(Path.cwd().resolve()) ) execution_binding = LocalExecutionBinding(workspace_root=workspace_root) session = cls( diff --git a/coding-agent/tests/ui/test_session_manager_public_api.py b/coding-agent/tests/ui/test_session_manager_public_api.py index 4dc90c1ba..44be6937a 100644 --- a/coding-agent/tests/ui/test_session_manager_public_api.py +++ b/coding-agent/tests/ui/test_session_manager_public_api.py @@ -178,6 +178,40 @@ def test_session_metadata_round_trips_local_execution_binding(tmp_path: Path) -> assert reloaded.execution_binding.workspace_root == str(bound_repo) +def test_session_defaults_local_execution_binding_from_repo_path( + tmp_path: Path, +) -> None: + bound_repo = tmp_path / "bound-repo" + session = Session( + id="binding-from-repo-path", + created_at=datetime.now(), + last_activity=datetime.now(), + approval_store=ApprovalStore(), + repo_path=bound_repo, + ) + + assert isinstance(session.execution_binding, LocalExecutionBinding) + assert session.execution_binding.workspace_root == str(bound_repo.resolve()) + + +def test_session_preserves_explicit_execution_binding_with_repo_path( + tmp_path: Path, +) -> None: + bound_repo = tmp_path / "bound-repo" + explicit_root = tmp_path / "explicit-root" + session = Session( + id="explicit-binding-wins", + created_at=datetime.now(), + last_activity=datetime.now(), + approval_store=ApprovalStore(), + repo_path=bound_repo, + execution_binding=LocalExecutionBinding(workspace_root=str(explicit_root)), + ) + + assert isinstance(session.execution_binding, LocalExecutionBinding) + assert session.execution_binding.workspace_root == str(explicit_root) + + def test_session_metadata_defaults_missing_binding_to_local_from_repo_path( tmp_path: Path, ) -> None: @@ -207,6 +241,37 @@ def test_session_metadata_defaults_missing_binding_to_local_from_repo_path( assert reloaded.execution_binding.workspace_root == str(legacy_repo) +def test_session_metadata_defaults_missing_binding_resolves_relative_repo_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store = InMemorySessionStore() + created_at = datetime.now() + last_activity = datetime.now() + legacy_repo = tmp_path / "legacy-repo" + legacy_repo.mkdir() + monkeypatch.chdir(tmp_path) + store.save( + "legacy-relative-session", + { + "id": "legacy-relative-session", + "created_at": created_at.isoformat(), + "last_activity": last_activity.isoformat(), + "repo_path": "legacy-repo", + "approval_policy": "auto", + "provider_name": None, + "model_name": None, + "base_url": None, + "max_steps": 30, + "tape_id": None, + }, + ) + + reloaded = SessionManager(store=store).get_session("legacy-relative-session") + + assert isinstance(reloaded.execution_binding, LocalExecutionBinding) + assert reloaded.execution_binding.workspace_root == str(legacy_repo.resolve()) + + def test_event_queue_mutations_do_not_persist_sync_runtime_only_state() -> None: manager = SessionManager(store=InMemorySessionStore()) session = Session(