Add project-aware chat startup defaults
Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks. Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls. Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
This commit is contained in:
parent
9b9b83ebeb
commit
535efc6919
28 changed files with 968 additions and 67 deletions
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
|
@ -15,6 +16,28 @@ from pyro_mcp.vm_manager import VmManager
|
|||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||
_git(root, "add", "note.txt")
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
|
|
@ -134,6 +157,58 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
|
|||
assert "workspace_disk_export" not in tool_map
|
||||
|
||||
|
||||
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
server = pyro.create_server(project_path=repo)
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return tool_map["workspace_create"], created, executed
|
||||
|
||||
workspace_create_tool, created, executed = asyncio.run(_run())
|
||||
assert "If `seed_path` is omitted" in str(workspace_create_tool["description"])
|
||||
assert str(repo.resolve()) in str(workspace_create_tool["description"])
|
||||
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "project-aware\n"
|
||||
|
||||
|
||||
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "recommended first profile for most chat hosts" in mcp_help
|
||||
assert "workspace-core: default for normal persistent chat editing" in mcp_help
|
||||
assert "workspace-full: larger opt-in surface" in mcp_help
|
||||
assert "--project-path" in mcp_help
|
||||
assert "--repo-url" in mcp_help
|
||||
assert "--repo-ref" in mcp_help
|
||||
assert "--no-project-source" in mcp_help
|
||||
assert "pyro mcp serve --project-path ." in mcp_help
|
||||
assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help
|
||||
|
||||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
|
||||
|
|
@ -2825,25 +2831,30 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
assert claude_cmd in readme
|
||||
assert codex_cmd in readme
|
||||
assert "examples/opencode_mcp_config.json" in readme
|
||||
assert "Bare `pyro mcp serve` starts `workspace-core`" in readme
|
||||
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
|
||||
assert "auto-detects\nthe current Git checkout" in readme
|
||||
assert "--project-path /abs/path/to/repo" in readme
|
||||
assert "--repo-url https://github.com/example/project.git" in readme
|
||||
|
||||
assert "## 5. Connect a chat host" in install
|
||||
assert "uvx --from pyro-mcp pyro mcp serve" in install
|
||||
assert claude_cmd in install
|
||||
assert codex_cmd in install
|
||||
assert "workspace-full" in install
|
||||
assert "--project-path /abs/path/to/repo" in install
|
||||
|
||||
assert claude_cmd in first_run
|
||||
assert codex_cmd in first_run
|
||||
assert "--project-path /abs/path/to/repo" in first_run
|
||||
|
||||
assert (
|
||||
"Bare `pyro mcp serve` starts `workspace-core`. That is the product path."
|
||||
in integrations
|
||||
)
|
||||
assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations
|
||||
assert "auto-detects the current Git checkout" in integrations
|
||||
assert "examples/claude_code_mcp.md" in integrations
|
||||
assert "examples/codex_mcp.md" in integrations
|
||||
assert "examples/opencode_mcp_config.json" in integrations
|
||||
assert "That is the product path." in integrations
|
||||
assert "--project-path /abs/path/to/repo" in integrations
|
||||
assert "--repo-url https://github.com/example/project.git" in integrations
|
||||
|
||||
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
||||
assert "Use the host-specific examples first when they apply:" in mcp_config
|
||||
|
|
@ -2854,10 +2865,12 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
assert claude_cmd in claude_code
|
||||
assert "claude mcp list" in claude_code
|
||||
assert "workspace-full" in claude_code
|
||||
assert "--project-path /abs/path/to/repo" in claude_code
|
||||
|
||||
assert codex_cmd in codex
|
||||
assert "codex mcp list" in codex
|
||||
assert "workspace-full" in codex
|
||||
assert "--project-path /abs/path/to/repo" in codex
|
||||
|
||||
assert opencode == {
|
||||
"mcp": {
|
||||
|
|
@ -4020,11 +4033,23 @@ def test_cli_run_json_error_exits_nonzero(
|
|||
|
||||
|
||||
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
observed: dict[str, str] = {}
|
||||
observed: dict[str, Any] = {}
|
||||
|
||||
class StubPyro:
|
||||
def create_server(self, *, profile: str) -> Any:
|
||||
def create_server(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
project_path: str | None,
|
||||
repo_url: str | None,
|
||||
repo_ref: str | None,
|
||||
no_project_source: bool,
|
||||
) -> Any:
|
||||
observed["profile"] = profile
|
||||
observed["project_path"] = project_path
|
||||
observed["repo_url"] = repo_url
|
||||
observed["repo_ref"] = repo_ref
|
||||
observed["no_project_source"] = no_project_source
|
||||
return type(
|
||||
"StubServer",
|
||||
(),
|
||||
|
|
@ -4033,12 +4058,27 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
|
||||
return argparse.Namespace(
|
||||
command="mcp",
|
||||
mcp_command="serve",
|
||||
profile="workspace-core",
|
||||
project_path="/repo",
|
||||
repo_url=None,
|
||||
repo_ref=None,
|
||||
no_project_source=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
assert observed == {"profile": "workspace-core", "transport": "stdio"}
|
||||
assert observed == {
|
||||
"profile": "workspace-core",
|
||||
"project_path": "/repo",
|
||||
"repo_url": None,
|
||||
"repo_ref": None,
|
||||
"no_project_source": False,
|
||||
"transport": "stdio",
|
||||
}
|
||||
|
||||
|
||||
def test_cli_demo_default_prints_json(
|
||||
|
|
|
|||
115
tests/test_project_startup.py
Normal file
115
tests/test_project_startup.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.project_startup import (
|
||||
ProjectStartupSource,
|
||||
describe_project_startup_source,
|
||||
materialize_project_startup_source,
|
||||
resolve_project_startup_source,
|
||||
)
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / filename).write_text(content, encoding="utf-8")
|
||||
_git(root, "add", filename)
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
nested = repo / "src" / "pkg"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
resolved = resolve_project_startup_source(cwd=nested)
|
||||
|
||||
assert resolved == ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
nested = repo / "nested"
|
||||
nested.mkdir()
|
||||
|
||||
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
|
||||
|
||||
assert resolved == ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
|
||||
|
||||
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||
resolve_project_startup_source(repo_ref="main")
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
resolve_project_startup_source(project_path=repo, no_project_source=True)
|
||||
|
||||
|
||||
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo", content="one\n")
|
||||
first_commit = _git(repo, "rev-parse", "HEAD")
|
||||
(repo / "note.txt").write_text("two\n", encoding="utf-8")
|
||||
_git(repo, "add", "note.txt")
|
||||
_git(repo, "commit", "-m", "update")
|
||||
|
||||
source = ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref=str(repo.resolve()),
|
||||
repo_ref=first_commit,
|
||||
)
|
||||
|
||||
with materialize_project_startup_source(source) as clone_dir:
|
||||
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
|
||||
|
||||
|
||||
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
project_description = describe_project_startup_source(
|
||||
ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
)
|
||||
repo_description = describe_project_startup_source(
|
||||
ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref="https://example.com/repo.git",
|
||||
repo_ref="main",
|
||||
)
|
||||
)
|
||||
|
||||
assert project_description == f"the current project at {repo.resolve()}"
|
||||
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -16,6 +17,28 @@ from pyro_mcp.vm_manager import VmManager
|
|||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||
_git(root, "add", "note.txt")
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
@ -62,6 +85,121 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
|
|||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
server = create_server(manager=manager, project_path=repo)
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return tool_map["workspace_create"]
|
||||
|
||||
workspace_create = asyncio.run(_run())
|
||||
description = cast(str, workspace_create["description"])
|
||||
assert "If `seed_path` is omitted" in description
|
||||
assert str(repo.resolve()) in description
|
||||
|
||||
|
||||
def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
server = create_server(manager=manager, project_path=repo)
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return created, executed
|
||||
|
||||
created, executed = asyncio.run(_run())
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["workspace_seed"]["seed_path"] == str(repo.resolve())
|
||||
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "project-aware\n"
|
||||
|
||||
|
||||
def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo", content="committed\n")
|
||||
(repo / "note.txt").write_text("dirty\n", encoding="utf-8")
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
server = create_server(manager=manager, repo_url=str(repo.resolve()))
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return created, executed
|
||||
|
||||
created, executed = asyncio.run(_run())
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["workspace_seed"]["seed_path"] is None
|
||||
assert created["workspace_seed"]["origin_kind"] == "repo_url"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "committed\n"
|
||||
|
||||
|
||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
|
|||
|
|
@ -436,6 +436,36 @@ class _FakePyro:
|
|||
workspace.shells.pop(shell_id, None)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
||||
|
||||
def create_server(self, *, profile: str, project_path: Path) -> Any:
|
||||
assert profile == "workspace-core"
|
||||
seed_path = Path(project_path)
|
||||
outer = self
|
||||
|
||||
class _FakeServer:
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> tuple[None, dict[str, Any]]:
|
||||
if tool_name != "workspace_create":
|
||||
raise AssertionError(f"unexpected tool call: {tool_name}")
|
||||
result = outer.create_workspace(
|
||||
environment=cast(str, arguments["environment"]),
|
||||
seed_path=seed_path,
|
||||
name=cast(str | None, arguments.get("name")),
|
||||
labels=cast(dict[str, str] | None, arguments.get("labels")),
|
||||
)
|
||||
created = outer.status_workspace(cast(str, result["workspace_id"]))
|
||||
created["workspace_seed"] = {
|
||||
"mode": "directory",
|
||||
"seed_path": str(seed_path.resolve()),
|
||||
"origin_kind": "project_path",
|
||||
"origin_ref": str(seed_path.resolve()),
|
||||
}
|
||||
return None, created
|
||||
|
||||
return _FakeServer()
|
||||
|
||||
|
||||
def test_use_case_registry_has_expected_scenarios() -> None:
|
||||
expected = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue