summaryrefslogtreecommitdiff
path: root/internal/executor/container_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/container_test.go')
-rw-r--r--internal/executor/container_test.go177
1 files changed, 123 insertions, 54 deletions
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index 54b4689..dec666e 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -2,6 +2,7 @@ package executor
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
@@ -36,6 +37,7 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) {
"-w", "/workspace",
"--env-file", "/tmp/ws/.claudomator-env",
"-e", "HOME=/home/agent",
+ "-e", "IS_SANDBOX=1",
"-e", "CLAUDOMATOR_API_URL=http://host.docker.internal:8484",
"-e", "CLAUDOMATOR_TASK_ID=task-123",
"-e", "CLAUDOMATOR_DROP_DIR=/data/drops",
@@ -53,13 +55,96 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) {
}
}
+func TestBuildAgentInstructions(t *testing.T) {
+ t.Run("mcp enabled prepends preamble", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}}
+ got := buildAgentInstructions(tk, true)
+ if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "do the thing") {
+ t.Errorf("expected preamble + instructions, got %q", got)
+ }
+ })
+ t.Run("mcp disabled keeps raw instructions", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}}
+ if got := buildAgentInstructions(tk, false); got != "do the thing" {
+ t.Errorf("expected raw instructions, got %q", got)
+ }
+ })
+ t.Run("skip planning keeps raw instructions even with mcp", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing", SkipPlanning: true}}
+ if got := buildAgentInstructions(tk, true); got != "do the thing" {
+ t.Errorf("expected raw instructions when skip_planning, got %q", got)
+ }
+ })
+}
+
+func TestWriteMCPConfig(t *testing.T) {
+ dir := t.TempDir()
+ if err := writeMCPConfig(dir, "http://host.docker.internal:8484/mcp", "tok-abc"); err != nil {
+ t.Fatalf("writeMCPConfig: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, ".claudomator-mcp.json"))
+ if err != nil {
+ t.Fatalf("read config: %v", err)
+ }
+ var parsed struct {
+ MCPServers map[string]struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers"`
+ } `json:"mcpServers"`
+ }
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("config is not valid JSON: %v", err)
+ }
+ srv, ok := parsed.MCPServers["claudomator"]
+ if !ok {
+ t.Fatal("expected claudomator server entry")
+ }
+ if srv.Type != "http" || srv.URL != "http://host.docker.internal:8484/mcp" {
+ t.Errorf("unexpected server config: %+v", srv)
+ }
+ if srv.Headers["Authorization"] != "Bearer tok-abc" {
+ t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"])
+ }
+}
+
+func TestWriteGeminiMCPSettings(t *testing.T) {
+ agentHome := t.TempDir()
+ if err := writeGeminiMCPSettings(agentHome, "http://host.docker.internal:8484/mcp", "tok-xyz"); err != nil {
+ t.Fatalf("writeGeminiMCPSettings: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(agentHome, ".gemini", "settings.json"))
+ if err != nil {
+ t.Fatalf("read settings: %v", err)
+ }
+ var parsed struct {
+ MCPServers map[string]struct {
+ HTTPURL string `json:"httpUrl"`
+ Headers map[string]string `json:"headers"`
+ } `json:"mcpServers"`
+ }
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("settings is not valid JSON: %v", err)
+ }
+ srv, ok := parsed.MCPServers["claudomator"]
+ if !ok {
+ t.Fatal("expected claudomator server entry")
+ }
+ if srv.HTTPURL != "http://host.docker.internal:8484/mcp" {
+ t.Errorf("unexpected httpUrl: %q", srv.HTTPURL)
+ }
+ if srv.Headers["Authorization"] != "Bearer tok-xyz" {
+ t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"])
+ }
+}
+
func TestContainerRunner_BuildInnerCmd(t *testing.T) {
runner := &ContainerRunner{}
t.Run("claude-fresh", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
exec := &storage.Execution{}
- cmd := runner.buildInnerCmd(tk, exec, false)
+ cmd := runner.buildInnerCmd(tk, exec, false, false)
cmdStr := strings.Join(cmd, " ")
if strings.Contains(cmdStr, "--resume") {
@@ -73,7 +158,7 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
t.Run("claude-resume", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
exec := &storage.Execution{ResumeSessionID: "orig-session-123"}
- cmd := runner.buildInnerCmd(tk, exec, true)
+ cmd := runner.buildInnerCmd(tk, exec, true, false)
cmdStr := strings.Join(cmd, " ")
if !strings.Contains(cmdStr, "--resume orig-session-123") {
@@ -81,10 +166,26 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
}
})
+ t.Run("claude-mcp-enabled", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
+ cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, true), " ")
+ if !strings.Contains(cmdStr, "--mcp-config "+mcpConfigContainerPath) {
+ t.Errorf("expected --mcp-config flag when MCP enabled, got %q", cmdStr)
+ }
+ })
+
+ t.Run("claude-mcp-disabled", func(t *testing.T) {
+ tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
+ cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, false), " ")
+ if strings.Contains(cmdStr, "--mcp-config") {
+ t.Errorf("did not expect --mcp-config flag when MCP disabled, got %q", cmdStr)
+ }
+ })
+
t.Run("gemini", func(t *testing.T) {
tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}}
exec := &storage.Execution{}
- cmd := runner.buildInnerCmd(tk, exec, false)
+ cmd := runner.buildInnerCmd(tk, exec, false, false)
cmdStr := strings.Join(cmd, " ")
if !strings.Contains(cmdStr, "gemini -p \"$INST\"") {
@@ -99,13 +200,13 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) {
}
tkClaude := &task.Task{Agent: task.AgentConfig{Type: "claude"}}
- cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false)
+ cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false, false)
if !strings.Contains(strings.Join(cmdClaude, " "), "/usr/bin/claude-v2 -p") {
t.Errorf("expected custom claude binary, got %q", cmdClaude)
}
tkGemini := &task.Task{Agent: task.AgentConfig{Type: "gemini"}}
- cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false)
+ cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false, false)
if !strings.Contains(strings.Join(cmdGemini, " "), "/usr/local/bin/gemini-pro -p") {
t.Errorf("expected custom gemini binary, got %q", cmdGemini)
}
@@ -139,7 +240,7 @@ func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) {
}
exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"}
- err := runner.Run(context.Background(), tk, exec)
+ err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to mocked docker failure")
}
@@ -168,48 +269,6 @@ func TestBlockedError_IncludesSandboxDir(t *testing.T) {
}
}
-func TestIsCompletionReport(t *testing.T) {
- tests := []struct {
- name string
- json string
- expected bool
- }{
- {
- name: "real question with options",
- json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`,
- expected: false,
- },
- {
- name: "real question no options",
- json: `{"text": "Which approach do you prefer?"}`,
- expected: false,
- },
- {
- name: "completion report no options no question mark",
- json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`,
- expected: true,
- },
- {
- name: "completion report with empty options",
- json: `{"text": "Feature implemented and committed.", "options": []}`,
- expected: true,
- },
- {
- name: "invalid json treated as not a report",
- json: `not json`,
- expected: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := isCompletionReport(tt.json)
- if got != tt.expected {
- t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected)
- }
- })
- }
-}
-
func TestTailFile_ReturnsLastNLines(t *testing.T) {
f, err := os.CreateTemp("", "tailfile-*")
if err != nil {
@@ -378,7 +437,7 @@ func TestContainerRunner_MissingCredentials_FailsFast(t *testing.T) {
}
e := &storage.Execution{ID: "test-exec", TaskID: "test-missing-creds"}
- err := runner.Run(context.Background(), tk, e)
+ err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to missing credentials, got nil")
}
@@ -418,7 +477,7 @@ func TestContainerRunner_MissingSettings_FailsFast(t *testing.T) {
}
e := &storage.Execution{ID: "test-exec-2", TaskID: "test-missing-settings"}
- err := runner.Run(context.Background(), tk, e)
+ err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
if err == nil {
t.Fatal("expected error due to missing settings, got nil")
}
@@ -504,7 +563,7 @@ func TestContainerRunner_AuthError_SyncsAndRetries(t *testing.T) {
e := &storage.Execution{ID: "auth-retry-exec", TaskID: "auth-retry-test"}
// Run — first attempt will fail with auth error, triggering sync+retry
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
// We don't check error strictly since second run may also fail (git push etc.)
// What we care about is that docker was called twice and sync was called
if callCount < 2 {
@@ -550,7 +609,7 @@ func TestContainerRunner_ClonesStoryBranch(t *testing.T) {
}
e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
os.RemoveAll(e.SandboxDir)
// Assert git checkout was called with the story branch name.
@@ -597,7 +656,7 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) {
}
e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID))
os.RemoveAll(e.SandboxDir)
for _, a := range cloneArgs {
@@ -663,7 +722,7 @@ func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) {
Agent: task.AgentConfig{Type: "claude"},
}
e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"}
- runner.Run(context.Background(), tk, e)
+ runner.Run(context.Background(), tk, e, noopChannel{})
os.RemoveAll(e.SandboxDir)
if cloneSrc == "https://github.com/thepeterstone/nav.git" {
@@ -752,3 +811,13 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) {
t.Fatalf("ensureStoryBranch on existing branch: %v", err)
}
}
+
+// noopChannel satisfies AgentChannel with no-op implementations for tests.
+type noopChannel struct{}
+
+func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil }
+func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil }
+func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) {
+ return "", nil
+}
+func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil }