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
23 changes: 23 additions & 0 deletions cmd/entire/cli/agent/opencode/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os/exec"
"strings"
"time"
)

Expand Down Expand Up @@ -34,6 +35,28 @@ func runOpenCodeExport(sessionID string) ([]byte, error) {
return output, nil
}

// runOpenCodeSessionDelete runs `opencode session delete <sessionID>` to remove
// a session from OpenCode's database. Returns nil on success or if the session
// doesn't exist (nothing to delete).
func runOpenCodeSessionDelete(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, "opencode", "session", "delete", sessionID)
if output, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout)
}
// "Session not found" means the session doesn't exist — nothing to delete.
if strings.Contains(strings.ToLower(string(output)), "session not found") {
return nil
}
return fmt.Errorf("opencode session delete failed: %w (output: %s)", err, string(output))
}

return nil
}

// runOpenCodeImport runs `opencode import <file>` to import a session into
// OpenCode's database. The import preserves the original session ID
// from the export file.
Expand Down
45 changes: 13 additions & 32 deletions cmd/entire/cli/agent/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@
package opencode

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

Expand Down Expand Up @@ -200,7 +203,6 @@ func (a *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession
SessionID: input.SessionID,
SessionRef: input.SessionRef,
NativeData: data,
ExportData: data, // Export JSON is both native and export format
ModifiedFiles: modifiedFiles,
}, nil
}
Expand All @@ -209,52 +211,31 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error {
if session == nil {
return errors.New("nil session")
}
if session.SessionRef == "" {
return errors.New("no session ref to write to")
}
if len(session.NativeData) == 0 {
return errors.New("no session data to write")
}

// 1. Write export JSON file (for Entire's internal checkpoint use)
dir := filepath.Dir(session.SessionRef)
//nolint:gosec // G301: Session directory needs standard permissions
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
}
if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil {
return fmt.Errorf("failed to write session data: %w", err)
}

// 2. If we have export data, import the session into OpenCode.
// This enables `opencode -s <id>` for both resume and rewind.
if len(session.ExportData) == 0 {
return nil // No export data — skip import (graceful degradation)
}

if err := a.importSessionIntoOpenCode(session.SessionID, session.ExportData); err != nil {
// Non-fatal: import is best-effort. The JSONL file is written,
// and the user can always run `opencode import <file>` manually.
fmt.Fprintf(os.Stderr, "warning: could not import session into OpenCode: %v\n", err)
}

return nil
// Import the session into OpenCode's database.
// This enables `opencode -s <id>` for both resume and rewind.
return a.importSessionIntoOpenCode(session.SessionID, session.NativeData)
}

// importSessionIntoOpenCode writes the export JSON to a temp file and runs
// `opencode import` to restore the session into OpenCode's database.
// For rewind (session already exists), the session is deleted first so the
// reimport replaces it with the checkpoint-state messages.
func (a *OpenCodeAgent) importSessionIntoOpenCode(sessionID string, exportData []byte) error {
// Delete existing messages first so reimport replaces them cleanly.
// Delete existing session first so reimport replaces it cleanly.
// opencode import uses ON CONFLICT DO NOTHING, so existing messages
// would be skipped without this step (breaking rewind).
// Uses direct SQLite delete since OpenCode CLI has no session delete command.
if err := deleteMessagesFromSQLite(sessionID); err != nil {
// Non-fatal: DB might not exist yet (first session), or sqlite3 not installed.
if err := runOpenCodeSessionDelete(sessionID); err != nil {
// Non-fatal: session might not exist yet (first session).
// Import will still work for new sessions; only rewind of existing sessions
// would have stale messages.
fmt.Fprintf(os.Stderr, "warning: could not clear existing messages: %v\n", err)
logging.Warn(context.Background(), "could not delete existing opencode session",
slog.String("session_id", sessionID),
slog.String("error", err.Error()),
)
}

// Write export JSON to a temp file for opencode import
Expand Down
78 changes: 0 additions & 78 deletions cmd/entire/cli/agent/opencode/sqlite.go

This file was deleted.

6 changes: 0 additions & 6 deletions cmd/entire/cli/agent/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,6 @@ type AgentSession struct {
// - Aider: Markdown content
NativeData []byte

// ExportData holds the session in the agent's native export/import format.
// Used by agents whose primary storage isn't file-based (e.g., OpenCode uses SQLite).
// At resume/rewind time, this data is imported back into the agent's storage.
// Optional — nil for agents where NativeData is sufficient (Claude, Gemini).
ExportData []byte

// Computed fields - populated by the agent when reading
ModifiedFiles []string
NewFiles []string
Expand Down
34 changes: 5 additions & 29 deletions cmd/entire/cli/integration_test/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1036,40 +1036,16 @@ func TestOpenCodeSessionOperations(t *testing.T) {
}
})

t.Run("WriteSession writes NativeData to file", func(t *testing.T) {
t.Run("WriteSession validates input", func(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
env.InitRepo()

ag, _ := agent.Get("opencode")

// First read a session
srcPath := filepath.Join(env.RepoDir, "src.json")
srcContent := `{"info": {"id": "test"}, "messages": [{"info": {"id": "msg-1", "role": "user", "time": {"created": 1708300000}}, "parts": [{"type": "text", "text": "hello"}]}]}`
if err := os.WriteFile(srcPath, []byte(srcContent), 0o644); err != nil {
t.Fatalf("failed to write source: %v", err)
}

session, _ := ag.ReadSession(&agent.HookInput{
SessionID: "test",
SessionRef: srcPath,
})

// Write to a new location
dstPath := filepath.Join(env.RepoDir, "dst.json")
session.SessionRef = dstPath

if err := ag.WriteSession(session); err != nil {
t.Fatalf("WriteSession() error = %v", err)
}

// Verify file was written
data, err := os.ReadFile(dstPath)
if err != nil {
t.Fatalf("failed to read destination: %v", err)
if err := ag.WriteSession(nil); err == nil {
t.Error("WriteSession(nil) should error")
}
if string(data) != srcContent {
t.Errorf("written content = %q, want %q", string(data), srcContent)
if err := ag.WriteSession(&agent.AgentSession{}); err == nil {
t.Error("WriteSession with empty NativeData should error")
}
})
}
Expand Down
4 changes: 0 additions & 4 deletions cmd/entire/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,16 +537,12 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string,
return fmt.Errorf("failed to create session directory: %w", err)
}

// Create an AgentSession with the native data.
// The transcript IS the export data — for agents that need import (OpenCode),
// WriteSession uses ExportData; for others (Claude, Gemini), it's ignored.
agentSession := &agent.AgentSession{
SessionID: sessionID,
AgentName: ag.Name(),
RepoPath: repoRoot,
SessionRef: sessionLogPath,
NativeData: logContent,
ExportData: logContent,
}

// Write the session using the agent's WriteSession method
Expand Down
9 changes: 0 additions & 9 deletions cmd/entire/cli/rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,10 +680,6 @@ func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string
sessionID = returnedSessionID
}

// Use WriteSession which handles both file writing and native storage import
// (e.g., SQLite for OpenCode). The transcript IS the export data — for agents
// that need import (OpenCode), WriteSession uses ExportData; for others
// (Claude, Gemini), WriteSession ignores it and just writes NativeData.
sessionFile, err := resolveTranscriptPath(sessionID, agent)
if err != nil {
return "", err
Expand All @@ -696,7 +692,6 @@ func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string
AgentName: agent.Name(),
SessionRef: sessionFile,
NativeData: content,
ExportData: content,
}
if err := agent.WriteSession(agentSession); err != nil {
return "", fmt.Errorf("failed to write session: %w", err)
Expand Down Expand Up @@ -726,9 +721,6 @@ func restoreSessionTranscriptFromShadow(commitHash, metadataDir, sessionID strin
return "", fmt.Errorf("failed to get transcript from shadow branch: %w", err)
}

// Use WriteSession which handles both file writing and native storage import.
// The transcript IS the export data — for agents that need import (OpenCode),
// WriteSession uses ExportData; for others (Claude, Gemini), it's ignored.
sessionFile, err := resolveTranscriptPath(sessionID, agent)
if err != nil {
return "", err
Expand All @@ -741,7 +733,6 @@ func restoreSessionTranscriptFromShadow(commitHash, metadataDir, sessionID strin
AgentName: agent.Name(),
SessionRef: sessionFile,
NativeData: content,
ExportData: content,
}
if err := agent.WriteSession(agentSession); err != nil {
return "", fmt.Errorf("failed to write session: %w", err)
Expand Down
1 change: 0 additions & 1 deletion cmd/entire/cli/strategy/manual_commit_rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,6 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(point RewindPoint, force bool) ([
RepoPath: repoRoot,
SessionRef: sessionFile,
NativeData: content.Transcript,
ExportData: content.Transcript,
}
if writeErr := sessionAgent.WriteSession(agentSession); writeErr != nil {
if totalSessions > 1 {
Expand Down