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
30 changes: 30 additions & 0 deletions cmd/entire/cli/agent/opencode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
Expand Down Expand Up @@ -110,6 +111,35 @@ func (a *OpenCodeAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent
}
}

// PrepareTranscript ensures the OpenCode transcript file is up-to-date by calling `opencode export`.
// OpenCode's transcript is created/updated via `opencode export`, but condensation may need fresh
// data mid-turn (e.g., during mid-turn commits or resumed sessions where the cached file is stale).
// This method always refreshes the transcript to ensure the latest agent activity is captured.
func (a *OpenCodeAgent) PrepareTranscript(sessionRef string) error {
// Validate the session ref path
if _, err := os.Stat(sessionRef); err != nil && !os.IsNotExist(err) {
// Permission denied, broken symlink, or other non-recoverable errors
return fmt.Errorf("failed to stat OpenCode transcript path %s: %w", sessionRef, err)
}

// Extract session ID from path: basename without .json extension
base := filepath.Base(sessionRef)
if !strings.HasSuffix(base, ".json") {
return fmt.Errorf("invalid OpenCode transcript path (expected .json): %s", sessionRef)
}
sessionID := strings.TrimSuffix(base, ".json")
if sessionID == "" {
return fmt.Errorf("empty session ID in transcript path: %s", sessionRef)
}

// Always call fetchAndCacheExport to get fresh transcript data.
// This is critical for resumed sessions where the cached file may contain stale data
// from a previous turn. Unlike turn-end (which always runs export), mid-turn commits
// need to refresh the transcript to capture agent activity since the last export.
_, err := a.fetchAndCacheExport(sessionID)
return err
}

// fetchAndCacheExport calls `opencode export <sessionID>` and writes the result
// to a temporary file. Returns the path to the temp file.
//
Expand Down
96 changes: 96 additions & 0 deletions cmd/entire/cli/agent/opencode/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package opencode

import (
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -203,3 +205,97 @@ func TestHookNames(t *testing.T) {
}
}
}

func TestPrepareTranscript_AlwaysRefreshesTranscript(t *testing.T) {
t.Parallel()

// PrepareTranscript should always call fetchAndCacheExport to get fresh data,
// even when the file exists. This ensures resumed sessions get updated transcripts.
// In production, fetchAndCacheExport calls `opencode export`.
// In mock mode (ENTIRE_TEST_OPENCODE_MOCK_EXPORT=1), it reads from .entire/tmp/.
// Without mock mode and without opencode CLI, it will fail - which is expected.

tmpDir := t.TempDir()
transcriptPath := filepath.Join(tmpDir, "sess-123.json")

// Create an existing file with stale data
if err := os.WriteFile(transcriptPath, []byte(`{"info":{},"messages":[]}`), 0o600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}

ag := &OpenCodeAgent{}
err := ag.PrepareTranscript(transcriptPath)

// Without ENTIRE_TEST_OPENCODE_MOCK_EXPORT and without opencode CLI installed,
// PrepareTranscript will fail because fetchAndCacheExport can't run `opencode export`.
// This is expected behavior - the point is that it TRIES to refresh, not that it no-ops.
if err == nil {
// If no error, either opencode CLI is installed or mock mode is enabled
t.Log("PrepareTranscript succeeded (opencode CLI available or mock mode enabled)")
} else {
// Expected: fails because we're not in mock mode and opencode CLI isn't installed
t.Logf("PrepareTranscript attempted refresh and failed (expected without opencode CLI): %v", err)
}
}

func TestPrepareTranscript_ErrorOnInvalidPath(t *testing.T) {
t.Parallel()

ag := &OpenCodeAgent{}

// Path without .json extension
err := ag.PrepareTranscript("/tmp/not-a-json-file")
if err == nil {
t.Fatal("expected error for path without .json extension")
}
if !strings.Contains(err.Error(), "invalid OpenCode transcript path") {
t.Errorf("expected 'invalid OpenCode transcript path' error, got: %v", err)
}
}

func TestPrepareTranscript_ErrorOnBrokenSymlink(t *testing.T) {
t.Parallel()

// Create a broken symlink to test non-IsNotExist error handling
tmpDir := t.TempDir()
transcriptPath := filepath.Join(tmpDir, "broken-link.json")

// Create symlink pointing to non-existent target
if err := os.Symlink("/nonexistent/target", transcriptPath); err != nil {
t.Skipf("cannot create symlink (permission denied?): %v", err)
}

ag := &OpenCodeAgent{}
err := ag.PrepareTranscript(transcriptPath)

// Broken symlinks cause os.Stat to return a specific error (not IsNotExist).
// The function should return a wrapped error explaining the issue.
// Note: On some systems, symlink to nonexistent target returns IsNotExist,
// so we accept either behavior here.
switch {
case err != nil && strings.Contains(err.Error(), "failed to stat OpenCode transcript path"):
// Good: proper error handling for broken symlink
t.Logf("Got expected stat error for broken symlink: %v", err)
case err != nil:
// Also acceptable: fetchAndCacheExport fails for other reasons
t.Logf("Got error (acceptable): %v", err)
default:
// Unexpected: should have gotten an error
t.Log("No error returned - symlink may have been treated as non-existent")
}
}

func TestPrepareTranscript_ErrorOnEmptySessionID(t *testing.T) {
t.Parallel()

ag := &OpenCodeAgent{}

// Path with empty session ID (.json with no basename)
err := ag.PrepareTranscript("/tmp/.json")
if err == nil {
t.Fatal("expected error for empty session ID")
}
if !strings.Contains(err.Error(), "empty session ID") {
t.Errorf("expected 'empty session ID' error, got: %v", err)
}
}
1 change: 1 addition & 0 deletions cmd/entire/cli/agent/opencode/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
// Compile-time interface assertions
var (
_ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil)
_ agent.TranscriptPreparer = (*OpenCodeAgent)(nil)
_ agent.TokenCalculator = (*OpenCodeAgent)(nil)
)

Expand Down
21 changes: 21 additions & 0 deletions cmd/entire/cli/integration_test/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1016,3 +1016,24 @@ func (env *TestEnv) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string)
runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T)
return runner.SimulateOpenCodeSessionEnd(sessionID, transcriptPath)
}

// CopyTranscriptToEntireTmp copies an OpenCode transcript to .entire/tmp/<sessionID>.json.
// This simulates what `opencode export` does in production. Required for mid-turn commits
// where PrepareTranscript calls fetchAndCacheExport, which in mock mode expects the file
// to already exist at .entire/tmp/<sessionID>.json.
func (env *TestEnv) CopyTranscriptToEntireTmp(sessionID, transcriptPath string) {
env.T.Helper()

srcData, err := os.ReadFile(transcriptPath)
if err != nil {
env.T.Fatalf("CopyTranscriptToEntireTmp: failed to read transcript from %q: %v", transcriptPath, err)
}
destDir := filepath.Join(env.RepoDir, ".entire", "tmp")
if err := os.MkdirAll(destDir, 0o755); err != nil {
env.T.Fatalf("CopyTranscriptToEntireTmp: failed to create directory %q: %v", destDir, err)
}
destPath := filepath.Join(destDir, sessionID+".json")
if err := os.WriteFile(destPath, srcData, 0o644); err != nil {
env.T.Fatalf("CopyTranscriptToEntireTmp: failed to write transcript to %q: %v", destPath, err)
}
}
69 changes: 69 additions & 0 deletions cmd/entire/cli/integration_test/opencode_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,75 @@ func TestOpenCodeMultiTurnCondensation(t *testing.T) {
})
}

// TestOpenCodeMidTurnCommit verifies that when OpenCode's agent commits mid-turn
// (before turn-end), the commit gets an Entire-Checkpoint trailer AND the checkpoint
// data is written to entire/checkpoints/v1.
//
// This tests the PrepareTranscript fix: OpenCode's transcript file is created lazily
// at turn-end via `opencode export`. When a commit happens mid-turn, PrepareTranscript
// is called to create the transcript on-demand so condensation can read it.
func TestOpenCodeMidTurnCommit(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit)
env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode)

session := env.NewOpenCodeSession()

// 1. session-start
if err := env.SimulateOpenCodeSessionStart(session.ID, session.TranscriptPath); err != nil {
t.Fatalf("session-start error: %v", err)
}

// 2. turn-start (session becomes ACTIVE)
if err := env.SimulateOpenCodeTurnStart(session.ID, session.TranscriptPath, "Add a script and commit it"); err != nil {
t.Fatalf("turn-start error: %v", err)
}

// 3. Agent creates file
env.WriteFile("script.sh", "#!/bin/bash\necho hello")

// 4. Create transcript reflecting the file change.
session.CreateOpenCodeTranscript("Add a script and commit it", []FileChange{
{Path: "script.sh", Content: "#!/bin/bash\necho hello"},
})

// 5. Copy transcript to .entire/tmp/ where PrepareTranscript will find it.
// In production, `opencode export` refreshes this file on each call.
// In tests, ENTIRE_TEST_OPENCODE_MOCK_EXPORT makes fetchAndCacheExport
// read from the pre-written file at .entire/tmp/<sessionID>.json.
// PrepareTranscript ALWAYS calls fetchAndCacheExport (even if file exists)
// to ensure fresh data for resumed sessions.
env.CopyTranscriptToEntireTmp(session.ID, session.TranscriptPath)

// 6. Agent commits mid-turn (no turn-end yet!)
// This triggers: PrepareCommitMsg (adds trailer) → PostCommit (runs condensation)
// Condensation needs the transcript, which PrepareTranscript should provide.
env.GitCommitWithShadowHooksAsAgent("Add script", "script.sh")

// 7. Verify commit has checkpoint trailer
commitHash := env.GetHeadHash()
checkpointID := env.GetCheckpointIDFromCommitMessage(commitHash)
if checkpointID == "" {
t.Fatal("mid-turn agent commit should have Entire-Checkpoint trailer")
}
t.Logf("Mid-turn commit has checkpoint ID: %s", checkpointID)

// 8. CRITICAL: Verify checkpoint data was written to entire/checkpoints/v1
transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName)
_, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath)
if !found {
t.Error("checkpoint transcript should exist on metadata branch after mid-turn commit")
}

// 9. Validate checkpoint metadata
env.ValidateCheckpoint(CheckpointValidation{
CheckpointID: checkpointID,
Strategy: strategy.StrategyNameManualCommit,
FilesTouched: []string{"script.sh"},
})
}

// TestOpenCodeResumedSessionAfterCommit verifies that resuming an OpenCode session
// after a commit correctly creates a checkpoint for the second turn.
//
Expand Down
19 changes: 19 additions & 0 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -1494,3 +1494,22 @@ func IsOnDefaultBranch(repo *git.Repository) (bool, string) {

return currentBranch == defaultBranch, currentBranch
}

// prepareTranscriptIfNeeded calls PrepareTranscript for agents that implement
// the TranscriptPreparer interface. This ensures transcript files exist before
// they are read (e.g., OpenCode creates its transcript lazily via `opencode export`).
// Errors are silently ignored — this is best-effort for hook paths.
func prepareTranscriptIfNeeded(agentType agent.AgentType, transcriptPath string) {
if transcriptPath == "" {
return
}
ag, err := agent.GetByAgentType(agentType)
if err != nil {
return
}
if preparer, ok := ag.(agent.TranscriptPreparer); ok {
// Best-effort: callers handle missing files gracefully.
// Transcript may not be available yet (e.g., agent not installed).
_ = preparer.PrepareTranscript(transcriptPath) //nolint:errcheck // Best-effort in hook path
}
}
4 changes: 4 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
if state.TranscriptPath == "" {
return nil, errors.New("shadow branch not found and no live transcript available")
}
// Ensure transcript file exists (OpenCode creates it lazily via `opencode export`)
prepareTranscriptIfNeeded(state.AgentType, state.TranscriptPath)
sessionData, err = s.extractSessionDataFromLiveTranscript(state)
if err != nil {
return nil, fmt.Errorf("failed to extract session data from live transcript: %w", err)
Expand Down Expand Up @@ -409,6 +411,8 @@ func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRe
// (SaveStep is only called when there are file modifications).
var fullTranscript string
if liveTranscriptPath != "" {
// Ensure transcript file exists (OpenCode creates it lazily via `opencode export`)
prepareTranscriptIfNeeded(agentType, liveTranscriptPath)
if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 { //nolint:gosec // path from session state
fullTranscript = string(liveData)
}
Expand Down
26 changes: 26 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,19 @@ func (s *ManualCommitStrategy) extractNewModifiedFilesFromLiveTranscript(state *
return nil, false
}

// Ensure transcript file is up-to-date (OpenCode creates/refreshes it via `opencode export`).
// Use the already-resolved agent to avoid a redundant lookup.
if preparer, ok := ag.(agent.TranscriptPreparer); ok {
if prepErr := preparer.PrepareTranscript(state.TranscriptPath); prepErr != nil {
logging.Debug(logCtx, "prepare transcript failed",
slog.String("session_id", state.SessionID),
slog.String("agent_type", string(state.AgentType)),
slog.String("transcript_path", state.TranscriptPath),
slog.Any("error", prepErr),
)
}
}

analyzer, ok := ag.(agent.TranscriptAnalyzer)
if !ok {
return nil, false
Expand Down Expand Up @@ -1181,6 +1194,19 @@ func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(state *Ses
return nil
}

// Ensure transcript file is up-to-date (OpenCode creates/refreshes it via `opencode export`).
// Use the already-resolved agent to avoid a redundant lookup.
if preparer, ok := ag.(agent.TranscriptPreparer); ok {
if prepErr := preparer.PrepareTranscript(state.TranscriptPath); prepErr != nil {
logging.Debug(logCtx, "prepare transcript failed",
slog.String("session_id", state.SessionID),
slog.String("agent_type", string(state.AgentType)),
slog.String("transcript_path", state.TranscriptPath),
slog.Any("error", prepErr),
)
}
}

analyzer, ok := ag.(agent.TranscriptAnalyzer)
if !ok {
return nil
Expand Down