Agents now decide which repo to use based on issue context and check out repos on demand via `multica repo checkout <url>`. Workspace repos are cached locally as bare clones for fast worktree creation. Key changes: - Add repocache package for bare clone management (clone, fetch, worktree) - Add `multica repo checkout` CLI command that talks to local daemon - Add POST /repo/checkout endpoint on daemon health server - Pass workspace repos metadata through register + task claim responses - Remove pre-created worktrees from execenv (workdir starts empty) - Update CLAUDE.md template to instruct agents to use `multica repo checkout` - Pass MULTICA_DAEMON_PORT, WORKSPACE_ID, AGENT_NAME, TASK_ID env vars to agent
573 lines
16 KiB
Go
573 lines
16 KiB
Go
package execenv
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.Default()
|
|
}
|
|
|
|
func TestShortID(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "a1b2c3d4"},
|
|
{"abcdef12", "abcdef12"},
|
|
{"ab", "ab"},
|
|
{"a1b2c3d4e5f67890", "a1b2c3d4"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := shortID(tt.input); got != tt.want {
|
|
t.Errorf("shortID(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeName(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"Code Reviewer", "code-reviewer"},
|
|
{"my_agent!@#v2", "my-agent-v2"},
|
|
{" spaces ", "spaces"},
|
|
{"UPPERCASE", "uppercase"},
|
|
{"a-very-long-name-that-exceeds-thirty-characters-total", "a-very-long-name-that-exceeds"},
|
|
{"", "agent"},
|
|
{"---", "agent"},
|
|
{"日本語テスト", "agent"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := sanitizeName(tt.input); got != tt.want {
|
|
t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepoNameFromURL(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"https://github.com/org/my-repo.git", "my-repo"},
|
|
{"https://github.com/org/my-repo", "my-repo"},
|
|
{"git@github.com:org/my-repo.git", "my-repo"},
|
|
{"https://github.com/org/repo/", "repo"},
|
|
{"my-repo", "my-repo"},
|
|
{"", "repo"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := repoNameFromURL(tt.input); got != tt.want {
|
|
t.Errorf("repoNameFromURL(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareDirectoryMode(t *testing.T) {
|
|
t.Parallel()
|
|
workspacesRoot := t.TempDir()
|
|
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: workspacesRoot,
|
|
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
AgentName: "Test Agent",
|
|
Task: TaskContextForEnv{
|
|
IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Code Review", Content: "Be concise."},
|
|
},
|
|
},
|
|
}, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("Prepare failed: %v", err)
|
|
}
|
|
defer env.Cleanup(true)
|
|
|
|
// Verify directory structure.
|
|
for _, sub := range []string{"workdir", "output", "logs"} {
|
|
path := filepath.Join(env.RootDir, sub)
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
t.Fatalf("expected %s to exist", path)
|
|
}
|
|
}
|
|
|
|
// Verify context file contains issue ID and CLI hints.
|
|
content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read issue_context.md: %v", err)
|
|
}
|
|
for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} {
|
|
if !strings.Contains(string(content), want) {
|
|
t.Fatalf("issue_context.md missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Verify skill files.
|
|
skillContent, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "skills", "code-review", "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read SKILL.md: %v", err)
|
|
}
|
|
if !strings.Contains(string(skillContent), "Be concise.") {
|
|
t.Fatal("SKILL.md missing content")
|
|
}
|
|
}
|
|
|
|
func TestPrepareWithRepoContext(t *testing.T) {
|
|
t.Parallel()
|
|
workspacesRoot := t.TempDir()
|
|
|
|
taskCtx := TaskContextForEnv{
|
|
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
|
Repos: []RepoContextForEnv{
|
|
{URL: "https://github.com/org/backend", Description: "Go backend"},
|
|
{URL: "https://github.com/org/frontend", Description: "React frontend"},
|
|
},
|
|
}
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: workspacesRoot,
|
|
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
|
AgentName: "Code Reviewer",
|
|
Provider: "claude",
|
|
Task: taskCtx,
|
|
}, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("Prepare failed: %v", err)
|
|
}
|
|
defer env.Cleanup(true)
|
|
|
|
// Inject runtime config (done separately in daemon, replicate here).
|
|
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
|
|
// Workdir should be empty (no pre-created repo dirs).
|
|
entries, err := os.ReadDir(env.WorkDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to read workdir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if name != ".agent_context" && name != "CLAUDE.md" && name != ".claude" {
|
|
t.Errorf("unexpected entry in workdir: %s", name)
|
|
}
|
|
}
|
|
|
|
// CLAUDE.md should contain repo info.
|
|
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read CLAUDE.md: %v", err)
|
|
}
|
|
s := string(content)
|
|
for _, want := range []string{
|
|
"multica repo checkout",
|
|
"https://github.com/org/backend",
|
|
"Go backend",
|
|
"https://github.com/org/frontend",
|
|
"React frontend",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("CLAUDE.md missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteContextFiles(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "test-issue-id-1234",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{
|
|
Name: "Go Conventions",
|
|
Content: "Follow Go conventions.",
|
|
Files: []SkillFileContextForEnv{
|
|
{Path: "templates/example.go", Content: "package main"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := writeContextFiles(dir, "", ctx); err != nil {
|
|
t.Fatalf("writeContextFiles failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
for _, want := range []string{
|
|
"test-issue-id-1234",
|
|
"multica issue get",
|
|
"## Agent Skills",
|
|
"Go Conventions",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("content missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Issue details should NOT be in the context file (agent fetches via CLI).
|
|
for _, absent := range []string{"## Description", "## Workspace Context"} {
|
|
if strings.Contains(s, absent) {
|
|
t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent)
|
|
}
|
|
}
|
|
|
|
// Verify skill directory and files.
|
|
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read SKILL.md: %v", err)
|
|
}
|
|
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
|
t.Error("SKILL.md missing content")
|
|
}
|
|
|
|
supportFile, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "templates", "example.go"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read supporting file: %v", err)
|
|
}
|
|
if string(supportFile) != "package main" {
|
|
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
|
|
}
|
|
}
|
|
|
|
func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "minimal-issue-id",
|
|
}
|
|
|
|
if err := writeContextFiles(dir, "", ctx); err != nil {
|
|
t.Fatalf("writeContextFiles failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
if !strings.Contains(s, "minimal-issue-id") {
|
|
t.Error("expected issue ID to be present")
|
|
}
|
|
if strings.Contains(s, "## Agent Skills") {
|
|
t.Error("expected skills section to be omitted when no skills")
|
|
}
|
|
}
|
|
|
|
func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "claude-skill-test",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{
|
|
Name: "Go Conventions",
|
|
Content: "Follow Go conventions.",
|
|
Files: []SkillFileContextForEnv{
|
|
{Path: "templates/example.go", Content: "package main"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := writeContextFiles(dir, "claude", ctx); err != nil {
|
|
t.Fatalf("writeContextFiles failed: %v", err)
|
|
}
|
|
|
|
// Skills should be in .claude/skills/ (native discovery), NOT .agent_context/skills/.
|
|
skillMd, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read .claude/skills/go-conventions/SKILL.md: %v", err)
|
|
}
|
|
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
|
|
t.Error("SKILL.md missing content")
|
|
}
|
|
|
|
// Supporting files should also be under .claude/skills/.
|
|
supportFile, err := os.ReadFile(filepath.Join(dir, ".claude", "skills", "go-conventions", "templates", "example.go"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read supporting file: %v", err)
|
|
}
|
|
if string(supportFile) != "package main" {
|
|
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
|
|
}
|
|
|
|
// .agent_context/skills/ should NOT exist for Claude.
|
|
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
|
|
t.Error("expected .agent_context/skills/ to NOT exist for Claude provider")
|
|
}
|
|
|
|
// issue_context.md should still be in .agent_context/.
|
|
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
|
|
t.Error("expected .agent_context/issue_context.md to exist")
|
|
}
|
|
}
|
|
|
|
func TestCleanupPreservesLogs(t *testing.T) {
|
|
t.Parallel()
|
|
workspacesRoot := t.TempDir()
|
|
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: workspacesRoot,
|
|
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
|
|
AgentName: "Preserve Test",
|
|
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
|
|
}, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("Prepare failed: %v", err)
|
|
}
|
|
|
|
// Write something to logs/.
|
|
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
|
|
|
|
// Cleanup with removeAll=false.
|
|
if err := env.Cleanup(false); err != nil {
|
|
t.Fatalf("Cleanup failed: %v", err)
|
|
}
|
|
|
|
// workdir should be gone.
|
|
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
|
|
t.Fatal("expected workdir to be removed")
|
|
}
|
|
|
|
// logs should still exist.
|
|
logFile := filepath.Join(env.RootDir, "logs", "test.log")
|
|
if _, err := os.Stat(logFile); os.IsNotExist(err) {
|
|
t.Fatal("expected logs/test.log to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestInjectRuntimeConfigClaude(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "test-issue-id",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{
|
|
{Path: "example.go", Content: "package main"},
|
|
}},
|
|
{Name: "PR Review", Content: "Review PRs carefully."},
|
|
},
|
|
}
|
|
|
|
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read CLAUDE.md: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
for _, want := range []string{
|
|
"Multica Agent Runtime",
|
|
"multica issue get",
|
|
"multica issue comment list",
|
|
"Go Conventions",
|
|
"PR Review",
|
|
"discovered automatically",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("CLAUDE.md missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInjectRuntimeConfigCodex(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "test-issue-id",
|
|
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
|
}
|
|
|
|
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read AGENTS.md: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
if !strings.Contains(s, "Multica Agent Runtime") {
|
|
t.Error("AGENTS.md missing meta skill header")
|
|
}
|
|
if !strings.Contains(s, "Coding") {
|
|
t.Error("AGENTS.md missing skill name")
|
|
}
|
|
}
|
|
|
|
func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
|
|
|
|
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
if err != nil {
|
|
t.Fatalf("failed to read CLAUDE.md: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
if !strings.Contains(s, "multica issue get") {
|
|
t.Error("should reference multica CLI even without skills")
|
|
}
|
|
if strings.Contains(s, "## Skills") {
|
|
t.Error("should not have Skills section when there are no skills")
|
|
}
|
|
}
|
|
|
|
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
// Unknown provider should be a no-op.
|
|
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
|
t.Fatalf("expected no error for unknown provider, got: %v", err)
|
|
}
|
|
|
|
// No files should be created.
|
|
entries, _ := os.ReadDir(dir)
|
|
if len(entries) != 0 {
|
|
t.Fatalf("expected empty dir for unknown provider, got %d entries", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
|
|
// Cannot use t.Parallel() with t.Setenv.
|
|
|
|
// Create a fake shared codex home.
|
|
sharedHome := t.TempDir()
|
|
os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"token":"secret"}`), 0o644)
|
|
os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"o3"}`), 0o644)
|
|
os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(`model = "o3"`), 0o644)
|
|
os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("Be helpful."), 0o644)
|
|
|
|
// Point CODEX_HOME to our fake shared home.
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
|
|
codexHome := filepath.Join(t.TempDir(), "codex-home")
|
|
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
|
|
t.Fatalf("prepareCodexHome failed: %v", err)
|
|
}
|
|
|
|
// auth.json should be a symlink.
|
|
authPath := filepath.Join(codexHome, "auth.json")
|
|
fi, err := os.Lstat(authPath)
|
|
if err != nil {
|
|
t.Fatalf("auth.json not found: %v", err)
|
|
}
|
|
if fi.Mode()&os.ModeSymlink == 0 {
|
|
t.Error("auth.json should be a symlink")
|
|
}
|
|
target, _ := os.Readlink(authPath)
|
|
if target != filepath.Join(sharedHome, "auth.json") {
|
|
t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json"))
|
|
}
|
|
// Verify content is accessible through symlink.
|
|
data, _ := os.ReadFile(authPath)
|
|
if string(data) != `{"token":"secret"}` {
|
|
t.Errorf("auth.json content = %q", data)
|
|
}
|
|
|
|
// config.json should be a copy (not symlink).
|
|
configPath := filepath.Join(codexHome, "config.json")
|
|
fi, err = os.Lstat(configPath)
|
|
if err != nil {
|
|
t.Fatalf("config.json not found: %v", err)
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
t.Error("config.json should be a copy, not a symlink")
|
|
}
|
|
data, _ = os.ReadFile(configPath)
|
|
if string(data) != `{"model":"o3"}` {
|
|
t.Errorf("config.json content = %q", data)
|
|
}
|
|
|
|
// config.toml should be copied.
|
|
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
|
|
if string(data) != `model = "o3"` {
|
|
t.Errorf("config.toml content = %q", data)
|
|
}
|
|
|
|
// instructions.md should be copied.
|
|
data, _ = os.ReadFile(filepath.Join(codexHome, "instructions.md"))
|
|
if string(data) != "Be helpful." {
|
|
t.Errorf("instructions.md content = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
|
|
// Cannot use t.Parallel() with t.Setenv.
|
|
|
|
// Empty shared home — no files to seed.
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
|
|
codexHome := filepath.Join(t.TempDir(), "codex-home")
|
|
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
|
|
t.Fatalf("prepareCodexHome failed: %v", err)
|
|
}
|
|
|
|
// Directory should exist but be empty (no auth.json, no config.json, etc.).
|
|
entries, err := os.ReadDir(codexHome)
|
|
if err != nil {
|
|
t.Fatalf("failed to read codex-home: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
names := make([]string, len(entries))
|
|
for i, e := range entries {
|
|
names[i] = e.Name()
|
|
}
|
|
t.Errorf("expected empty codex-home, got: %v", names)
|
|
}
|
|
}
|
|
|
|
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
src := filepath.Join(dir, "source.json")
|
|
dst := filepath.Join(dir, "link.json")
|
|
|
|
os.WriteFile(src, []byte("real"), 0o644)
|
|
|
|
// Create a broken symlink pointing to a non-existent file.
|
|
os.Symlink(filepath.Join(dir, "old-source.json"), dst)
|
|
|
|
if err := ensureSymlink(src, dst); err != nil {
|
|
t.Fatalf("ensureSymlink failed: %v", err)
|
|
}
|
|
|
|
// Should now point to src.
|
|
target, _ := os.Readlink(dst)
|
|
if target != src {
|
|
t.Errorf("symlink target = %q, want %q", target, src)
|
|
}
|
|
data, _ := os.ReadFile(dst)
|
|
if string(data) != "real" {
|
|
t.Errorf("content = %q, want %q", data, "real")
|
|
}
|
|
}
|