Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
38 changes: 32 additions & 6 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => {
const messageStore = new Map<string, any>()

/**
* 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<string, unknown>) {
Expand All @@ -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<string, unknown>) {
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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
111 changes: 111 additions & 0 deletions cmd/entire/cli/e2e_test/agent_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -324,3 +329,109 @@ 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")
if model == "" {
model = "anthropic/claude-haiku-4-5"
}
}

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 "<prompt>"
// 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
}
99 changes: 65 additions & 34 deletions cmd/entire/cli/e2e_test/resume_relocated_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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/<sanitized-repo-path>/
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")
Expand All @@ -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)

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")
}
Loading
Loading