From 74c9647391645ec10bfda9f1be4ae41c9dd4f827 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 23 Feb 2026 15:14:08 +0100 Subject: [PATCH 1/4] adding E2E tests for OpenCode Entire-Checkpoint: 2baba34463b5 --- .../cli/agent/opencode/entire_plugin.ts | 38 +++++- cmd/entire/cli/e2e_test/agent_runner.go | 109 ++++++++++++++++++ .../scenario_checkpoint_workflows_test.go | 4 +- cmd/entire/cli/e2e_test/testenv.go | 15 ++- mise.toml | 4 + 5 files changed, 161 insertions(+), 9 deletions(-) diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts index c44abc54d..e311645ac 100644 --- a/cmd/entire/cli/agent/opencode/entire_plugin.ts +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -14,7 +14,7 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => { const messageStore = new Map() /** - * Pipe JSON payload to an entire hooks command. + * Pipe JSON payload to an entire hooks command (async). * Errors are logged but never thrown — plugin failures must not crash OpenCode. */ async function callHook(hookName: string, payload: Record) { @@ -26,6 +26,26 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => { } } + /** + * Synchronous variant for hooks that fire near process exit (turn-end, session-end). + * `opencode run` breaks its event loop on the same session.status idle event that + * triggers turn-end. The async callHook would be killed before completing. + * Bun.spawnSync blocks the event loop, preventing exit until the hook finishes. + */ + function callHookSync(hookName: string, payload: Record) { + try { + const json = JSON.stringify(payload) + Bun.spawnSync(["sh", "-c", `${ENTIRE_CMD} hooks opencode ${hookName}`], { + cwd: directory, + stdin: new TextEncoder().encode(json + "\n"), + stdout: "ignore", + stderr: "ignore", + }) + } catch { + // Silently ignore — plugin failures must not crash OpenCode + } + } + return { event: async ({ event }) => { switch (event.type) { @@ -71,11 +91,16 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => { break } - case "session.idle": { - const sessionID = (event as any).properties?.sessionID + case "session.status": { + // session.status fires in both TUI and non-interactive (run) mode. + // session.idle is deprecated and not reliably emitted in run mode. + const props = (event as any).properties + if (props?.status?.type !== "idle") break + const sessionID = props?.sessionID if (!sessionID) break - // Go hook handler will call `opencode export` to get the transcript - await callHook("turn-end", { + // Use sync variant: `opencode run` exits on the same idle event, + // so an async hook would be killed before completing. + callHookSync("turn-end", { session_id: sessionID, }) break @@ -96,7 +121,8 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => { seenUserMessages.clear() messageStore.clear() currentSessionID = null - await callHook("session-end", { + // Use sync variant: session-end may fire during shutdown. + callHookSync("session-end", { session_id: session.id, }) break diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index 89fd8c191..62efcbd13 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -19,6 +19,9 @@ const AgentNameClaudeCode = "claude-code" // AgentNameGemini is the name for Gemini CLI agent. const AgentNameGemini = "gemini" +// AgentNameOpenCode is the name for OpenCode agent. +const AgentNameOpenCode = "opencode" + // AgentRunner abstracts invoking a coding agent for e2e tests. // This follows the multi-agent pattern from cmd/entire/cli/agent/agent.go. type AgentRunner interface { @@ -58,6 +61,8 @@ func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { return NewClaudeCodeRunner(config) case AgentNameGemini: return NewGeminiCLIRunner(config) + case AgentNameOpenCode: + return NewOpenCodeRunner(config) default: // Return a runner that reports as unavailable return &unavailableRunner{name: name} @@ -324,3 +329,107 @@ func (r *GeminiCLIRunner) RunPromptWithTools(ctx context.Context, workDir string result.ExitCode = 0 return result, nil } + +// OpenCodeRunner implements AgentRunner for OpenCode. +// See: https://opencode.ai/docs/cli/ +type OpenCodeRunner struct { + Model string + Timeout time.Duration +} + +// NewOpenCodeRunner creates a new OpenCode runner with the given config. +func NewOpenCodeRunner(config AgentRunnerConfig) *OpenCodeRunner { + model := config.Model + if model == "" { + model = os.Getenv("E2E_OPENCODE_MODEL") + // No default model - OpenCode uses whatever is configured in the project + } + + timeout := config.Timeout + if timeout == 0 { + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if parsed, err := time.ParseDuration(envTimeout); err == nil { + timeout = parsed + } + } + if timeout == 0 { + timeout = 2 * time.Minute + } + } + + return &OpenCodeRunner{ + Model: model, + Timeout: timeout, + } +} + +func (r *OpenCodeRunner) Name() string { + return AgentNameOpenCode +} + +// IsAvailable checks if OpenCode CLI is installed and responds to --version. +func (r *OpenCodeRunner) IsAvailable() (bool, error) { + if _, err := exec.LookPath("opencode"); err != nil { + return false, fmt.Errorf("opencode CLI not found in PATH: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("opencode CLI not working: %w", err) + } + + return true, nil +} + +func (r *OpenCodeRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, nil) +} + +func (r *OpenCodeRunner) RunPromptWithTools(ctx context.Context, workDir string, prompt string, _ []string) (*AgentResult, error) { + // Build command: opencode run "" + // In non-interactive mode, all permissions are auto-approved. + args := []string{"run"} + + if r.Model != "" { + args = append(args, "--model", r.Model) + } + + args = append(args, prompt) + + ctx, cancel := context.WithTimeout(ctx, r.Timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", args...) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + result := &AgentResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: duration, + } + + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + //nolint:wrapcheck // error is from exec.Run, caller can check ExitCode in result + return result, err + } + + result.ExitCode = 0 + return result, nil +} diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go index 0b9280d86..bf03d6aaf 100644 --- a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go @@ -1256,7 +1256,7 @@ Create only this file.` // No new checkpoint should have been created checkpointsAfter := env.GetAllCheckpointIDsFromHistory() - assert.Equal(t, len(checkpointsBefore), len(checkpointsAfter), + assert.Len(t, checkpointsAfter, len(checkpointsBefore), "No new checkpoint should have been created when trailer is removed") } @@ -1295,6 +1295,6 @@ Create only this file.` // This commit should NOT have a new checkpoint — the session is depleted // (all agent files were already committed) and the new edit is purely manual. allCheckpointIDs := env.GetAllCheckpointIDsFromHistory() - assert.Equal(t, len(checkpointIDs), len(allCheckpointIDs), + assert.Len(t, allCheckpointIDs, len(checkpointIDs), "Manual edit after session depletion should not create a new checkpoint") } diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index aaed018b0..5eb534a0a 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -77,6 +77,20 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/e2e-test") + // OpenCode's `run` (non-interactive) mode defaults external_directory permission + // to "ask", which auto-rejects since there's no user to prompt — even when the + // path is the project directory itself. Override to "allow" for the test repo. + // Note: specific path patterns don't work here because OpenCode evaluates the + // catch-all "ask" rule before specific "allow" rules (known issue). + if defaultAgent == AgentNameOpenCode { + env.WriteFile("opencode.json", `{ + "permission": { + "external_directory": "allow" + } +} +`) + } + // Use `entire enable` to set up everything (hooks, settings, etc.) // This sets up .entire/settings.json and .claude/settings.json with hooks env.RunEntireEnable(strategyName) @@ -222,7 +236,6 @@ func (env *TestEnv) GitAdd(paths ...string) { func (env *TestEnv) GitAddAll() { env.T.Helper() - //nolint:gosec // test code, "." is safe cmd := exec.Command("git", "add", ".") cmd.Dir = env.RepoDir if output, err := cmd.CombinedOutput(); err != nil { diff --git a/mise.toml b/mise.toml index cfaa903b8..c58b3e6ac 100644 --- a/mise.toml +++ b/mise.toml @@ -243,3 +243,7 @@ run = "E2E_AGENT=claude-code go test -tags=e2e -count=1 -timeout=30m -v ./cmd/en [tasks."test:e2e:gemini"] description = "Run E2E tests with Gemini CLI (sequential to avoid rate limits)" run = "E2E_AGENT=gemini go test -tags=e2e -count=1 -parallel 1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:opencode"] +description = "Run E2E tests with OpenCode" +run = "E2E_AGENT=opencode go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." From 6c5777de5f54ff27a4cd541b488b9ac8ce124f7f Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 23 Feb 2026 19:26:49 +0100 Subject: [PATCH 2/4] multiple fixes to make e2e test work for opencode Entire-Checkpoint: a054e0b1c569 --- .../e2e_test/resume_relocated_repo_test.go | 99 ++++++++++++------- cmd/entire/cli/e2e_test/testenv.go | 2 + 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go index f1d6d16b9..c012fa302 100644 --- a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go +++ b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go @@ -4,11 +4,16 @@ package e2e import ( "os" + "os/exec" "path/filepath" + "regexp" "strings" "testing" - "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent" + _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // Register claude-code agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // Register gemini agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" // Register opencode agent "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -54,13 +59,13 @@ func TestE2E_ResumeInRelocatedRepo(t *testing.T) { require.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") t.Logf("Checkpoint ID: %s", checkpointID) - // Compute the expected session directories for original and new locations. - // Claude stores transcripts at ~/.claude/projects// - home, err := os.UserHomeDir() - require.NoError(t, err) + // Get the agent to compute session directories (agent-agnostic) + agentInstance, err := agent.Get(agent.AgentName(defaultAgent)) + require.NoError(t, err, "should be able to get agent instance") - originalProjectDir := claudecode.SanitizePathForClaude(originalDir) - originalSessionDir := filepath.Join(home, ".claude", "projects", originalProjectDir) + // Compute the expected session directories for original and new locations. + originalSessionDir, err := agentInstance.GetSessionDir(originalDir) + require.NoError(t, err, "should compute original session dir") // Step 5: Move the repository to a new location t.Log("Step 5: Moving repository to new location") @@ -77,12 +82,12 @@ func TestE2E_ResumeInRelocatedRepo(t *testing.T) { require.True(t, os.IsNotExist(err), "original location should not exist after move") t.Logf("Moved repo to: %s", newDir) - newProjectDir := claudecode.SanitizePathForClaude(newDir) - newSessionDir := filepath.Join(home, ".claude", "projects", newProjectDir) + newSessionDir, err := agentInstance.GetSessionDir(newDir) + require.NoError(t, err, "should compute new session dir") - // Sanity check: the two project dirs must be different - require.NotEqual(t, originalProjectDir, newProjectDir, - "sanitized project dirs should differ for different repo paths") + // Sanity check: the two session dirs must be different + require.NotEqual(t, originalSessionDir, newSessionDir, + "session dirs should differ for different repo paths") t.Logf("Original session dir: %s", originalSessionDir) t.Logf("New session dir: %s", newSessionDir) @@ -103,33 +108,59 @@ func TestE2E_ResumeInRelocatedRepo(t *testing.T) { output := newEnv.RunCLI("resume", "feature/e2e-test", "--force") t.Logf("Resume output:\n%s", output) - // Step 8: Verify the transcript was written to the NEW session directory + // Step 8: Verify the session was restored at the new location t.Log("Step 8: Verifying session was created at new location") - // The resume output should reference the new session dir, not the old one - assert.Contains(t, output, newProjectDir, - "resume output should reference the new project directory") - assert.NotContains(t, output, originalProjectDir, - "resume output should NOT reference the old project directory") - - // Verify the new session dir was actually created with files - newDirEntries, err := os.ReadDir(newSessionDir) - require.NoError(t, err, "new session directory should exist after resume") - require.NotEmpty(t, newDirEntries, "new session directory should contain files") - t.Logf("New session dir contains %d entries", len(newDirEntries)) - - // Verify at least one JSONL transcript file exists - hasTranscript := false - for _, entry := range newDirEntries { - if strings.HasSuffix(entry.Name(), ".jsonl") { - info, statErr := entry.Info() - if statErr == nil && info.Size() > 0 { - hasTranscript = true - t.Logf("Found transcript: %s (%d bytes)", entry.Name(), info.Size()) + // The resume output should reference the new session dir path, not the old one. + newSessionDirBase := filepath.Base(newSessionDir) + originalSessionDirBase := filepath.Base(originalSessionDir) + assert.Contains(t, output, newSessionDirBase, + "resume output should reference the new session directory") + if newSessionDirBase != originalSessionDirBase { + assert.NotContains(t, output, originalSessionDirBase, + "resume output should NOT reference the old session directory") + } + + // Verification differs by agent: + // - Claude Code: writes transcript files to session directory + // - OpenCode: imports session into its database (no files in session dir) + if defaultAgent == AgentNameOpenCode { + // For OpenCode, extract session ID from output and verify it exists in the database + // Output format: "Session: ses_xxxxx" + sessionIDRegex := regexp.MustCompile(`Session: (ses_[a-zA-Z0-9]+)`) + matches := sessionIDRegex.FindStringSubmatch(output) + require.NotEmpty(t, matches, "resume output should contain session ID") + sessionID := matches[1] + t.Logf("Extracted session ID: %s", sessionID) + + // Verify session exists in OpenCode's database via `opencode session list` + listCmd := exec.Command("opencode", "session", "list") + listCmd.Dir = newDir + listOutput, listErr := listCmd.CombinedOutput() + require.NoError(t, listErr, "opencode session list should succeed") + assert.Contains(t, string(listOutput), sessionID, + "session should exist in OpenCode's database after resume") + t.Logf("OpenCode session list output:\n%s", string(listOutput)) + } else { + // For Claude Code and others, verify files in session directory + newDirEntries, err := os.ReadDir(newSessionDir) + require.NoError(t, err, "new session directory should exist after resume") + require.NotEmpty(t, newDirEntries, "new session directory should contain files") + t.Logf("New session dir contains %d entries", len(newDirEntries)) + + // Verify at least one transcript file exists + hasTranscript := false + for _, entry := range newDirEntries { + if strings.HasSuffix(entry.Name(), ".jsonl") || strings.HasSuffix(entry.Name(), ".json") { + info, statErr := entry.Info() + if statErr == nil && info.Size() > 0 { + hasTranscript = true + t.Logf("Found transcript: %s (%d bytes)", entry.Name(), info.Size()) + } } } + assert.True(t, hasTranscript, "new session directory should contain a non-empty transcript file") } - assert.True(t, hasTranscript, "new session directory should contain a non-empty JSONL transcript") // Step 9: Verify the OLD session directory was NOT created by resume // (It may exist from Step 1's agent run, so check that resume didn't write to it diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 5eb534a0a..521028f07 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -82,8 +82,10 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { // path is the project directory itself. Override to "allow" for the test repo. // Note: specific path patterns don't work here because OpenCode evaluates the // catch-all "ask" rule before specific "allow" rules (known issue). + // Include $schema to prevent OpenCode from modifying the file when it runs. if defaultAgent == AgentNameOpenCode { env.WriteFile("opencode.json", `{ + "$schema": "https://opencode.ai/config.json", "permission": { "external_directory": "allow" } From a5d510ee964946b20808362e3bd64a3845f28d3e Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Tue, 24 Feb 2026 11:02:48 +1100 Subject: [PATCH 3/4] use haiku + set anthropic provider api key --- cmd/entire/cli/e2e_test/agent_runner.go | 4 +++- cmd/entire/cli/e2e_test/testenv.go | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index 62efcbd13..7c46a8234 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -342,7 +342,9 @@ func NewOpenCodeRunner(config AgentRunnerConfig) *OpenCodeRunner { model := config.Model if model == "" { model = os.Getenv("E2E_OPENCODE_MODEL") - // No default model - OpenCode uses whatever is configured in the project + if model == "" { + model = "anthropic/claude-haiku-4-5" + } } timeout := config.Timeout diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 521028f07..5585783c9 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -84,13 +84,23 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { // catch-all "ask" rule before specific "allow" rules (known issue). // Include $schema to prevent OpenCode from modifying the file when it runs. if defaultAgent == AgentNameOpenCode { - env.WriteFile("opencode.json", `{ + opencodeConfig := `{ "$schema": "https://opencode.ai/config.json", "permission": { "external_directory": "allow" - } -} -`) + }` + if os.Getenv("ANTHROPIC_API_KEY") != "" { + opencodeConfig += `, + "provider": { + "anthropic": { + "options": { + "apiKey": "` + os.Getenv("ANTHROPIC_API_KEY") + `" + } + } + }` + } + opencodeConfig += "\n}\n" + env.WriteFile("opencode.json", opencodeConfig) } // Use `entire enable` to set up everything (hooks, settings, etc.) From 41995a93449af1aa984ef1d96fa923cc7bac1096 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Tue, 24 Feb 2026 11:09:16 +1100 Subject: [PATCH 4/4] run opencode in parallel on github actions --- .github/workflows/e2e.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7edb5f579..188d7ab1f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,10 @@ jobs: e2e-tests: runs-on: ubuntu-latest timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + agent: [claude, opencode] steps: - name: Checkout repository @@ -24,18 +28,16 @@ jobs: - name: Setup mise uses: jdx/mise-action@v3 - - name: Install Claude CLI + - name: Install agent CLI run: | - echo "Installing Claude Code CLI..." - curl -fsSL https://claude.ai/install.sh | bash + case "${{ matrix.agent }}" in + claude) curl -fsSL https://claude.ai/install.sh | bash ;; + opencode) curl -fsSL https://opencode.ai/install | bash ;; + esac echo "$HOME/.local/bin" >> $GITHUB_PATH - - name: Verify Claude CLI installation - run: | - claude --version - - name: Run E2E Tests env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | - mise run test:e2e:claude + mise run test:e2e:${{ matrix.agent }}