/api/session/new treated an absent 'worktree' key as false, so the agent's config-level 'worktree: true' default was never consulted for WebUI sessions even though the WebUI reuses the agent's own worktree helper. Both callers should agree on same-agent/same-repo isolation. Three-value model: - body 'worktree' absent -> agent config default applies (profile-aware via get_config_for_profile_home, so a user with worktree:true in one profile but not another gets per-profile behavior) - explicit true/false -> honored verbatim; explicit always wins Non-git workspaces: a config-default request degrades to a plain session and surfaces 'worktree_skipped' in the payload; an explicit worktree:true keeps the hard 400. Frontend (same change, load-bearing): newSession() forwards explicit true/false and omits the key otherwise; the boot-time auto-bind (_maybeBindFreshDefaultWorkspaceSession) and the onboarding session send worktree:false explicitly so a config default can never mint a worktree + branch from merely opening the UI. Deliberate 'New Chat' sends nothing and inherits the default. Tests: route matrix (config on/off x body absent/true/false), non-git fallback vs explicit 400, explicit-false-beats-config-true, config helper (ambient/named-profile/fail-soft), static UI assertions that auto-bind and onboarding opt out and New Chat paths don't pin a value. Fixes #6022
233 lines
8.1 KiB
Python
233 lines
8.1 KiB
Python
"""Tests for the config-level worktree default in /api/session/new (Issue #6022).
|
|
|
|
Three-value contract:
|
|
- body ``worktree`` ABSENT -> agent config ``worktree:`` default applies
|
|
- body ``worktree`` explicit -> honored verbatim (explicit always wins)
|
|
|
|
Config-default requests degrade to a plain session (+ ``worktree_skipped`` in
|
|
the payload) on non-git workspaces; explicit requests keep the hard 400.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import api.models as models
|
|
import api.routes as routes
|
|
import api.worktrees as worktrees
|
|
from api.models import SESSIONS
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_sessions(tmp_path, monkeypatch):
|
|
session_dir = tmp_path / "sessions"
|
|
session_dir.mkdir()
|
|
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
|
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
|
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent.resolve()))
|
|
SESSIONS.clear()
|
|
yield session_dir
|
|
SESSIONS.clear()
|
|
|
|
|
|
def _post_session_new(tmp_path, monkeypatch, body, *, config_default, workspace_dir=None, fake_worktree=None):
|
|
"""Drive POST /api/session/new with a stubbed transport and worktree factory."""
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda handler: body)
|
|
monkeypatch.setattr(
|
|
routes, "_worktree_default_from_config", lambda profile: config_default
|
|
)
|
|
if workspace_dir is not None:
|
|
monkeypatch.setattr(
|
|
routes, "resolve_trusted_workspace", lambda raw: workspace_dir
|
|
)
|
|
if fake_worktree is not None:
|
|
monkeypatch.setattr(
|
|
worktrees, "create_worktree_for_workspace", lambda workspace: fake_worktree
|
|
)
|
|
captured = {}
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"j",
|
|
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
|
payload=payload, status=status
|
|
)
|
|
or True,
|
|
)
|
|
import api.helpers as helpers
|
|
|
|
monkeypatch.setattr(
|
|
helpers,
|
|
"j",
|
|
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
|
payload=payload, status=status
|
|
)
|
|
or True,
|
|
)
|
|
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/new")) is True
|
|
return captured
|
|
|
|
|
|
def _fake_worktree_info(repo, worktree):
|
|
return {
|
|
"path": str(worktree),
|
|
"branch": "hermes/hermes-6022",
|
|
"repo_root": str(repo),
|
|
"created_at": 321.0,
|
|
}
|
|
|
|
|
|
def _mk_repo_dirs(tmp_path):
|
|
repo = tmp_path / "repo"
|
|
worktree = repo / ".worktrees" / "hermes-6022"
|
|
repo.mkdir()
|
|
worktree.mkdir(parents=True)
|
|
return repo, worktree
|
|
|
|
|
|
# ── Route matrix: absent key ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_absent_key_with_config_default_off_creates_plain_session(tmp_path, monkeypatch):
|
|
repo, _ = _mk_repo_dirs(tmp_path)
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(repo), "profile": "default"},
|
|
config_default=False,
|
|
workspace_dir=repo,
|
|
)
|
|
assert captured["status"] == 200
|
|
session = captured["payload"]["session"]
|
|
assert session.get("worktree_path") in (None, "")
|
|
assert "worktree_skipped" not in captured["payload"]
|
|
|
|
|
|
def test_absent_key_with_config_default_on_creates_worktree_session(tmp_path, monkeypatch):
|
|
repo, worktree = _mk_repo_dirs(tmp_path)
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(repo), "profile": "default"},
|
|
config_default=True,
|
|
workspace_dir=repo,
|
|
fake_worktree=_fake_worktree_info(repo, worktree),
|
|
)
|
|
assert captured["status"] == 200
|
|
session = captured["payload"]["session"]
|
|
assert session["workspace"] == str(worktree.resolve())
|
|
assert session["worktree_path"] == str(worktree.resolve())
|
|
assert session["worktree_branch"] == "hermes/hermes-6022"
|
|
|
|
|
|
# ── Route matrix: explicit wins ──────────────────────────────────────────────
|
|
|
|
|
|
def test_explicit_false_beats_config_default_true(tmp_path, monkeypatch):
|
|
repo, worktree = _mk_repo_dirs(tmp_path)
|
|
|
|
def _fail(workspace):
|
|
pytest.fail("worktree must not be created when body says worktree=false")
|
|
|
|
monkeypatch.setattr(worktrees, "create_worktree_for_workspace", _fail)
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(repo), "profile": "default", "worktree": False},
|
|
config_default=True,
|
|
workspace_dir=repo,
|
|
)
|
|
assert captured["status"] == 200
|
|
session = captured["payload"]["session"]
|
|
assert session.get("worktree_path") in (None, "")
|
|
|
|
|
|
def test_explicit_true_with_config_default_off_creates_worktree(tmp_path, monkeypatch):
|
|
repo, worktree = _mk_repo_dirs(tmp_path)
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(repo), "profile": "default", "worktree": True},
|
|
config_default=False,
|
|
workspace_dir=repo,
|
|
fake_worktree=_fake_worktree_info(repo, worktree),
|
|
)
|
|
assert captured["status"] == 200
|
|
session = captured["payload"]["session"]
|
|
assert session["worktree_path"] == str(worktree.resolve())
|
|
|
|
|
|
# ── Route matrix: non-git workspace ──────────────────────────────────────────
|
|
|
|
|
|
def test_config_default_on_non_git_workspace_falls_back_to_plain_session(tmp_path, monkeypatch):
|
|
non_git = tmp_path / "plain"
|
|
non_git.mkdir()
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(non_git), "profile": "default"},
|
|
config_default=True,
|
|
workspace_dir=non_git,
|
|
# real create_worktree_for_workspace -> find_git_repo_root raises ValueError
|
|
)
|
|
assert captured["status"] == 200
|
|
session = captured["payload"]["session"]
|
|
assert session.get("worktree_path") in (None, "")
|
|
assert "not inside a git repository" in captured["payload"]["worktree_skipped"]
|
|
|
|
|
|
def test_explicit_true_on_non_git_workspace_still_hard_400(tmp_path, monkeypatch):
|
|
non_git = tmp_path / "plain"
|
|
non_git.mkdir()
|
|
captured = _post_session_new(
|
|
tmp_path,
|
|
monkeypatch,
|
|
{"workspace": str(non_git), "profile": "default", "worktree": True},
|
|
config_default=False,
|
|
workspace_dir=non_git,
|
|
)
|
|
assert captured["status"] == 400
|
|
assert "not inside a git repository" in captured["payload"].get("error", "")
|
|
|
|
|
|
# ── Config helper ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_worktree_default_reads_ambient_config_when_no_profile(monkeypatch):
|
|
monkeypatch.setattr(
|
|
routes, "get_config_for_profile_home", lambda home: {"worktree": True}
|
|
)
|
|
assert routes._worktree_default_from_config(None) is True
|
|
monkeypatch.setattr(
|
|
routes, "get_config_for_profile_home", lambda home: {"worktree": False}
|
|
)
|
|
assert routes._worktree_default_from_config(None) is False
|
|
monkeypatch.setattr(routes, "get_config_for_profile_home", lambda home: {})
|
|
assert routes._worktree_default_from_config(None) is False
|
|
|
|
|
|
def test_worktree_default_resolves_named_profile_home(monkeypatch, tmp_path):
|
|
import api.profiles as profiles
|
|
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
profiles, "get_hermes_home_for_profile", lambda name: tmp_path / name
|
|
)
|
|
|
|
def fake_cfg(home):
|
|
seen["home"] = home
|
|
return {"worktree": True}
|
|
|
|
monkeypatch.setattr(routes, "get_config_for_profile_home", fake_cfg)
|
|
assert routes._worktree_default_from_config("work") is True
|
|
assert seen["home"] == tmp_path / "work"
|
|
|
|
|
|
def test_worktree_default_is_fail_soft_on_config_errors(monkeypatch):
|
|
def boom(home):
|
|
raise RuntimeError("config unavailable")
|
|
|
|
monkeypatch.setattr(routes, "get_config_for_profile_home", boom)
|
|
assert routes._worktree_default_from_config(None) is False
|