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
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue