Files
multica/server/internal/daemon/execenv/execenv_test.go
2026-05-18 12:12:35 +08:00

2761 lines
89 KiB
Go

package execenv
import (
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.Default()
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
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 TestPredictRootDir(t *testing.T) {
t.Parallel()
got := PredictRootDir("/root", "ws-uuid", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
want := filepath.Join("/root", "ws-uuid", "a1b2c3d4")
if got != want {
t.Errorf("PredictRootDir = %q, want %q", got, want)
}
if got := PredictRootDir("", "ws", "task"); got != "" {
t.Errorf("expected empty when workspaces root missing, got %q", got)
}
if got := PredictRootDir("/r", "", "task"); got != "" {
t.Errorf("expected empty when workspace ID missing, got %q", got)
}
if got := PredictRootDir("/r", "ws", ""); got != "" {
t.Errorf("expected empty when task ID missing, got %q", got)
}
}
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,
WorkspaceID: "ws-test-001",
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", "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 TestPrepareWithProjectResources(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
taskCtx := TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
ProjectID: "22222222-3333-4444-5555-666666666666",
ProjectTitle: "Agent UX 2026",
ProjectResources: []ProjectResourceForEnv{
{
ID: "33333333-4444-5555-6666-777777777777",
ResourceType: "github_repo",
ResourceRef: json.RawMessage(`{"url":"https://github.com/multica-ai/multica","default_branch_hint":"main"}`),
},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-pr",
TaskID: "11111111-2222-3333-4444-555555555555",
AgentName: "Test Agent",
Provider: "claude",
Task: taskCtx,
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// resources.json should exist and decode back to what we wrote.
resourcesPath := filepath.Join(env.WorkDir, ".multica", "project", "resources.json")
raw, err := os.ReadFile(resourcesPath)
if err != nil {
t.Fatalf("failed to read resources.json: %v", err)
}
var got struct {
ProjectID string `json:"project_id"`
ProjectTitle string `json:"project_title"`
Resources []struct {
ID string `json:"id"`
ResourceType string `json:"resource_type"`
ResourceRef json.RawMessage `json:"resource_ref"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("resources.json unmarshal: %v\n%s", err, string(raw))
}
if got.ProjectID != taskCtx.ProjectID {
t.Errorf("resources.json project_id = %q, want %q", got.ProjectID, taskCtx.ProjectID)
}
if got.ProjectTitle != taskCtx.ProjectTitle {
t.Errorf("resources.json project_title = %q, want %q", got.ProjectTitle, taskCtx.ProjectTitle)
}
if len(got.Resources) != 1 || got.Resources[0].ResourceType != "github_repo" {
t.Fatalf("resources.json resources mismatch: %+v", got.Resources)
}
// CLAUDE.md should mention the project context block.
if _, err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"## Project Context",
"Agent UX 2026",
"GitHub repo",
"https://github.com/multica-ai/multica",
"default branch: `main`",
".multica/project/resources.json",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
// When the issue's project has its own github_repo resources, those should be
// the only repos rendered in the meta-skill — workspace-level repos must not
// leak into the agent prompt to avoid confusing it about which repo to use.
//
// The handler-side override is exercised in handler tests; this test confirms
// the rendering side: given a TaskContextForEnv where Repos was already
// narrowed by the server to project repos only, the meta skill renders just
// those.
func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
ProjectID: "22222222-3333-4444-5555-666666666666",
ProjectTitle: "Project A",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/project-repo"},
},
ProjectResources: []ProjectResourceForEnv{
{
ID: "33333333-4444-5555-6666-777777777777",
ResourceType: "github_repo",
ResourceRef: []byte(`{"url":"https://github.com/org/project-repo"}`),
},
},
}
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "https://github.com/org/project-repo") {
t.Errorf("CLAUDE.md missing project repo URL")
}
if strings.Contains(s, "https://github.com/org/workspace-repo") {
t.Errorf("CLAUDE.md should not contain workspace repo when project has its own")
}
}
func TestWriteProjectResourcesSkippedWhenNone(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := writeProjectResources(dir, TaskContextForEnv{}); err != nil {
t.Fatalf("writeProjectResources: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".multica", "project", "resources.json")); !os.IsNotExist(err) {
t.Errorf("expected no resources.json to be written when project context is empty")
}
}
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"},
{URL: "https://github.com/org/frontend"},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-002",
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",
"https://github.com/org/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",
"## 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 TestWriteContextFilesAutopilotRunOnly(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
AutopilotRunID: "run-1",
AutopilotID: "autopilot-1",
AutopilotTitle: "Daily dependency check",
AutopilotDescription: "Check dependencies and report outdated packages.",
AutopilotSource: "manual",
}
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{
"# Autopilot Run",
"run-1",
"autopilot-1",
"Check dependencies and report outdated packages.",
"multica autopilot get autopilot-1 --output json",
"no assigned issue",
} {
if !strings.Contains(s, want) {
t.Errorf("autopilot context missing %q\n---\n%s", want, s)
}
}
if strings.Contains(s, "Run `multica issue get") {
t.Errorf("autopilot context should not contain issue get workflow\n---\n%s", s)
}
}
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,
WorkspaceID: "ws-test-003",
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)
}
}
}
// Regression test for #2347: the runtime config injected into agent harnesses
// must advertise both autopilot execution modes on create AND update, so an
// agent acting as a CLI user is not confined to create_issue.
func TestInjectRuntimeConfigAutopilotAdvertisesBothModes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); 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 autopilot create --title \"...\" --agent <name> --mode create_issue|run_only",
"multica autopilot update <id>",
"[--mode create_issue|run_only]",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
func TestInjectRuntimeConfigGemini(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
}
if _, err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "GEMINI.md"))
if err != nil {
t.Fatalf("failed to read GEMINI.md: %v", err)
}
s := string(content)
for _, want := range []string{
"Multica Agent Runtime",
"multica issue get",
"Writing",
} {
if !strings.Contains(s, want) {
t.Errorf("GEMINI.md missing %q", want)
}
}
// Should not write CLAUDE.md or AGENTS.md for gemini provider.
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create CLAUDE.md")
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create AGENTS.md")
}
}
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 TestWriteContextFilesCopilotNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "copilot-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "copilot", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// Copilot CLI natively discovers project-level skills from .github/skills/.
skillMd, err := os.ReadFile(filepath.Join(dir, ".github", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .github/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 .github/skills/.
supportFile, err := os.ReadFile(filepath.Join(dir, ".github", "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 Copilot.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error("expected .agent_context/skills/ to NOT exist for Copilot 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 TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "opencode-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// Skills should be in .opencode/skills/ (native discovery).
skillMd, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .opencode/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 .opencode/skills/.
supportFile, err := os.ReadFile(filepath.Join(dir, ".opencode", "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 OpenCode.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error("expected .agent_context/skills/ to NOT exist for OpenCode 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")
}
}
// OpenClaw's native skill scanner reads {workspaceDir}/skills/. The daemon
// pairs writeContextFiles with a per-task synthesized openclaw-config.json
// (see openclaw_config.go) that pins agents.defaults.workspace to workDir,
// so writing skills to {workDir}/skills/ is what the CLI actually scans.
// This test pins the post-MUL-2219 write path; the previous fallback into
// .agent_context/skills/ was a dead drop the openclaw scanner never read.
func TestWriteContextFilesOpenclawNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "openclaw-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, "openclaw", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
skillMd, err := os.ReadFile(filepath.Join(dir, "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read skills/go-conventions/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, "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")
}
// The pre-MUL-2219 fallback path must NOT be written: openclaw never scans it.
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error(".agent_context/skills/ MUST NOT be written for openclaw — the scanner does not read that path")
}
if _, err := os.Stat(filepath.Join(dir, ".openclaw", "skills")); !os.IsNotExist(err) {
t.Error(".openclaw/skills/ MUST NOT be written — openclaw never scans that path; writing there is a dead drop")
}
}
func TestWriteContextFilesKiroNativeSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "kiro-skill-test",
AgentSkills: []SkillContextForEnv{
{Name: "Go Conventions", Content: "Follow Go conventions."},
},
}
if err := writeContextFiles(dir, "kiro", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
skillMd, err := os.ReadFile(filepath.Join(dir, ".kiro", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .kiro/skills/go-conventions/SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
t.Error("expected .agent_context/skills/ to NOT exist for Kiro provider")
}
}
func TestInjectRuntimeConfigOpencode(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, "opencode", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// OpenCode uses AGENTS.md (same as codex).
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")
}
if !strings.Contains(s, "discovered automatically") {
t.Error("AGENTS.md missing native skill discovery hint")
}
// CLAUDE.md should NOT exist.
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Error("expected CLAUDE.md to NOT exist for OpenCode provider")
}
}
func TestInjectRuntimeConfigKiro(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, "kiro", 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")
}
if !strings.Contains(s, "discovered automatically") {
t.Error("AGENTS.md missing native skill discovery hint")
}
}
func TestPrepareWithRepoContextOpencode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
taskCtx := TaskContextForEnv{
IssueID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/backend"},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-test-oc",
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
AgentName: "OpenCode Agent",
Provider: "opencode",
Task: taskCtx,
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// Workdir should only contain expected entries.
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 != "AGENTS.md" {
t.Errorf("unexpected entry in workdir: %s", name)
}
}
// AGENTS.md should contain repo info.
content, err := os.ReadFile(filepath.Join(env.WorkDir, "AGENTS.md"))
if err != nil {
t.Fatalf("failed to read AGENTS.md: %v", err)
}
s := string(content)
for _, want := range []string{
"multica repo checkout",
"https://github.com/org/backend",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing %q", want)
}
}
}
// TestInjectRuntimeConfigRequiresExplicitCommentPost ensures the injected
// stable Output section still tells issue-based agents that terminal/log text
// is not user-visible, without embedding the current issue/comment IDs. The
// exact issue-specific command now lives in BuildPrompt.
func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
t.Parallel()
assignmentCtx := TaskContextForEnv{IssueID: "issue-1"}
commentCtx := TaskContextForEnv{IssueID: "issue-1", TriggerCommentID: "comment-1"}
for _, tc := range []struct {
name string
ctx TaskContextForEnv
}{
{"assignment-triggered", assignmentCtx},
{"comment-triggered", commentCtx},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(data)
mustContain := []string{
"results must be delivered via `multica issue comment add`",
"does NOT see your terminal output",
}
for _, want := range mustContain {
if !strings.Contains(s, want) {
t.Errorf("%s: CLAUDE.md missing %q\n---\n%s", tc.name, want, s)
}
}
for _, banned := range []string{
"multica issue comment add issue-1",
"comment-1",
"--parent comment-1",
} {
if strings.Contains(s, banned) {
t.Errorf("%s: CLAUDE.md should not contain per-turn command fragment %q\n---\n%s", tc.name, banned, s)
}
}
})
}
}
// TestInjectRuntimeConfigAvailableCommandsIsNeutral pins that the global
// Available Commands section lists the three input modes neutrally for
// every non-Codex provider on every host OS, with no "MUST pipe via stdin"
// mandate.
//
// Background: #1795 / #1851 introduced "MUST pipe via stdin" /
// `--description-stdin` directives in the global section to fix Codex's
// habit of emitting literal `\n` inside `--content "..."` (MUL-1467).
// That mandate landed in the all-provider section and ended up steering
// every provider at stdin — which then broke non-ASCII bytes on Windows
// shells (#2198 / #2236 / #2376). The strong Codex-specific mandate now
// lives in the per-turn prompt helpers, leaving runtime config neutral.
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigAvailableCommandsIsNeutral(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
for _, host := range []string{"linux", "darwin", "windows"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
t.Run(provider+"/"+host, func(t *testing.T) {
runtimeGOOS = host
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, provider, TaskContextForEnv{IssueID: "issue-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
configFile := "CLAUDE.md"
if provider != "claude" {
configFile = "AGENTS.md"
}
if provider == "gemini" {
configFile = "GEMINI.md"
}
data, err := os.ReadFile(filepath.Join(dir, configFile))
if err != nil {
t.Fatalf("read %s: %v", configFile, err)
}
s := string(data)
// Available Commands lists all three input modes as fact.
for _, want := range []string{
"`--content \"...\"`",
"`--content-stdin`",
"`--content-file <path>`",
"`--description-stdin`",
"`--description-file <path>`",
} {
if !strings.Contains(s, want) {
t.Errorf("%s missing flag mention %q\n---\n%s", configFile, want, s)
}
}
// "MUST pipe via stdin" must NOT appear in any non-Codex
// provider's runtime config: it was the over-spread of
// the Codex-specific fix.
for _, banned := range []string{
"MUST pipe via stdin",
"Agent-authored comments should always pipe content via stdin",
"use `--description-stdin` and pipe a HEREDOC",
} {
if strings.Contains(s, banned) {
t.Errorf("%s carries over-spread Codex mandate %q for non-Codex provider %s\n---\n%s", configFile, banned, provider, s)
}
}
})
}
}
}
// TestInjectRuntimeConfigCodexOmitsCommentFormattingTemplate pins that
// Codex-specific comment command templates live in BuildPrompt, not AGENTS.md.
// This keeps per-turn parent IDs and HEREDOC snippets from being duplicated
// across runtime config and the task prompt.
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigCodexOmitsCommentFormattingTemplate(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
runtimeGOOS = "linux"
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, banned := range []string{
"Codex-Specific Comment Formatting",
"always use `--content-stdin` with a HEREDOC",
"<<'COMMENT'",
"comment-1",
"--parent comment-1",
} {
if strings.Contains(s, banned) {
t.Errorf("AGENTS.md should not contain per-turn Codex formatting %q\n---\n%s", banned, s)
}
}
}
// TestInjectRuntimeConfigCodexWindowsKeepsCommandsNeutral pins that AGENTS.md
// still lists the safe file mode but does not carry a turn-specific
// `--content-file` reply template.
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigCodexWindowsKeepsCommandsNeutral(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
runtimeGOOS = "windows"
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{IssueID: "issue-1", TriggerCommentID: "comment-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, want := range []string{
"--content-file",
"--description-file",
"The per-turn prompt is the source of truth",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing neutral command guidance %q\n---\n%s", want, s)
}
}
for _, banned := range []string{
"always use `--content-stdin` with a HEREDOC, even for short single-line replies",
"Codex-Specific Comment Formatting",
"--parent comment-1",
"comment-1",
"$OutputEncoding",
} {
if strings.Contains(s, banned) {
t.Errorf("AGENTS.md still carries per-turn Codex/Windows guidance %q\n---\n%s", banned, s)
}
}
}
func TestInjectRuntimeConfigQuickCreateDefersOutputRulesToPrompt(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{QuickCreatePrompt: "create a task"}
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, want := range []string{
"The per-turn prompt is the source of truth",
"chat, quick-create, and run-only autopilot tasks",
} {
if !strings.Contains(s, want) {
t.Errorf("quick-create runtime config missing stable guidance %q\n---\n%s", want, s)
}
}
for _, absent := range []string{
"Created MUL-<n>",
"Created <identifier-or-id>: <title>",
"identifier` from JSON output",
"multica issue create --output json",
} {
if strings.Contains(s, absent) {
t.Errorf("quick-create runtime config should defer per-turn rule %q\n---\n%s", absent, s)
}
}
}
func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
AutopilotRunID: "run-1",
AutopilotID: "autopilot-1",
AutopilotTitle: "Daily dependency check",
AutopilotDescription: "Check dependencies and report outdated packages.",
AutopilotSource: "manual",
}
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, want := range []string{
"The per-turn prompt is the source of truth",
"chat, quick-create, and run-only autopilot tasks",
} {
if !strings.Contains(s, want) {
t.Errorf("autopilot runtime config missing stable guidance %q\n---\n%s", want, s)
}
}
for _, absent := range []string{
"Run `multica issue get",
"Autopilot run ID: `run-1`",
"Check dependencies and report outdated packages.",
"multica autopilot get autopilot-1 --output json",
"Your final assistant output is captured automatically as the autopilot run result",
} {
if strings.Contains(s, absent) {
t.Errorf("autopilot runtime config should not contain %q\n---\n%s", absent, s)
}
}
}
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 TestInjectRuntimeConfigHermes(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, "hermes", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// Hermes uses AGENTS.md.
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")
}
// Hermes has no native skill discovery path wired up, so AGENTS.md must
// point the agent at the .agent_context/skills/ fallback — NOT claim that
// skills are "discovered automatically".
if strings.Contains(s, "discovered automatically") {
t.Error("AGENTS.md for Hermes should not claim native skill discovery")
}
if !strings.Contains(s, ".agent_context/skills/") {
t.Error("AGENTS.md for Hermes should reference .agent_context/skills/ fallback path")
}
// CLAUDE.md should NOT exist.
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Error("expected CLAUDE.md to NOT exist for Hermes provider")
}
}
func TestWriteContextFilesHermesFallbackSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "hermes-skill-test",
AgentSkills: []SkillContextForEnv{
{Name: "Go Conventions", Content: "Follow Go conventions."},
},
}
if err := writeContextFiles(dir, "hermes", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
// Skills should be in the fallback .agent_context/skills/ path since
// Hermes has no native skills discovery directory.
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read .agent_context/skills/go-conventions/SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
}
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)
sharedPluginCache := filepath.Join(sharedHome, "plugins", "cache")
if err := os.MkdirAll(filepath.Join(sharedPluginCache, "superpowers"), 0o755); err != nil {
t.Fatalf("create shared plugin cache: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedPluginCache, "superpowers", "SKILL.md"), []byte("Use superpowers."), 0o644); err != nil {
t.Fatalf("write shared plugin skill: %v", err)
}
// 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)
}
// sessions should be a symlink to the shared sessions dir.
sessionsPath := filepath.Join(codexHome, "sessions")
fi, err := os.Lstat(sessionsPath)
if err != nil {
t.Fatalf("sessions not found: %v", err)
}
sessionsIsLink := fi.Mode()&os.ModeSymlink != 0
if !sessionsIsLink && runtime.GOOS != "windows" {
t.Error("sessions should be a symlink")
}
if sessionsIsLink {
sessTarget, _ := os.Readlink(sessionsPath)
if sessTarget != filepath.Join(sharedHome, "sessions") {
t.Errorf("sessions symlink target = %q, want %q", sessTarget, filepath.Join(sharedHome, "sessions"))
}
} else if fi.IsDir() {
if _, err := os.Stat(sessionsPath); err != nil {
t.Fatalf("sessions link target should be accessible: %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)
}
authIsLink := fi.Mode()&os.ModeSymlink != 0
if !authIsLink && runtime.GOOS != "windows" {
t.Error("auth.json should be a symlink")
}
if authIsLink {
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 and have network access appended.
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
tomlStr := string(data)
if !strings.Contains(tomlStr, `model = "o3"`) {
t.Errorf("config.toml missing original model setting, got: %q", tomlStr)
}
if !strings.Contains(tomlStr, "network_access = true") {
t.Errorf("config.toml missing network_access, got: %q", tomlStr)
}
// 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)
}
// plugin cache should be exposed at the same relative path in codex-home.
pluginSkillPath := filepath.Join(codexHome, "plugins", "cache", "superpowers", "SKILL.md")
data, err = os.ReadFile(pluginSkillPath)
if err != nil {
t.Fatalf("plugin cache skill not exposed: %v", err)
}
if string(data) != "Use superpowers." {
t.Errorf("plugin cache skill content = %q", data)
}
}
// Regression test for #1753 — Codex Desktop writes plugin-backed
// `[[skills.config]]` entries without a `path` field, and the CLI's TOML
// parser rejects them with `missing field path`. prepareCodexHome must drop
// every `[[skills.config]]` entry while copying the user's config.toml so
// the per-task home stays parseable.
func TestPrepareCodexHomeStripsSkillsConfigEntries(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
sharedConfig := `model = "o3"
[[skills.config]]
path = "/Users/x/SKILL.md"
enabled = false
[[skills.config]]
name = "superpowers:brainstorming"
enabled = false
[profiles.default]
model = "o3"
`
if err := os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(sharedConfig), 0o644); err != nil {
t.Fatalf("write shared config.toml: %v", err)
}
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)
}
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("read per-task config.toml: %v", err)
}
tomlStr := string(data)
if strings.Contains(tomlStr, "[[skills.config]]") {
t.Errorf("per-task config.toml should not inherit [[skills.config]] entries, got:\n%s", tomlStr)
}
if strings.Contains(tomlStr, "superpowers:brainstorming") {
t.Errorf("per-task config.toml should not retain plugin skill names, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, `model = "o3"`) {
t.Errorf("top-level keys should be preserved, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "[profiles.default]") {
t.Errorf("unrelated tables should be preserved, got:\n%s", tomlStr)
}
}
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 contain sessions symlink + auto-generated config.toml.
entries, err := os.ReadDir(codexHome)
if err != nil {
t.Fatalf("failed to read codex-home: %v", err)
}
entryNames := make(map[string]bool, len(entries))
for _, e := range entries {
entryNames[e.Name()] = true
}
if !entryNames["sessions"] {
t.Error("expected sessions symlink")
}
if !entryNames["config.toml"] {
t.Error("expected config.toml (auto-generated for network access)")
}
if !entryNames["plugins"] {
t.Error("expected plugins directory for plugin cache exposure")
}
for name := range entryNames {
if name != "sessions" && name != "config.toml" && name != "plugins" {
t.Errorf("unexpected entry: %s", name)
}
}
// sessions should be a symlink to the shared sessions dir.
sessionsPath := filepath.Join(codexHome, "sessions")
fi, err := os.Lstat(sessionsPath)
if err != nil {
t.Fatalf("sessions not found: %v", err)
}
if fi.Mode()&os.ModeSymlink == 0 && runtime.GOOS != "windows" {
t.Error("sessions should be a symlink")
}
if _, err := os.Stat(filepath.Join(codexHome, "plugins", "cache")); err != nil {
t.Fatalf("missing shared plugin cache exposure should still be tolerated and created: %v", err)
}
}
// Regression for issue #2081: when the per-task auth.json is a stale regular
// file (e.g. left behind from an earlier Windows copy fallback), a subsequent
// Reuse() / prepareCodexHome must refresh it from the shared source rather
// than preserve the stale copy. Without this, Codex would keep retrying with
// a refresh token the OAuth server has already revoked, surfacing as
// `refresh_token_reused` / `token_expired` until the user manually nukes the
// workspace directory.
func TestPrepareCodexHome_RefreshesStaleAuthCopyOnReuse(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"refresh_token":"v1"}`), 0o644)
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
// Pre-seed the per-task home with a stale regular-file auth.json,
// simulating a previous run where os.Symlink failed and createFileLink
// fell back to copying.
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
stalePath := filepath.Join(codexHome, "auth.json")
if err := os.WriteFile(stalePath, []byte(`{"refresh_token":"v0_stale"}`), 0o644); err != nil {
t.Fatalf("seed stale auth: %v", err)
}
// Shared source rotates to v2 while the per-task copy is still stuck on v0.
os.WriteFile(filepath.Join(sharedHome, "auth.json"), []byte(`{"refresh_token":"v2"}`), 0o644)
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// After Reuse, dst should mirror the current shared source — either as a
// fresh symlink (preferred) or as a fresh copy (Windows fallback).
data, err := os.ReadFile(stalePath)
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
if string(data) != `{"refresh_token":"v2"}` {
t.Errorf("auth.json content = %q, want refreshed v2 contents", data)
}
}
func TestEnsureCodexSandboxConfigCreatesDefaultLinux(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
policy := codexSandboxPolicyFor("linux", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config.toml: %v", err)
}
s := string(data)
if !strings.Contains(s, multicaManagedBeginMarker) || !strings.Contains(s, multicaManagedEndMarker) {
t.Errorf("missing managed block markers, got:\n%s", s)
}
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("missing sandbox_mode")
}
// The managed block uses TOML dotted-key form rather than a
// `[sandbox_workspace_write]` section header so it cannot leak into or
// inherit from any surrounding table scope. See upsertMulticaManagedBlock
// for why.
if strings.Contains(s, "[sandbox_workspace_write]") {
t.Errorf("managed block must not open a [sandbox_workspace_write] table header, got:\n%s", s)
}
if !strings.Contains(s, "sandbox_workspace_write.network_access = true") {
t.Errorf("missing dotted-key network_access = true, got:\n%s", s)
}
}
func TestEnsureCodexSandboxConfigDarwinFallsBack(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
policy := codexSandboxPolicyFor("darwin", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
s, _ := os.ReadFile(configPath)
if !strings.Contains(string(s), `sandbox_mode = "danger-full-access"`) {
t.Errorf("expected danger-full-access fallback on macOS, got:\n%s", s)
}
if strings.Contains(string(s), "[sandbox_workspace_write]") {
t.Errorf("should not emit workspace-write section on macOS fallback, got:\n%s", s)
}
}
func TestEnsureCodexSandboxConfigIsIdempotent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
policy := codexSandboxPolicyFor("linux", "0.121.0")
for i := 0; i < 3; i++ {
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("pass %d: %v", i, err)
}
}
data, _ := os.ReadFile(configPath)
// The managed block should appear exactly once.
if n := strings.Count(string(data), multicaManagedBeginMarker); n != 1 {
t.Errorf("expected exactly 1 managed block, got %d in:\n%s", n, data)
}
}
func TestEnsureCodexSandboxConfigPreservesUserContent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
existing := `model = "o3"
approval_policy = "on-failure"
`
os.WriteFile(configPath, []byte(existing), 0o644)
policy := codexSandboxPolicyFor("linux", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, `model = "o3"`) {
t.Error("lost existing model setting")
}
if !strings.Contains(s, "approval_policy") {
t.Error("lost existing approval_policy")
}
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
}
func TestEnsureCodexSandboxConfigStripsLegacyInlineDirectives(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
// Simulate a config.toml produced by an older daemon version that wrote
// sandbox directives inline (no managed block markers). After migration,
// the inline directives should be gone and only the managed block should
// carry them.
existing := `model = "o3"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
`
os.WriteFile(configPath, []byte(existing), 0o644)
policy := codexSandboxPolicyFor("darwin", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, `model = "o3"`) {
t.Error("should have preserved unrelated user config")
}
// Inline sandbox_mode and [sandbox_workspace_write] should be stripped.
if strings.Count(s, "sandbox_mode") != 1 {
t.Errorf("expected exactly one sandbox_mode line (inside managed block), got:\n%s", s)
}
if strings.Contains(s, "[sandbox_workspace_write]") {
t.Errorf("darwin fallback should not retain workspace-write section:\n%s", s)
}
if !strings.Contains(s, `sandbox_mode = "danger-full-access"`) {
t.Errorf("expected danger-full-access on macOS, got:\n%s", s)
}
}
func TestEnsureCodexSandboxConfigHoistsAboveUserTables(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
// User config that ends inside a table. If the managed block were
// appended at EOF, `sandbox_mode = "..."` would be parsed as
// permissions.multica.sandbox_mode and Codex would never see it — see
// review of MUL-963 PR #1246. The block must be hoisted above any
// user-defined table headers so it lives at the TOML root.
existing := `model = "o3"
[permissions.multica]
trust = "always"
`
os.WriteFile(configPath, []byte(existing), 0o644)
policy := codexSandboxPolicyFor("linux", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
beginIdx := strings.Index(s, multicaManagedBeginMarker)
endIdx := strings.Index(s, multicaManagedEndMarker)
tableIdx := strings.Index(s, "[permissions.multica]")
if beginIdx < 0 || endIdx < 0 || tableIdx < 0 {
t.Fatalf("expected managed block and user table to both be present, got:\n%s", s)
}
// The entire managed block must sit before the user's table header so
// that sandbox_mode and sandbox_workspace_write.network_access are
// parsed at the TOML root.
if !(beginIdx < endIdx && endIdx < tableIdx) {
t.Errorf("managed block must be hoisted above [permissions.multica]; got begin=%d end=%d table=%d:\n%s", beginIdx, endIdx, tableIdx, s)
}
// User content must be preserved verbatim.
if !strings.Contains(s, `model = "o3"`) {
t.Error("lost user top-level key")
}
if !strings.Contains(s, `trust = "always"`) {
t.Error("lost user permissions.multica content")
}
// Running again must be idempotent even when the preceding content ends
// inside a table.
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("second pass: %v", err)
}
data2, _ := os.ReadFile(configPath)
if string(data2) != s {
t.Errorf("second pass should be idempotent:\n--- first ---\n%s\n--- second ---\n%s", s, data2)
}
if n := strings.Count(string(data2), multicaManagedBeginMarker); n != 1 {
t.Errorf("expected exactly one managed block after idempotent rewrite, got %d", n)
}
}
func TestEnsureCodexSandboxConfigMovesLegacyTrailingBlockToTop(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
// Simulate a config.toml produced by the pre-fix PR #1246 logic, which
// appended the managed block to EOF — so the block sits below a user
// table. On the next daemon run, the block must be hoisted back to the
// top; otherwise sandbox_mode remains trapped inside the preceding table.
legacy := `model = "o3"
[permissions.multica]
trust = "always"
` + multicaManagedBeginMarker + `
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
` + multicaManagedEndMarker + `
`
os.WriteFile(configPath, []byte(legacy), 0o644)
policy := codexSandboxPolicyFor("linux", "0.121.0")
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
beginIdx := strings.Index(s, multicaManagedBeginMarker)
tableIdx := strings.Index(s, "[permissions.multica]")
if beginIdx < 0 || tableIdx < 0 || beginIdx > tableIdx {
t.Errorf("expected managed block to be hoisted above [permissions.multica], got:\n%s", s)
}
if strings.Count(s, multicaManagedBeginMarker) != 1 {
t.Errorf("expected exactly one managed block, got:\n%s", s)
}
// The old inline `[sandbox_workspace_write]` header must be gone — the
// new block uses dotted-key form only.
if strings.Contains(s, "[sandbox_workspace_write]") {
t.Errorf("managed block must not emit [sandbox_workspace_write] table header, got:\n%s", s)
}
}
func TestCodexSandboxPolicyFor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
goos string
version string
wantMode string
wantNet bool
}{
{"linux any version", "linux", "0.100.0", "workspace-write", true},
{"linux unknown version", "linux", "", "workspace-write", true},
{"darwin old version", "darwin", "0.121.0", "danger-full-access", false},
{"darwin unknown version", "darwin", "", "danger-full-access", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := codexSandboxPolicyFor(tc.goos, tc.version)
if p.Mode != tc.wantMode {
t.Errorf("mode = %q, want %q", p.Mode, tc.wantMode)
}
if p.NetworkAccess != tc.wantNet {
t.Errorf("network_access = %v, want %v", p.NetworkAccess, tc.wantNet)
}
if p.Reason == "" {
t.Error("expected non-empty Reason")
}
})
}
}
func TestPrepareCodexHomeEnsuresNetworkAccess(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Empty shared home — no config.toml to copy.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
// Default prepareCodexHome assumes linux-like behavior.
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// config.toml should be created with network access defaults.
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not created: %v", err)
}
s := string(data)
if !strings.Contains(s, "network_access = true") {
t.Error("config.toml missing network_access = true")
}
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("config.toml missing sandbox_mode")
}
}
func TestReuseRestoresCodexHome(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
// First, Prepare a codex env.
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-reuse",
TaskID: "e5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.CodexHome == "" {
t.Fatal("expected CodexHome to be set after Prepare")
}
// Reuse should restore CodexHome.
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-test"}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if reused.CodexHome == "" {
t.Fatal("expected CodexHome to be restored after Reuse")
}
// Verify config.toml has a managed block (exact mode depends on host
// platform; either workspace-write or danger-full-access is valid).
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not found in reused CodexHome: %v", err)
}
if !strings.Contains(string(data), multicaManagedBeginMarker) {
t.Error("reused config.toml missing multica-managed block")
}
}
func TestReuseRestoresCodexPluginCache(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
sharedPluginCache := filepath.Join(sharedHome, "plugins", "cache")
if err := os.MkdirAll(filepath.Join(sharedPluginCache, "superpowers"), 0o755); err != nil {
t.Fatalf("create shared plugin cache: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedPluginCache, "superpowers", "SKILL.md"), []byte("Use superpowers."), 0o644); err != nil {
t.Fatalf("write shared plugin skill: %v", err)
}
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-plugin-reuse",
TaskID: "a5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-plugin-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if err := os.RemoveAll(filepath.Join(env.CodexHome, "plugins")); err != nil {
t.Fatalf("remove codex plugins dir: %v", err)
}
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{IssueID: "reuse-plugin-test"}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "plugins", "cache", "superpowers", "SKILL.md"))
if err != nil {
t.Fatalf("reused codex plugin cache not restored: %v", err)
}
if string(data) != "Use superpowers." {
t.Errorf("reused plugin cache skill content = %q", data)
}
}
func TestReuseWritesMissingCodexWorkspaceSkills(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-skill-reuse",
TaskID: "b5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-skill-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if err := os.RemoveAll(filepath.Join(env.CodexHome, "skills")); err != nil {
t.Fatalf("remove codex skills dir: %v", err)
}
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{
IssueID: "reuse-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Writing",
Content: "Write clearly.",
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Example"}},
},
},
}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "SKILL.md"))
if err != nil {
t.Fatalf("missing reused codex workspace skill: %v", err)
}
if string(data) != "Write clearly." {
t.Errorf("skill content = %q", data)
}
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
if err != nil {
t.Fatalf("missing reused codex workspace skill support file: %v", err)
}
if string(example) != "Example" {
t.Errorf("support file content = %q", example)
}
}
func TestReuseUpdatesCodexWorkspaceSkills(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-skill-update",
TaskID: "c5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{
IssueID: "reuse-skill-update-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Writing",
Content: "Old writing guidance.",
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Old example"}},
},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{
IssueID: "reuse-skill-update-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Writing",
Content: "Updated writing guidance.",
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Updated example"}},
},
},
}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "SKILL.md"))
if err != nil {
t.Fatalf("missing reused codex workspace skill: %v", err)
}
if string(data) != "Updated writing guidance." {
t.Errorf("skill content = %q", data)
}
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
if err != nil {
t.Fatalf("missing reused codex workspace skill support file: %v", err)
}
if string(example) != "Updated example" {
t.Errorf("support file content = %q", example)
}
}
// TestPrepareCodexSeedsUserSkills covers the fix for #1922: skills the user
// installs under ~/.codex/skills/ must be discoverable by the codex CLI
// inside a Multica task, despite the daemon redirecting CODEX_HOME to a
// per-task directory.
func TestPrepareCodexSeedsUserSkills(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
// Lay out two user-installed skills with both a SKILL.md and a
// supporting file, plus an ignored dotfile that must not be copied.
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "summarize", "examples"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "SKILL.md"), []byte("summarize"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "examples", "ex.md"), []byte("example"), 0o644); err != nil {
t.Fatalf("seed user support file: %v", err)
}
if err := os.MkdirAll(filepath.Join(userSkills, "translate"), 0o755); err != nil {
t.Fatalf("seed second user skill: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "translate", "SKILL.md"), []byte("translate"), 0o644); err != nil {
t.Fatalf("seed second user SKILL.md: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, ".DS_Store"), []byte("noise"), 0o644); err != nil {
t.Fatalf("seed ignored dotfile: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-user-skills",
TaskID: "d6f7a8b9-c0d1-2345-efab-678901234567",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "user-skills-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "summarize", "SKILL.md")); err != nil {
t.Fatalf("user skill SKILL.md not seeded: %v", err)
} else if string(data) != "summarize" {
t.Errorf("summarize SKILL.md = %q, want %q", data, "summarize")
}
if data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "summarize", "examples", "ex.md")); err != nil {
t.Fatalf("user skill support file not seeded: %v", err)
} else if string(data) != "example" {
t.Errorf("ex.md = %q, want %q", data, "example")
}
if data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "translate", "SKILL.md")); err != nil {
t.Fatalf("second user skill not seeded: %v", err)
} else if string(data) != "translate" {
t.Errorf("translate SKILL.md = %q, want %q", data, "translate")
}
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", ".DS_Store")); !os.IsNotExist(err) {
t.Errorf("ignored dotfile leaked into codex-home/skills: err=%v", err)
}
}
// TestPrepareCodexWorkspaceSkillBeatsUserSkillOnConflict checks that when a
// workspace-assigned skill shares a sanitized name with a user-installed
// skill, the workspace version fully replaces the user version (rather than
// leaving stale user files lingering).
func TestPrepareCodexWorkspaceSkillBeatsUserSkillOnConflict(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkillDir := filepath.Join(sharedHome, "skills", "writing")
if err := os.MkdirAll(filepath.Join(userSkillDir, "drafts"), 0o755); err != nil {
t.Fatalf("seed user writing skill: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkillDir, "SKILL.md"), []byte("user writing"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkillDir, "drafts", "stale.md"), []byte("stale"), 0o644); err != nil {
t.Fatalf("seed user stale file: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-skill-conflict",
TaskID: "e7f8a9b0-c1d2-3456-efab-789012345678",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{
IssueID: "skill-conflict-test",
AgentSkills: []SkillContextForEnv{
{Name: "Writing", Content: "workspace writing"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "writing", "SKILL.md"))
if err != nil {
t.Fatalf("workspace skill not written: %v", err)
}
if string(data) != "workspace writing" {
t.Errorf("SKILL.md = %q, want workspace content", data)
}
// The user's stale support file must not leak through — seeding is
// skipped entirely for names that workspace skills claim.
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "writing", "drafts", "stale.md")); !os.IsNotExist(err) {
t.Errorf("user-skill stale file leaked despite workspace conflict: err=%v", err)
}
}
// TestPrepareCodexNoUserSkillsDir is a regression guard for the empty case —
// when ~/.codex/skills doesn't exist, the seed step is a no-op and Prepare
// still succeeds.
func TestPrepareCodexNoUserSkillsDir(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-no-user-skills",
TaskID: "f8a9b0c1-d2e3-4567-fabc-890123456789",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "no-user-skills-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills")); !os.IsNotExist(err) {
t.Errorf("skills dir should not exist when neither user nor workspace skills are present, err=%v", err)
}
}
// TestPrepareCodexResolvesUserSkillSymlinks covers the lark-cli /
// shared-installer case: each user skill is a symlink into a separate
// installer directory. The per-task home must end up with a real copy, not
// a dangling symlink that points outside the task root.
func TestPrepareCodexResolvesUserSkillSymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows; covered by Unix path")
}
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
installerRoot := filepath.Join(t.TempDir(), "installer", "lark-mail")
if err := os.MkdirAll(installerRoot, 0o755); err != nil {
t.Fatalf("seed installer dir: %v", err)
}
if err := os.WriteFile(filepath.Join(installerRoot, "SKILL.md"), []byte("lark"), 0o644); err != nil {
t.Fatalf("seed installer SKILL.md: %v", err)
}
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(userSkills, 0o755); err != nil {
t.Fatalf("seed user skills dir: %v", err)
}
if err := os.Symlink(installerRoot, filepath.Join(userSkills, "lark-mail")); err != nil {
t.Fatalf("seed user skill symlink: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-symlinked-skills",
TaskID: "a9b0c1d2-e3f4-5678-abcd-901234567890",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "symlinked-skills-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
dst := filepath.Join(env.CodexHome, "skills", "lark-mail")
fi, err := os.Lstat(dst)
if err != nil {
t.Fatalf("seeded skill missing: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Errorf("seeded skill should be a real directory, got a symlink")
}
data, err := os.ReadFile(filepath.Join(dst, "SKILL.md"))
if err != nil {
t.Fatalf("seeded SKILL.md missing: %v", err)
}
if string(data) != "lark" {
t.Errorf("seeded SKILL.md = %q, want %q", data, "lark")
}
}
// TestReuseSeedsUserSkillUpdates ensures that user-skill edits between two
// runs of the same task (the Reuse path) propagate into the per-task home.
func TestReuseSeedsUserSkillUpdates(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkill := filepath.Join(sharedHome, "skills", "summarize")
if err := os.MkdirAll(userSkill, 0o755); err != nil {
t.Fatalf("seed user skill: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkill, "SKILL.md"), []byte("v1"), 0o644); err != nil {
t.Fatalf("seed v1 SKILL.md: %v", err)
}
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-user-skill-reuse",
TaskID: "b0c1d2e3-f4a5-6789-abcd-012345678901",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "user-skill-reuse-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if err := os.WriteFile(filepath.Join(userSkill, "SKILL.md"), []byte("v2"), 0o644); err != nil {
t.Fatalf("update user SKILL.md: %v", err)
}
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{
IssueID: "user-skill-reuse-test",
}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "summarize", "SKILL.md"))
if err != nil {
t.Fatalf("user skill not refreshed on reuse: %v", err)
}
if string(data) != "v2" {
t.Errorf("after Reuse, user skill content = %q, want %q", data, "v2")
}
}
// TestReuseClearsUserSkillResidueOnWorkspaceConflict locks in the fix for
// the GPT-Boy review on PR #2519: when round 1 seeded a user skill named
// `writing` (including support files) and round 2 reuses the same workdir
// with a workspace skill `Writing`, the user-version support files must not
// linger under the workspace skill's directory.
func TestReuseClearsUserSkillResidueOnWorkspaceConflict(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkillDir := filepath.Join(sharedHome, "skills", "writing")
if err := os.MkdirAll(filepath.Join(userSkillDir, "drafts"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkillDir, "SKILL.md"), []byte("user writing"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkillDir, "drafts", "stale.md"), []byte("stale"), 0o644); err != nil {
t.Fatalf("seed user support file: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-reuse-conflict",
TaskID: "c1d2e3f4-a5b6-7890-abcd-123456789012",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-conflict-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// Round 1 had no workspace skill, so the user version should be present.
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "writing", "drafts", "stale.md")); err != nil {
t.Fatalf("user support file should be seeded in round 1: %v", err)
}
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{
IssueID: "reuse-conflict-test",
AgentSkills: []SkillContextForEnv{
{Name: "Writing", Content: "workspace writing"},
},
}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "SKILL.md"))
if err != nil {
t.Fatalf("workspace SKILL.md missing after reuse: %v", err)
}
if string(data) != "workspace writing" {
t.Errorf("SKILL.md = %q, want workspace content", data)
}
if _, err := os.Stat(filepath.Join(reused.CodexHome, "skills", "writing", "drafts", "stale.md")); !os.IsNotExist(err) {
t.Errorf("round-1 user support file leaked into round-2 workspace skill dir, err=%v", err)
}
}
// TestReuseClearsRemovedUserSkill checks that uninstalling a user skill
// between two runs (delete it from ~/.codex/skills) also drops it from the
// per-task home on Reuse — otherwise users would still see deleted skills
// surface to the codex CLI.
func TestReuseClearsRemovedUserSkill(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkill := filepath.Join(sharedHome, "skills", "deprecated")
if err := os.MkdirAll(userSkill, 0o755); err != nil {
t.Fatalf("seed user skill: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkill, "SKILL.md"), []byte("deprecated"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-reuse-remove",
TaskID: "d2e3f4a5-b6c7-8901-abcd-234567890123",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-remove-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "deprecated", "SKILL.md")); err != nil {
t.Fatalf("user skill should be seeded in round 1: %v", err)
}
// Uninstall the user skill before round 2.
if err := os.RemoveAll(userSkill); err != nil {
t.Fatalf("remove user skill: %v", err)
}
reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "codex", Task: TaskContextForEnv{
IssueID: "reuse-remove-test",
}}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if _, err := os.Stat(filepath.Join(reused.CodexHome, "skills", "deprecated")); !os.IsNotExist(err) {
t.Errorf("removed user skill still present in per-task home after reuse, err=%v", err)
}
}
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.
if err := os.Symlink(filepath.Join(dir, "old-source.json"), dst); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("file symlink unavailable on this Windows session: %v", err)
}
t.Fatalf("seed broken symlink: %v", err)
}
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")
}
}
func TestWriteReadGCMeta(t *testing.T) {
t.Parallel()
dir := t.TempDir()
issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
wsID := "ws-test-001"
if err := WriteGCMeta(dir, GCMeta{
Kind: GCKindIssue,
IssueID: issueID,
WorkspaceID: wsID,
}, discardLogger()); err != nil {
t.Fatalf("WriteGCMeta: %v", err)
}
meta, err := ReadGCMeta(dir)
if err != nil {
t.Fatalf("ReadGCMeta: %v", err)
}
if meta.Kind != GCKindIssue {
t.Errorf("Kind = %q, want %q", meta.Kind, GCKindIssue)
}
if meta.IssueID != issueID {
t.Errorf("IssueID = %q, want %q", meta.IssueID, issueID)
}
if meta.WorkspaceID != wsID {
t.Errorf("WorkspaceID = %q, want %q", meta.WorkspaceID, wsID)
}
if meta.CompletedAt.IsZero() {
t.Error("CompletedAt should not be zero")
}
}
func TestWriteGCMeta_EmptyRoot(t *testing.T) {
t.Parallel()
if err := WriteGCMeta("", GCMeta{Kind: GCKindIssue, IssueID: "x", WorkspaceID: "ws"}, discardLogger()); err != nil {
t.Fatalf("expected nil for empty root, got %v", err)
}
}
func TestWriteGCMeta_EmptyKind(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := WriteGCMeta(dir, GCMeta{WorkspaceID: "ws"}, discardLogger()); err != nil {
t.Fatalf("expected nil for empty kind, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, gcMetaFile)); !os.IsNotExist(err) {
t.Fatalf("expected gc meta file to be absent, got err=%v", err)
}
}
// Pre-v2 meta files lacked the kind field. ReadGCMeta must default an empty
// kind to GCKindIssue so the existing on-disk meta files keep flowing
// through the issue path.
func TestReadGCMeta_LegacyFileDefaultsToIssueKind(t *testing.T) {
t.Parallel()
dir := t.TempDir()
legacy := []byte(`{"issue_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","workspace_id":"ws","completed_at":"2025-01-01T00:00:00Z"}`)
if err := os.WriteFile(filepath.Join(dir, gcMetaFile), legacy, 0o644); err != nil {
t.Fatal(err)
}
meta, err := ReadGCMeta(dir)
if err != nil {
t.Fatalf("ReadGCMeta: %v", err)
}
if meta.Kind != GCKindIssue {
t.Fatalf("legacy kind: want %q, got %q", GCKindIssue, meta.Kind)
}
if meta.IssueID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
t.Fatalf("legacy issue_id: got %q", meta.IssueID)
}
}
// New v2 meta files for chat / autopilot / quick-create round-trip without
// being misclassified as the issue kind.
func TestWriteReadGCMeta_KindRoundTrip(t *testing.T) {
t.Parallel()
cases := []struct {
name string
meta GCMeta
want GCMetaKind
}{
{"chat", GCMeta{Kind: GCKindChat, ChatSessionID: "cs-1", WorkspaceID: "ws"}, GCKindChat},
{"autopilot_run", GCMeta{Kind: GCKindAutopilotRun, AutopilotRunID: "ar-1", WorkspaceID: "ws"}, GCKindAutopilotRun},
{"quick_create", GCMeta{Kind: GCKindQuickCreate, TaskID: "t-1", WorkspaceID: "ws"}, GCKindQuickCreate},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := WriteGCMeta(dir, tc.meta, discardLogger()); err != nil {
t.Fatalf("WriteGCMeta: %v", err)
}
got, err := ReadGCMeta(dir)
if err != nil {
t.Fatalf("ReadGCMeta: %v", err)
}
if got.Kind != tc.want {
t.Fatalf("Kind: want %q, got %q", tc.want, got.Kind)
}
})
}
}
func TestReadGCMeta_NoFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, err := ReadGCMeta(dir)
if err == nil {
t.Fatal("expected error for missing file")
}
}
// TestInjectRuntimeConfigMentionLoopHardening locks in the mention-loop
// instructions (see MUL-1323 / GH#1576). Two agents were stuck in an infinite
// @mention loop because the harness told them mentions were "actions" but did
// not tell them (a) when NOT to mention, (b) that silence ends a thread, or
// (c) that the triggering comment was from another agent. If any of the
// signals below regress, agent-to-agent loops come back.
func TestInjectRuntimeConfigMentionLoopHardening(t *testing.T) {
t.Parallel()
commentTriggerCtx := TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
}
assignmentCtx := TaskContextForEnv{IssueID: "issue-1"}
readClaudeMD := func(t *testing.T, ctx TaskContextForEnv) string {
t.Helper()
dir := t.TempDir()
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
return string(data)
}
t.Run("mentions-section-lists-loop-protocol", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, assignmentCtx)
for _, want := range []string{
"side-effecting actions",
"enqueues a new run for that agent",
"When NOT to use a mention link",
"When a mention IS appropriate",
"end with no mention at all",
"Silence ends conversations",
} {
if !strings.Contains(s, want) {
t.Errorf("Mentions section missing %q\n---\n%s", want, s)
}
}
})
t.Run("closing-line-no-longer-says-always-mention", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, assignmentCtx)
// The old footer said "**always** use the mention format" which models
// over-generalized to agent/member mentions. Guard against regression.
if strings.Contains(s, "**always** use the mention format") {
t.Errorf("CLAUDE.md still contains the overreaching \"**always** use the mention format\" guidance")
}
})
t.Run("runtime-config-omits-comment-trigger-workflow", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, commentTriggerCtx)
for _, banned := range []string{
"comment-1",
"Find the triggering comment",
"Decide whether a reply is warranted",
"--parent comment-1",
} {
if strings.Contains(s, banned) {
t.Errorf("comment-triggered CLAUDE.md should not contain per-turn workflow %q\n---\n%s", banned, s)
}
}
if !strings.Contains(s, "The per-turn prompt is the source of truth") {
t.Errorf("CLAUDE.md missing per-turn source-of-truth guidance")
}
})
}
// TestInjectRuntimeConfigSquadLeaderNoActionDefersToPrompt verifies that
// squad-leader no_action commands are no longer written into AGENTS.md /
// CLAUDE.md with task-specific issue IDs. BuildPrompt owns those current-turn
// commands.
func TestInjectRuntimeConfigSquadLeaderNoActionDefersToPrompt(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
IsSquadLeader: true,
}
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(data)
for _, banned := range []string{
"Squad leader rule",
"multica squad activity issue-1 no_action",
"you MUST exit without posting any comment",
"comment-1",
} {
if strings.Contains(s, banned) {
t.Errorf("squad leader comment-triggered CLAUDE.md should not contain per-turn no_action fragment %q\n---\n%s", banned, s)
}
}
dir2 := t.TempDir()
ctx2 := TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
IsSquadLeader: false,
}
if _, err := InjectRuntimeConfig(dir2, "claude", ctx2); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data2, err := os.ReadFile(filepath.Join(dir2, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s2 := string(data2)
if strings.Contains(s2, "Squad leader rule") {
t.Errorf("non-squad-leader CLAUDE.md should NOT contain squad leader rule")
}
}