From 42d2aac571b12dc3ba17e900f58624f16ebc68ed Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 18 Feb 2026 18:42:44 -0800 Subject: [PATCH 1/7] Add OpenCode agent support --- .../cli/agent/opencode/entire_plugin.ts | 222 ++++++++++++++++ cmd/entire/cli/agent/opencode/hooks.go | 124 +++++++++ cmd/entire/cli/agent/opencode/hooks_test.go | 175 ++++++++++++ cmd/entire/cli/agent/opencode/lifecycle.go | 107 ++++++++ .../cli/agent/opencode/lifecycle_test.go | 189 +++++++++++++ cmd/entire/cli/agent/opencode/opencode.go | 216 +++++++++++++++ cmd/entire/cli/agent/opencode/plugin.go | 9 + cmd/entire/cli/agent/opencode/transcript.go | 234 ++++++++++++++++ .../cli/agent/opencode/transcript_test.go | 250 ++++++++++++++++++ cmd/entire/cli/agent/opencode/types.go | 84 ++++++ cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/explain.go | 19 +- cmd/entire/cli/hooks_cmd.go | 1 + cmd/entire/cli/setup.go | 50 +++- cmd/entire/cli/strategy/common.go | 4 + .../strategy/manual_commit_condensation.go | 30 +++ cmd/entire/cli/summarize/summarize.go | 52 ++++ 17 files changed, 1757 insertions(+), 11 deletions(-) create mode 100644 cmd/entire/cli/agent/opencode/entire_plugin.ts create mode 100644 cmd/entire/cli/agent/opencode/hooks.go create mode 100644 cmd/entire/cli/agent/opencode/hooks_test.go create mode 100644 cmd/entire/cli/agent/opencode/lifecycle.go create mode 100644 cmd/entire/cli/agent/opencode/lifecycle_test.go create mode 100644 cmd/entire/cli/agent/opencode/opencode.go create mode 100644 cmd/entire/cli/agent/opencode/plugin.go create mode 100644 cmd/entire/cli/agent/opencode/transcript.go create mode 100644 cmd/entire/cli/agent/opencode/transcript_test.go create mode 100644 cmd/entire/cli/agent/opencode/types.go diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts new file mode 100644 index 000000000..da0328bed --- /dev/null +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -0,0 +1,222 @@ +// Entire CLI plugin for OpenCode +// Auto-generated by `entire enable --agent opencode` +// Do not edit manually — changes will be overwritten on next install. +// Requires Bun runtime (used by OpenCode's plugin system for loading ESM plugins). +import type { Plugin } from "@opencode-ai/plugin" + +export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { + const ENTIRE_CMD = "__ENTIRE_CMD__" + const transcriptDir = `${directory}/.opencode/sessions/entire` + const seenUserMessages = new Set() + + // In-memory stores — used to write transcripts without relying on the SDK API, + // which may be unavailable during shutdown. + // messageStore: keyed by message ID, stores message metadata (role, time, tokens, etc.) + const messageStore = new Map() + // partStore: keyed by message ID, stores accumulated parts from message.part.updated events + const partStore = new Map() + let currentSessionID: string | null = null + + // Ensure transcript directory exists + await $`mkdir -p ${transcriptDir}`.quiet().nothrow() + + /** + * Pipe JSON payload to an entire hooks command. + * Errors are logged but never thrown — plugin failures must not crash OpenCode. + */ + async function callHook(hookName: string, payload: Record) { + try { + const json = JSON.stringify(payload) + await $`echo ${json} | ${ENTIRE_CMD} hooks opencode ${hookName}`.quiet().nothrow() + } catch { + // Silently ignore — plugin failures must not crash OpenCode + } + } + + /** Extract text content from a list of parts. */ + function textFromParts(parts: any[]): string { + return parts + .filter((p: any) => p.type === "text") + .map((p: any) => p.text ?? "") + .join("\n") + } + + /** Format a message for the transcript using its accumulated parts. */ + function formatMessageFromStore(msg: any) { + const parts = partStore.get(msg.id) ?? [] + return { + id: msg.id, + role: msg.role, + content: textFromParts(parts), + time: msg.time, + ...(msg.role === "assistant" ? { + tokens: msg.tokens, + cost: msg.cost, + parts: parts.map((p: any) => ({ + type: p.type, + ...(p.type === "text" ? { text: p.text } : {}), + ...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}), + })), + } : {}), + } + } + + /** Format a message from an API response (which includes parts inline). */ + function formatMessageFromAPI(info: any, parts: any[]) { + return { + id: info.id, + role: info.role, + content: textFromParts(parts), + time: info.time, + ...(info.role === "assistant" ? { + tokens: info.tokens, + cost: info.cost, + parts: parts.map((p: any) => ({ + type: p.type, + ...(p.type === "text" ? { text: p.text } : {}), + ...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}), + })), + } : {}), + } + } + + /** + * Write transcript from in-memory stores (messageStore + partStore). + * This does NOT call the SDK API, so it works even during shutdown. + */ + async function writeTranscriptFromMemory(sessionID: string): Promise { + const transcriptPath = `${transcriptDir}/${sessionID}.json` + try { + const messages = Array.from(messageStore.values()) + .sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0)) + + const transcript = { + session_id: sessionID, + messages: messages.map(formatMessageFromStore), + } + await Bun.write(transcriptPath, JSON.stringify(transcript)) + } catch { + // Silently ignore write failures + } + return transcriptPath + } + + /** + * Try to fetch messages via the SDK API (returns messages with parts inline) + * and write transcript. Falls back to in-memory stores if the API is unavailable. + */ + async function writeTranscriptWithFallback(sessionID: string): Promise { + const transcriptPath = `${transcriptDir}/${sessionID}.json` + try { + const response = await client.session.message.list({ path: { id: sessionID } }) + // API returns Array<{ info: Message, parts: Array }> + const items = response.data ?? [] + + const transcript = { + session_id: sessionID, + messages: items.map((item: any) => formatMessageFromAPI(item.info, item.parts ?? [])), + } + await Bun.write(transcriptPath, JSON.stringify(transcript)) + return transcriptPath + } catch { + // API unavailable (likely shutting down) — fall back to in-memory stores + return writeTranscriptFromMemory(sessionID) + } + } + + return { + event: async ({ event }) => { + switch (event.type) { + case "session.created": { + const session = (event as any).properties?.info + if (!session?.id) break + currentSessionID = session.id + await callHook("session-start", { + session_id: session.id, + transcript_path: `${transcriptDir}/${session.id}.json`, + }) + break + } + + case "message.updated": { + const msg = (event as any).properties?.info + if (!msg) break + // Store message metadata (role, time, tokens, etc.) + // Content is NOT on the message — it arrives via message.part.updated events. + messageStore.set(msg.id, msg) + break + } + + case "message.part.updated": { + const part = (event as any).properties?.part + if (!part?.messageID) break + + // Accumulate parts per message + const existing = partStore.get(part.messageID) ?? [] + // Replace existing part with same id, or append new one + const idx = existing.findIndex((p: any) => p.id === part.id) + if (idx >= 0) { + existing[idx] = part + } else { + existing.push(part) + } + partStore.set(part.messageID, existing) + + // Fire turn-start on the first text part of a new user message + const msg = messageStore.get(part.messageID) + if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) { + seenUserMessages.add(msg.id) + const sessionID = msg.sessionID ?? currentSessionID + if (sessionID) { + await callHook("turn-start", { + session_id: sessionID, + transcript_path: `${transcriptDir}/${sessionID}.json`, + prompt: part.text ?? "", + }) + } + } + break + } + + case "session.idle": { + const sessionID = (event as any).properties?.sessionID + if (!sessionID) break + const transcriptPath = await writeTranscriptWithFallback(sessionID) + await callHook("turn-end", { + session_id: sessionID, + transcript_path: transcriptPath, + }) + break + } + + case "session.compacted": { + const sessionID = (event as any).properties?.sessionID + if (!sessionID) break + await callHook("compaction", { + session_id: sessionID, + transcript_path: `${transcriptDir}/${sessionID}.json`, + }) + break + } + + case "session.deleted": { + const session = (event as any).properties?.info + if (!session?.id) break + // Write final transcript before signaling session end + if (messageStore.size > 0) { + await writeTranscriptFromMemory(session.id) + } + seenUserMessages.clear() + messageStore.clear() + partStore.clear() + currentSessionID = null + await callHook("session-end", { + session_id: session.id, + transcript_path: `${transcriptDir}/${session.id}.json`, + }) + break + } + } + }, + } +} diff --git a/cmd/entire/cli/agent/opencode/hooks.go b/cmd/entire/cli/agent/opencode/hooks.go new file mode 100644 index 000000000..d2526e571 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/hooks.go @@ -0,0 +1,124 @@ +package opencode + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Compile-time interface assertion +var _ agent.HookSupport = (*OpenCodeAgent)(nil) + +const ( + // pluginFileName is the name of the plugin file written to .opencode/plugins/ + pluginFileName = "entire.ts" + + // pluginDirName is the directory under .opencode/ where plugins live + pluginDirName = "plugins" + + // entireMarker is a string present in the plugin file to identify it as Entire's + entireMarker = "Auto-generated by `entire enable --agent opencode`" +) + +// getPluginPath returns the absolute path to the plugin file. +func getPluginPath() (string, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + // Fallback to CWD if not in a git repo (e.g., during tests) + //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + repoRoot, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + } + return filepath.Join(repoRoot, ".opencode", pluginDirName, pluginFileName), nil +} + +// InstallHooks writes the Entire plugin file to .opencode/plugins/entire.ts. +// Returns 1 if the plugin was installed, 0 if already present (idempotent). +func (a *OpenCodeAgent) InstallHooks(localDev bool, force bool) (int, error) { + pluginPath, err := getPluginPath() + if err != nil { + return 0, err + } + + // Check if already installed (idempotent) unless force + if !force { + if _, err := os.Stat(pluginPath); err == nil { + data, readErr := os.ReadFile(pluginPath) //nolint:gosec // Path constructed from repo root + if readErr == nil && strings.Contains(string(data), entireMarker) { + return 0, nil // Already installed + } + } + } + + // Build the command prefix + var cmdPrefix string + if localDev { + cmdPrefix = "go run ${OPENCODE_PROJECT_DIR}/cmd/entire/main.go" + } else { + cmdPrefix = "entire" + } + + // Generate plugin content from template + content := strings.ReplaceAll(pluginTemplate, entireCmdPlaceholder, cmdPrefix) + + // Ensure directory exists + pluginDir := filepath.Dir(pluginPath) + //nolint:gosec // G301: Plugin directory needs standard permissions + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + return 0, fmt.Errorf("failed to create plugin directory: %w", err) + } + + // Write plugin file + //nolint:gosec // G306: Plugin file needs standard permissions for OpenCode to read + if err := os.WriteFile(pluginPath, []byte(content), 0o644); err != nil { + return 0, fmt.Errorf("failed to write plugin file: %w", err) + } + + return 1, nil +} + +// UninstallHooks removes the Entire plugin file. +func (a *OpenCodeAgent) UninstallHooks() error { + pluginPath, err := getPluginPath() + if err != nil { + return err + } + + if err := os.Remove(pluginPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove plugin file: %w", err) + } + + return nil +} + +// AreHooksInstalled checks if the Entire plugin file exists and contains the marker. +func (a *OpenCodeAgent) AreHooksInstalled() bool { + pluginPath, err := getPluginPath() + if err != nil { + return false + } + + data, err := os.ReadFile(pluginPath) //nolint:gosec // Path constructed from repo root + if err != nil { + return false + } + + return strings.Contains(string(data), entireMarker) +} + +// GetSupportedHooks returns the hook types this agent supports. +func (a *OpenCodeAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + "session_start", + "session_end", + "turn_start", + "turn_end", + "compaction", + } +} diff --git a/cmd/entire/cli/agent/opencode/hooks_test.go b/cmd/entire/cli/agent/opencode/hooks_test.go new file mode 100644 index 000000000..beb3ed37a --- /dev/null +++ b/cmd/entire/cli/agent/opencode/hooks_test.go @@ -0,0 +1,175 @@ +package opencode + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time check +var _ agent.HookSupport = (*OpenCodeAgent)(nil) + +func TestInstallHooks_FreshInstall(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 1 { + t.Errorf("expected 1 hook installed, got %d", count) + } + + // Verify plugin file was created + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("plugin file not created: %v", err) + } + + content := string(data) + // The plugin uses JS template literal ${ENTIRE_CMD} — check the constant was set correctly + if !strings.Contains(content, `const ENTIRE_CMD = "entire"`) { + t.Error("plugin file does not contain production command constant") + } + if !strings.Contains(content, "hooks opencode") { + t.Error("plugin file does not contain 'hooks opencode'") + } + if !strings.Contains(content, "EntirePlugin") { + t.Error("plugin file does not contain 'EntirePlugin' export") + } + // Should use production command + if strings.Contains(content, "go run") { + t.Error("plugin file contains 'go run' in production mode") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + // First install + count1, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first install failed: %v", err) + } + if count1 != 1 { + t.Errorf("first install: expected 1, got %d", count1) + } + + // Second install — should be idempotent + count2, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second install failed: %v", err) + } + if count2 != 0 { + t.Errorf("second install: expected 0 (idempotent), got %d", count2) + } +} + +func TestInstallHooks_LocalDev(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + count, err := ag.InstallHooks(true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 1 { + t.Errorf("expected 1 hook installed, got %d", count) + } + + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("plugin file not created: %v", err) + } + + content := string(data) + if !strings.Contains(content, "go run") { + t.Error("local dev mode: plugin file should contain 'go run'") + } +} + +func TestInstallHooks_ForceReinstall(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + // First install + if _, err := ag.InstallHooks(false, false); err != nil { + t.Fatalf("first install failed: %v", err) + } + + // Force reinstall + count, err := ag.InstallHooks(false, true) + if err != nil { + t.Fatalf("force install failed: %v", err) + } + if count != 1 { + t.Errorf("force install: expected 1, got %d", count) + } +} + +func TestUninstallHooks(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + if _, err := ag.InstallHooks(false, false); err != nil { + t.Fatalf("install failed: %v", err) + } + + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("uninstall failed: %v", err) + } + + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); !os.IsNotExist(err) { + t.Error("plugin file still exists after uninstall") + } +} + +func TestUninstallHooks_NoFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + // Should not error when no plugin file exists + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("uninstall with no file should not error: %v", err) + } +} + +func TestAreHooksInstalled(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + if ag.AreHooksInstalled() { + t.Error("hooks should not be installed initially") + } + + if _, err := ag.InstallHooks(false, false); err != nil { + t.Fatalf("install failed: %v", err) + } + + if !ag.AreHooksInstalled() { + t.Error("hooks should be installed after InstallHooks") + } + + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("uninstall failed: %v", err) + } + + if ag.AreHooksInstalled() { + t.Error("hooks should not be installed after UninstallHooks") + } +} diff --git a/cmd/entire/cli/agent/opencode/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go new file mode 100644 index 000000000..1c8270ca5 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/lifecycle.go @@ -0,0 +1,107 @@ +package opencode + +import ( + "io" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface assertions +var ( + _ agent.HookHandler = (*OpenCodeAgent)(nil) +) + +// Hook name constants — these become CLI subcommands under `entire hooks opencode`. +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameTurnStart = "turn-start" + HookNameTurnEnd = "turn-end" + HookNameCompaction = "compaction" +) + +// HookNames returns the hook verbs this agent supports. +func (a *OpenCodeAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameTurnStart, + HookNameTurnEnd, + HookNameCompaction, + } +} + +// GetHookNames implements agent.HookHandler for CLI hook registration. +func (a *OpenCodeAgent) GetHookNames() []string { + return a.HookNames() +} + +// ParseHookEvent translates OpenCode hook calls into normalized lifecycle events. +func (a *OpenCodeAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil + + case HookNameTurnStart: + raw, err := agent.ReadAndParseHookInput[turnStartRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Prompt: raw.Prompt, + Timestamp: time.Now(), + }, nil + + case HookNameTurnEnd: + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil + + case HookNameCompaction: + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.Compaction, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil + + case HookNameSessionEnd: + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil + + default: + return nil, nil //nolint:nilnil // nil event = no lifecycle action for unknown hooks + } +} diff --git a/cmd/entire/cli/agent/opencode/lifecycle_test.go b/cmd/entire/cli/agent/opencode/lifecycle_test.go new file mode 100644 index 000000000..c0a372462 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/lifecycle_test.go @@ -0,0 +1,189 @@ +package opencode + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"session_id": "sess-abc123", "transcript_path": "/tmp/project/.opencode/sessions/entire/sess-abc123.json"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionStart { + t.Errorf("expected SessionStart, got %v", event.Type) + } + if event.SessionID != "sess-abc123" { + t.Errorf("expected session_id 'sess-abc123', got %q", event.SessionID) + } + if event.SessionRef != "/tmp/project/.opencode/sessions/entire/sess-abc123.json" { + t.Errorf("unexpected session ref: %q", event.SessionRef) + } +} + +func TestParseHookEvent_TurnStart(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"session_id": "sess-1", "transcript_path": "/tmp/t.json", "prompt": "Fix the bug in login.ts"}` + + event, err := ag.ParseHookEvent(HookNameTurnStart, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnStart { + t.Errorf("expected TurnStart, got %v", event.Type) + } + if event.Prompt != "Fix the bug in login.ts" { + t.Errorf("expected prompt 'Fix the bug in login.ts', got %q", event.Prompt) + } + if event.SessionID != "sess-1" { + t.Errorf("expected session_id 'sess-1', got %q", event.SessionID) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"session_id": "sess-2", "transcript_path": "/tmp/t.json"}` + + event, err := ag.ParseHookEvent(HookNameTurnEnd, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnEnd { + t.Errorf("expected TurnEnd, got %v", event.Type) + } + if event.SessionID != "sess-2" { + t.Errorf("expected session_id 'sess-2', got %q", event.SessionID) + } +} + +func TestParseHookEvent_Compaction(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"session_id": "sess-3"}` + + event, err := ag.ParseHookEvent(HookNameCompaction, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.Compaction { + t.Errorf("expected Compaction, got %v", event.Type) + } + if event.SessionID != "sess-3" { + t.Errorf("expected session_id 'sess-3', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"session_id": "sess-4", "transcript_path": "/tmp/t.json"}` + + event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionEnd { + t.Errorf("expected SessionEnd, got %v", event.Type) + } +} + +func TestParseHookEvent_UnknownHook(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + event, err := ag.ParseHookEvent("unknown-hook", strings.NewReader(`{}`)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for unknown hook, got %+v", event) + } +} + +func TestParseHookEvent_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("")) + + if err == nil { + t.Fatal("expected error for empty input") + } + if !strings.Contains(err.Error(), "empty hook input") { + t.Errorf("expected 'empty hook input' error, got: %v", err) + } +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("not json")) + + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestHookNames(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + names := ag.HookNames() + + expected := []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameTurnStart, + HookNameTurnEnd, + HookNameCompaction, + } + + if len(names) != len(expected) { + t.Fatalf("expected %d hook names, got %d", len(expected), len(names)) + } + + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + for _, e := range expected { + if !nameSet[e] { + t.Errorf("missing expected hook name: %s", e) + } + } +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go new file mode 100644 index 000000000..c24ad8883 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -0,0 +1,216 @@ +// Package opencode implements the Agent interface for OpenCode. +package opencode + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameOpenCode, NewOpenCodeAgent) +} + +//nolint:revive // OpenCodeAgent is clearer than Agent in this context +type OpenCodeAgent struct{} + +// NewOpenCodeAgent creates a new OpenCode agent instance. +func NewOpenCodeAgent() agent.Agent { + return &OpenCodeAgent{} +} + +// --- Identity --- + +func (a *OpenCodeAgent) Name() agent.AgentName { return agent.AgentNameOpenCode } +func (a *OpenCodeAgent) Type() agent.AgentType { return agent.AgentTypeOpenCode } +func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" } +func (a *OpenCodeAgent) IsPreview() bool { return true } +func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } + +func (a *OpenCodeAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + // Check for .opencode directory or opencode.json config + if _, err := os.Stat(filepath.Join(repoRoot, ".opencode")); err == nil { + return true, nil + } + if _, err := os.Stat(filepath.Join(repoRoot, "opencode.json")); err == nil { + return true, nil + } + return false, nil +} + +// --- Transcript Storage --- + +func (a *OpenCodeAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path from agent hook + if err != nil { + return nil, fmt.Errorf("failed to read opencode transcript: %w", err) + } + return data, nil +} + +func (a *OpenCodeAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + // OpenCode uses JSON format (like Gemini). Parse and split by messages. + if len(content) <= maxSize { + return [][]byte{content}, nil + } + + var transcript Transcript + if err := json.Unmarshal(content, &transcript); err != nil { + // Fallback to JSONL chunking if not valid JSON + chunks, chunkErr := agent.ChunkJSONL(content, maxSize) + if chunkErr != nil { + return nil, fmt.Errorf("failed to chunk transcript as JSONL: %w", chunkErr) + } + return chunks, nil + } + + // Split messages across chunks + var chunks [][]byte + var currentMessages []Message + + for _, msg := range transcript.Messages { + currentMessages = append(currentMessages, msg) + chunk := Transcript{ + SessionID: transcript.SessionID, + Messages: currentMessages, + } + data, err := json.Marshal(chunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal transcript chunk: %w", err) + } + if len(data) > maxSize && len(currentMessages) > 1 { + // Remove last message and save chunk + currentMessages = currentMessages[:len(currentMessages)-1] + chunk.Messages = currentMessages + data, err = json.Marshal(chunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal transcript chunk: %w", err) + } + chunks = append(chunks, data) + currentMessages = []Message{msg} + } + } + + // Save remaining messages + if len(currentMessages) > 0 { + chunk := Transcript{ + SessionID: transcript.SessionID, + Messages: currentMessages, + } + data, err := json.Marshal(chunk) + if err != nil { + return nil, fmt.Errorf("failed to marshal final transcript chunk: %w", err) + } + chunks = append(chunks, data) + } + + return chunks, nil +} + +func (a *OpenCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + if len(chunks) == 0 { + return nil, nil + } + if len(chunks) == 1 { + return chunks[0], nil + } + + var combined Transcript + for i, chunk := range chunks { + var t Transcript + if err := json.Unmarshal(chunk, &t); err != nil { + return nil, fmt.Errorf("failed to parse transcript chunk %d: %w", i, err) + } + if i == 0 { + combined.SessionID = t.SessionID + } + combined.Messages = append(combined.Messages, t.Messages...) + } + + data, err := json.Marshal(combined) + if err != nil { + return nil, fmt.Errorf("failed to marshal reassembled transcript: %w", err) + } + return data, nil +} + +// --- Legacy methods --- + +func (a *OpenCodeAgent) GetHookConfigPath() string { return "" } // Plugin file, not a JSON config +func (a *OpenCodeAgent) SupportsHooks() bool { return true } + +func (a *OpenCodeAgent) ParseHookInput(_ agent.HookType, r io.Reader) (*agent.HookInput, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](r) + if err != nil { + return nil, err + } + return &agent.HookInput{ + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + }, nil +} + +func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) { + // OpenCode transcript files are written by the plugin to .opencode/sessions/entire/ + return filepath.Join(repoPath, ".opencode", "sessions", "entire"), nil +} + +func (a *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".json") +} + +func (a *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("no session ref provided") + } + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read session: %w", err) + } + return &agent.AgentSession{ + AgentName: a.Name(), + SessionID: input.SessionID, + SessionRef: input.SessionRef, + NativeData: data, + }, nil +} + +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") + } + 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) + } + return nil +} + +func (a *OpenCodeAgent) FormatResumeCommand(sessionID string) string { + return "opencode --session " + sessionID +} diff --git a/cmd/entire/cli/agent/opencode/plugin.go b/cmd/entire/cli/agent/opencode/plugin.go new file mode 100644 index 000000000..92d1e833e --- /dev/null +++ b/cmd/entire/cli/agent/opencode/plugin.go @@ -0,0 +1,9 @@ +package opencode + +import _ "embed" + +//go:embed entire_plugin.ts +var pluginTemplate string + +// entireCmdPlaceholder is replaced with the actual command during installation. +const entireCmdPlaceholder = "__ENTIRE_CMD__" diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go new file mode 100644 index 000000000..b02ccaf72 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -0,0 +1,234 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "os" + "slices" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface assertions +var ( + _ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil) + _ agent.TokenCalculator = (*OpenCodeAgent)(nil) +) + +// ParseTranscript parses raw JSON content into a transcript structure. +func ParseTranscript(data []byte) (*Transcript, error) { + var t Transcript + if err := json.Unmarshal(data, &t); err != nil { + return nil, fmt.Errorf("failed to parse opencode transcript: %w", err) + } + return &t, nil +} + +// parseTranscriptFile reads and parses a transcript JSON file. +func parseTranscriptFile(path string) (*Transcript, error) { + data, err := os.ReadFile(path) //nolint:gosec // Path from agent hook + if err != nil { + return nil, err //nolint:wrapcheck // Callers check os.IsNotExist on this error + } + var t Transcript + if err := json.Unmarshal(data, &t); err != nil { + return nil, fmt.Errorf("failed to parse opencode transcript: %w", err) + } + return &t, nil +} + +// GetTranscriptPosition returns the number of messages in the transcript. +func (a *OpenCodeAgent) GetTranscriptPosition(path string) (int, error) { + t, err := parseTranscriptFile(path) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + return len(t.Messages), nil +} + +// ExtractModifiedFilesFromOffset extracts files modified by tool calls from the given message offset. +func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { + t, err := parseTranscriptFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, 0, nil + } + return nil, 0, err + } + + seen := make(map[string]bool) + var files []string + + for i := startOffset; i < len(t.Messages); i++ { + msg := t.Messages[i] + if msg.Role != roleAssistant { + continue + } + for _, part := range msg.Parts { + if part.Type != "tool" || part.State == nil { + continue + } + if !slices.Contains(FileModificationTools, part.Tool) { + continue + } + filePath := extractFilePathFromInput(part.State.Input) + if filePath != "" && !seen[filePath] { + seen[filePath] = true + files = append(files, filePath) + } + } + } + + return files, len(t.Messages), nil +} + +// extractFilePathFromInput extracts the file path from a tool's input map. +func extractFilePathFromInput(input map[string]interface{}) string { + for _, key := range []string{"file_path", "path", "file", "filename"} { + if v, ok := input[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" +} + +// ExtractPrompts extracts user prompt strings from the transcript starting at the given offset. +func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + t, err := parseTranscriptFile(sessionRef) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var prompts []string + for i := fromOffset; i < len(t.Messages); i++ { + msg := t.Messages[i] + if msg.Role == roleUser && msg.Content != "" { + prompts = append(prompts, msg.Content) + } + } + + return prompts, nil +} + +// ExtractSummary extracts the last assistant message content as a summary. +func (a *OpenCodeAgent) ExtractSummary(sessionRef string) (string, error) { + t, err := parseTranscriptFile(sessionRef) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + for i := len(t.Messages) - 1; i >= 0; i-- { + msg := t.Messages[i] + if msg.Role == roleAssistant && msg.Content != "" { + return msg.Content, nil + } + } + + return "", nil +} + +// SliceFromMessage returns a JSON transcript containing only messages from startMessageIndex onward. +// This is used by explain to scope a full transcript to a specific checkpoint's portion. +func SliceFromMessage(data []byte, startMessageIndex int) []byte { + if len(data) == 0 || startMessageIndex <= 0 { + return data + } + + t, err := ParseTranscript(data) + if err != nil { + return nil + } + + if startMessageIndex >= len(t.Messages) { + return nil + } + + scoped := &Transcript{ + SessionID: t.SessionID, + Messages: t.Messages[startMessageIndex:], + } + + out, err := json.Marshal(scoped) + if err != nil { + return nil + } + return out +} + +// ExtractAllUserPrompts extracts all user prompts from raw transcript bytes. +// This is a package-level function used by the condensation path. +func ExtractAllUserPrompts(data []byte) ([]string, error) { + t, err := ParseTranscript(data) + if err != nil { + return nil, err + } + + var prompts []string + for _, msg := range t.Messages { + if msg.Role == roleUser && msg.Content != "" { + prompts = append(prompts, msg.Content) + } + } + return prompts, nil +} + +// CalculateTokenUsageFromBytes computes token usage from raw transcript bytes starting at the given message offset. +// This is a package-level function used by the condensation path (which has bytes, not a file path). +func CalculateTokenUsageFromBytes(data []byte, startMessageIndex int) *agent.TokenUsage { + t, err := ParseTranscript(data) + if err != nil || t == nil { + return &agent.TokenUsage{} + } + + usage := &agent.TokenUsage{} + for i := startMessageIndex; i < len(t.Messages); i++ { + msg := t.Messages[i] + if msg.Role != roleAssistant || msg.Tokens == nil { + continue + } + usage.InputTokens += msg.Tokens.Input + usage.OutputTokens += msg.Tokens.Output + usage.CacheReadTokens += msg.Tokens.Cache.Read + usage.CacheCreationTokens += msg.Tokens.Cache.Write + usage.APICallCount++ + } + + return usage +} + +// CalculateTokenUsage computes token usage from assistant messages starting at the given offset. +func (a *OpenCodeAgent) CalculateTokenUsage(sessionRef string, fromOffset int) (*agent.TokenUsage, error) { + t, err := parseTranscriptFile(sessionRef) + if err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil // nil usage for nonexistent file is expected + } + return nil, fmt.Errorf("failed to parse transcript for token usage: %w", err) + } + + usage := &agent.TokenUsage{} + for i := fromOffset; i < len(t.Messages); i++ { + msg := t.Messages[i] + if msg.Role != roleAssistant || msg.Tokens == nil { + continue + } + usage.InputTokens += msg.Tokens.Input + usage.OutputTokens += msg.Tokens.Output + usage.CacheReadTokens += msg.Tokens.Cache.Read + usage.CacheCreationTokens += msg.Tokens.Cache.Write + usage.APICallCount++ + } + + return usage, nil +} diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go new file mode 100644 index 000000000..4367c5916 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -0,0 +1,250 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time checks +var ( + _ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil) + _ agent.TokenCalculator = (*OpenCodeAgent)(nil) +) + +const testTranscriptJSON = `{ + "session_id": "test-session", + "messages": [ + { + "id": "msg-1", + "role": "user", + "content": "Fix the bug in main.go", + "time": {"created": 1708300000} + }, + { + "id": "msg-2", + "role": "assistant", + "content": "I'll fix the bug.", + "time": {"created": 1708300001, "completed": 1708300005}, + "tokens": {"input": 150, "output": 80, "reasoning": 10, "cache": {"read": 5, "write": 15}}, + "cost": 0.003, + "parts": [ + {"type": "text", "text": "I'll fix the bug."}, + {"type": "tool", "tool": "edit_file", "callID": "call-1", + "state": {"status": "completed", "input": {"file_path": "main.go"}, "output": "Applied edit"}} + ] + }, + { + "id": "msg-3", + "role": "user", + "content": "Also fix util.go", + "time": {"created": 1708300010} + }, + { + "id": "msg-4", + "role": "assistant", + "content": "Done fixing util.go.", + "time": {"created": 1708300011, "completed": 1708300015}, + "tokens": {"input": 200, "output": 100, "reasoning": 5, "cache": {"read": 10, "write": 20}}, + "cost": 0.005, + "parts": [ + {"type": "tool", "tool": "write_file", "callID": "call-2", + "state": {"status": "completed", "input": {"file_path": "util.go"}, "output": "File written"}}, + {"type": "text", "text": "Done fixing util.go."} + ] + } + ] +}` + +func writeTestTranscript(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test-session.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write test transcript: %v", err) + } + return path +} + +func TestGetTranscriptPosition(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + pos, err := ag.GetTranscriptPosition(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pos != 4 { + t.Errorf("expected position 4 (4 messages), got %d", pos) + } +} + +func TestGetTranscriptPosition_NonexistentFile(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + + pos, err := ag.GetTranscriptPosition("/nonexistent/path.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pos != 0 { + t.Errorf("expected position 0 for nonexistent file, got %d", pos) + } +} + +func TestExtractModifiedFilesFromOffset(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + // From offset 0 — should get both main.go and util.go + files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pos != 4 { + t.Errorf("expected position 4, got %d", pos) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d: %v", len(files), files) + } + + // From offset 2 — should only get util.go (messages 3 and 4) + files, pos, err = ag.ExtractModifiedFilesFromOffset(path, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pos != 4 { + t.Errorf("expected position 4, got %d", pos) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d: %v", len(files), files) + } + if files[0] != "util.go" { + t.Errorf("expected 'util.go', got %q", files[0]) + } +} + +func TestExtractPrompts(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + // From offset 0 — both prompts + prompts, err := ag.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(prompts) != 2 { + t.Fatalf("expected 2 prompts, got %d: %v", len(prompts), prompts) + } + if prompts[0] != "Fix the bug in main.go" { + t.Errorf("expected first prompt 'Fix the bug in main.go', got %q", prompts[0]) + } + + // From offset 2 — only second prompt + prompts, err = ag.ExtractPrompts(path, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(prompts) != 1 { + t.Fatalf("expected 1 prompt from offset 2, got %d", len(prompts)) + } + if prompts[0] != "Also fix util.go" { + t.Errorf("expected 'Also fix util.go', got %q", prompts[0]) + } +} + +func TestExtractSummary(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary != "Done fixing util.go." { + t.Errorf("expected summary 'Done fixing util.go.', got %q", summary) + } +} + +func TestExtractSummary_EmptyTranscript(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, `{"session_id": "empty", "messages": []}`) + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary != "" { + t.Errorf("expected empty summary, got %q", summary) + } +} + +func TestCalculateTokenUsage(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + // From offset 0 — both assistant messages + usage, err := ag.CalculateTokenUsage(path, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage == nil { + t.Fatal("expected non-nil usage") + } + if usage.InputTokens != 350 { + t.Errorf("expected 350 input tokens, got %d", usage.InputTokens) + } + if usage.OutputTokens != 180 { + t.Errorf("expected 180 output tokens, got %d", usage.OutputTokens) + } + if usage.CacheReadTokens != 15 { + t.Errorf("expected 15 cache read tokens, got %d", usage.CacheReadTokens) + } + if usage.CacheCreationTokens != 35 { + t.Errorf("expected 35 cache creation tokens, got %d", usage.CacheCreationTokens) + } + if usage.APICallCount != 2 { + t.Errorf("expected 2 API calls, got %d", usage.APICallCount) + } +} + +func TestCalculateTokenUsage_FromOffset(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testTranscriptJSON) + + usage, err := ag.CalculateTokenUsage(path, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage.InputTokens != 200 { + t.Errorf("expected 200 input tokens, got %d", usage.InputTokens) + } + if usage.OutputTokens != 100 { + t.Errorf("expected 100 output tokens, got %d", usage.OutputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("expected 1 API call, got %d", usage.APICallCount) + } +} + +func TestCalculateTokenUsage_NonexistentFile(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + + usage, err := ag.CalculateTokenUsage("/nonexistent/path.json", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage != nil { + t.Errorf("expected nil usage for nonexistent file, got %+v", usage) + } +} diff --git a/cmd/entire/cli/agent/opencode/types.go b/cmd/entire/cli/agent/opencode/types.go new file mode 100644 index 000000000..f7ad7ed1b --- /dev/null +++ b/cmd/entire/cli/agent/opencode/types.go @@ -0,0 +1,84 @@ +package opencode + +// sessionInfoRaw matches the JSON payload piped from the OpenCode plugin for session events. +type sessionInfoRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` +} + +// turnStartRaw matches the JSON payload for turn-start (user prompt submission). +type turnStartRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt"` +} + +// --- Transcript types (JSON format, similar to Gemini CLI) --- + +// Message role constants. +const ( + roleAssistant = "assistant" + roleUser = "user" +) + +// Transcript represents the full transcript JSON written by the plugin. +type Transcript struct { + SessionID string `json:"session_id"` + Messages []Message `json:"messages"` +} + +// Message represents a single message in the transcript. +type Message struct { + ID string `json:"id"` + Role string `json:"role"` // "user" or "assistant" + Content string `json:"content"` + Time Time `json:"time"` + Tokens *Tokens `json:"tokens,omitempty"` + Cost float64 `json:"cost,omitempty"` + Parts []Part `json:"parts,omitempty"` +} + +// Time holds message timestamps. +type Time struct { + Created int64 `json:"created"` + Completed int64 `json:"completed,omitempty"` +} + +// Tokens holds token usage from assistant messages. +type Tokens struct { + Input int `json:"input"` + Output int `json:"output"` + Reasoning int `json:"reasoning"` + Cache Cache `json:"cache"` +} + +// Cache holds cache-related token counts. +type Cache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +// Part represents a message part (text, tool, etc.). +type Part struct { + Type string `json:"type"` // "text", "tool", etc. + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + CallID string `json:"callID,omitempty"` + State *ToolState `json:"state,omitempty"` +} + +// ToolState represents tool execution state. +type ToolState struct { + Status string `json:"status"` // "pending", "running", "completed", "error" + Input map[string]interface{} `json:"input,omitempty"` + Output string `json:"output,omitempty"` +} + +// FileModificationTools are tools in OpenCode that modify files on disk. +var FileModificationTools = []string{ + "edit_file", + "write", + "write_file", + "create_file", + "patch", +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 0be89d8b6..37f7a2905 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -93,12 +93,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameOpenCode AgentName = "opencode" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpenCode AgentType = "OpenCode" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 1bc6385e2..d6a12ac02 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -13,6 +13,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/logging" @@ -533,8 +534,13 @@ func getAssociatedCommits(repo *git.Repository, checkpointID id.CheckpointID, se // For Claude Code (JSONL), the offset is a line number and we slice by line. // For Gemini (single JSON blob), the offset is a message index and we slice by message. func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentType agent.AgentType) []byte { - if agentType == agent.AgentTypeGemini { + switch agentType { + case agent.AgentTypeGemini: return geminicli.SliceFromMessage(fullTranscript, startOffset) + case agent.AgentTypeOpenCode: + return opencode.SliceFromMessage(fullTranscript, startOffset) + case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) } @@ -1526,12 +1532,21 @@ func countLines(content []byte) int { // transcriptOffset returns the appropriate offset for scoping a transcript. // For Claude Code (JSONL), this is the line count. For Gemini (JSON), this is the message count. func transcriptOffset(transcriptBytes []byte, agentType agent.AgentType) int { - if agentType == agent.AgentTypeGemini { + switch agentType { + case agent.AgentTypeGemini: t, err := geminicli.ParseTranscript(transcriptBytes) if err != nil { return 0 } return len(t.Messages) + case agent.AgentTypeOpenCode: + t, err := opencode.ParseTranscript(transcriptBytes) + if err != nil { + return 0 + } + return len(t.Messages) + case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + return countLines(transcriptBytes) } return countLines(transcriptBytes) } diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 76fc8a8a4..215954a56 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -5,6 +5,7 @@ import ( // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 80fb2932d..b8ec5dc53 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -178,7 +178,7 @@ To completely remove Entire integrations from this repository, use --uninstall: - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push) - Session state files (.git/entire-sessions/) - Shadow branches (entire/) - - Agent hooks (Claude Code, Gemini CLI)`, + - Agent hooks (Claude Code, Gemini CLI, OpenCode)`, RunE: func(cmd *cobra.Command, _ []string) error { if uninstall { return runUninstall(cmd.OutOrStdout(), cmd.ErrOrStderr(), force) @@ -1070,11 +1070,12 @@ func runUninstall(w, errW io.Writer, force bool) error { gitHooksInstalled := strategy.IsGitHookInstalled() claudeHooksInstalled := checkClaudeCodeHooksInstalled() geminiHooksInstalled := checkGeminiCLIHooksInstalled() + opencodeHooksInstalled := checkOpenCodeHooksInstalled() entireDirExists := checkEntireDirExists() // Check if there's anything to uninstall if !entireDirExists && !gitHooksInstalled && sessionStateCount == 0 && - shadowBranchCount == 0 && !claudeHooksInstalled && !geminiHooksInstalled { + shadowBranchCount == 0 && !claudeHooksInstalled && !geminiHooksInstalled && !opencodeHooksInstalled { fmt.Fprintln(w, "Entire is not installed in this repository.") return nil } @@ -1094,13 +1095,18 @@ func runUninstall(w, errW io.Writer, force bool) error { if shadowBranchCount > 0 { fmt.Fprintf(w, " - Shadow branches (%d)\n", shadowBranchCount) } - switch { - case claudeHooksInstalled && geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code, Gemini CLI)") - case claudeHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code)") - case geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Gemini CLI)") + var agentNames []string + if claudeHooksInstalled { + agentNames = append(agentNames, "Claude Code") + } + if geminiHooksInstalled { + agentNames = append(agentNames, "Gemini CLI") + } + if opencodeHooksInstalled { + agentNames = append(agentNames, "OpenCode") + } + if len(agentNames) > 0 { + fmt.Fprintf(w, " - Agent hooks (%s)\n", strings.Join(agentNames, ", ")) } fmt.Fprintln(w) @@ -1215,6 +1221,19 @@ func checkGeminiCLIHooksInstalled() bool { return hookAgent.AreHooksInstalled() } +// checkOpenCodeHooksInstalled checks if OpenCode hooks are installed. +func checkOpenCodeHooksInstalled() bool { + ag, err := agent.Get(agent.AgentNameOpenCode) + if err != nil { + return false + } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + return false + } + return hookAgent.AreHooksInstalled() +} + // checkEntireDirExists checks if the .entire directory exists. func checkEntireDirExists() bool { entireDirAbs, err := paths.AbsPath(paths.EntireDir) @@ -1255,6 +1274,19 @@ func removeAgentHooks(w io.Writer) error { } } + // Remove OpenCode hooks + opencodeAgent, err := agent.Get(agent.AgentNameOpenCode) + if err == nil { + if hookAgent, ok := opencodeAgent.(agent.HookSupport); ok { + wasInstalled := hookAgent.AreHooksInstalled() + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, err) + } else if wasInstalled { + fmt.Fprintln(w, " Removed OpenCode hooks") + } + } + } + return errors.Join(errs...) } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index fd8e6585e..1d501b271 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -466,6 +466,10 @@ func ReadAgentTypeFromTree(tree *object.Tree, checkpointPath string) agent.Agent } // Fall back to detecting agent from config files (shadow branches don't have metadata.json) + // Check for OpenCode config + if _, err := tree.Tree(".opencode"); err == nil { + return agent.AgentTypeOpenCode + } // Check for Gemini config if _, err := tree.File(".gemini/settings.json"); err == nil { return agent.AgentTypeGemini diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 1e6beb00c..c3fd97ab7 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -12,6 +12,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/logging" @@ -480,6 +481,15 @@ func countTranscriptItems(agentType agent.AgentType, content string) int { return 0 } + // OpenCode uses JSON with messages array (like Gemini) + if agentType == agent.AgentTypeOpenCode { + t, err := opencode.ParseTranscript([]byte(content)) + if err == nil && t != nil { + return len(t.Messages) + } + return 0 + } + // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { transcript, err := geminicli.ParseTranscript([]byte(content)) @@ -509,6 +519,21 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { return nil } + // OpenCode uses JSON with messages array + if agentType == agent.AgentTypeOpenCode { + prompts, err := opencode.ExtractAllUserPrompts([]byte(content)) + if err == nil && len(prompts) > 0 { + cleaned := make([]string, 0, len(prompts)) + for _, prompt := range prompts { + if stripped := textutil.StripIDEContextTags(prompt); stripped != "" { + cleaned = append(cleaned, stripped) + } + } + return cleaned + } + return nil + } + // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { prompts, err := geminicli.ExtractAllUserPrompts([]byte(content)) @@ -542,6 +567,11 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } + // OpenCode uses JSON with token info on assistant messages + if agentType == agent.AgentTypeOpenCode { + return opencode.CalculateTokenUsageFromBytes(data, startOffset) + } + // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { // Attempt to parse as Gemini JSON diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..014a0afb4 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -116,6 +117,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) + case agent.AgentTypeOpenCode: + return buildCondensedTranscriptFromOpenCode(content) case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: // Claude format - fall through to shared logic below } @@ -166,6 +169,55 @@ func buildCondensedTranscriptFromGemini(content []byte) ([]Entry, error) { return entries, nil } +// buildCondensedTranscriptFromOpenCode parses OpenCode JSON transcript and extracts a condensed view. +func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) { + ocTranscript, err := opencode.ParseTranscript(content) + if err != nil { + return nil, fmt.Errorf("failed to parse OpenCode transcript: %w", err) + } + + var entries []Entry + for _, msg := range ocTranscript.Messages { + switch msg.Role { + case "user": + if msg.Content != "" { + entries = append(entries, Entry{ + Type: EntryTypeUser, + Content: msg.Content, + }) + } + case "assistant": + if msg.Content != "" { + entries = append(entries, Entry{ + Type: EntryTypeAssistant, + Content: msg.Content, + }) + } + for _, part := range msg.Parts { + if part.Type == "tool" && part.State != nil { + entries = append(entries, Entry{ + Type: EntryTypeTool, + ToolName: part.Tool, + ToolDetail: extractOpenCodeToolDetail(part.State.Input), + }) + } + } + } + } + + return entries, nil +} + +// extractOpenCodeToolDetail extracts an appropriate detail string from OpenCode tool input. +func extractOpenCodeToolDetail(input map[string]interface{}) string { + for _, key := range []string{"description", "command", "file_path", "path", "pattern"} { + if v, ok := input[key].(string); ok && v != "" { + return v + } + } + return "" +} + // extractGeminiToolDetail extracts an appropriate detail string from Gemini tool args. func extractGeminiToolDetail(args map[string]interface{}) string { // Check common fields in order of preference From 98749b32e0e0d02e12b293936082d5f03196f6ab Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 18 Feb 2026 19:35:41 -0800 Subject: [PATCH 2/7] Address PR review feedback --- cmd/entire/cli/agent/opencode/hooks.go | 20 +- cmd/entire/cli/agent/opencode/hooks_test.go | 2 + cmd/entire/cli/agent/opencode/opencode.go | 71 +++--- cmd/entire/cli/agent/opencode/transcript.go | 33 +++ .../cli/agent/opencode/transcript_test.go | 215 ++++++++++++++++++ cmd/entire/cli/strategy/common.go | 16 +- cmd/entire/cli/summarize/summarize.go | 20 +- cmd/entire/cli/summarize/summarize_test.go | 110 ++++++++- 8 files changed, 432 insertions(+), 55 deletions(-) diff --git a/cmd/entire/cli/agent/opencode/hooks.go b/cmd/entire/cli/agent/opencode/hooks.go index d2526e571..fd5c7201a 100644 --- a/cmd/entire/cli/agent/opencode/hooks.go +++ b/cmd/entire/cli/agent/opencode/hooks.go @@ -112,13 +112,21 @@ func (a *OpenCodeAgent) AreHooksInstalled() bool { return strings.Contains(string(data), entireMarker) } -// GetSupportedHooks returns the hook types this agent supports. +// GetSupportedHooks returns the normalized lifecycle events this agent supports. +// OpenCode's native hooks map to standard agent lifecycle events: +// - session-start → HookSessionStart +// - session-end → HookSessionEnd +// - turn-start → HookUserPromptSubmit (user prompt triggers a turn) +// - turn-end → HookStop (agent response complete) +// +// Note: HookNames() returns 5 hooks (including "compaction"), but GetSupportedHooks() +// returns only 4. The "compaction" hook is OpenCode-specific with no standard HookType +// mapping — it is handled via ParseHookEvent but not advertised as a standard lifecycle event. func (a *OpenCodeAgent) GetSupportedHooks() []agent.HookType { return []agent.HookType{ - "session_start", - "session_end", - "turn_start", - "turn_end", - "compaction", + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookUserPromptSubmit, + agent.HookStop, } } diff --git a/cmd/entire/cli/agent/opencode/hooks_test.go b/cmd/entire/cli/agent/opencode/hooks_test.go index beb3ed37a..41e0b5aa0 100644 --- a/cmd/entire/cli/agent/opencode/hooks_test.go +++ b/cmd/entire/cli/agent/opencode/hooks_test.go @@ -12,6 +12,8 @@ import ( // Compile-time check var _ agent.HookSupport = (*OpenCodeAgent)(nil) +// Note: Hook tests cannot use t.Parallel() because t.Chdir() modifies process state. + func TestInstallHooks_FreshInstall(t *testing.T) { dir := t.TempDir() t.Chdir(dir) diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index c24ad8883..0e02c44fe 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -31,7 +31,7 @@ func NewOpenCodeAgent() agent.Agent { func (a *OpenCodeAgent) Name() agent.AgentName { return agent.AgentNameOpenCode } func (a *OpenCodeAgent) Type() agent.AgentType { return agent.AgentTypeOpenCode } func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" } -func (a *OpenCodeAgent) IsPreview() bool { return true } +func (a *OpenCodeAgent) IsPreview() bool { return true } func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } func (a *OpenCodeAgent) DetectPresence() (bool, error) { @@ -75,44 +75,54 @@ func (a *OpenCodeAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, return chunks, nil } - // Split messages across chunks + if len(transcript.Messages) == 0 { + return [][]byte{content}, nil + } + + // Pre-marshal each message to avoid O(n²) re-serialization. + // Track running size and split at chunk boundaries (same approach as Gemini). var chunks [][]byte var currentMessages []Message + baseSize := len(fmt.Sprintf(`{"session_id":%q,"messages":[]}`, transcript.SessionID)) + currentSize := baseSize for _, msg := range transcript.Messages { - currentMessages = append(currentMessages, msg) - chunk := Transcript{ - SessionID: transcript.SessionID, - Messages: currentMessages, - } - data, err := json.Marshal(chunk) + msgBytes, err := json.Marshal(msg) if err != nil { - return nil, fmt.Errorf("failed to marshal transcript chunk: %w", err) + continue // Skip messages that fail to marshal } - if len(data) > maxSize && len(currentMessages) > 1 { - // Remove last message and save chunk - currentMessages = currentMessages[:len(currentMessages)-1] - chunk.Messages = currentMessages - data, err = json.Marshal(chunk) - if err != nil { - return nil, fmt.Errorf("failed to marshal transcript chunk: %w", err) + msgSize := len(msgBytes) + 1 // +1 for comma separator + + if currentSize+msgSize > maxSize && len(currentMessages) > 0 { + // Save current chunk + chunkData, marshalErr := json.Marshal(Transcript{ + SessionID: transcript.SessionID, + Messages: currentMessages, + }) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal transcript chunk: %w", marshalErr) } - chunks = append(chunks, data) - currentMessages = []Message{msg} + chunks = append(chunks, chunkData) + + // Start new chunk + currentMessages = nil + currentSize = baseSize } + + currentMessages = append(currentMessages, msg) + currentSize += msgSize } // Save remaining messages if len(currentMessages) > 0 { - chunk := Transcript{ + chunkData, err := json.Marshal(Transcript{ SessionID: transcript.SessionID, Messages: currentMessages, - } - data, err := json.Marshal(chunk) + }) if err != nil { return nil, fmt.Errorf("failed to marshal final transcript chunk: %w", err) } - chunks = append(chunks, data) + chunks = append(chunks, chunkData) } return chunks, nil @@ -182,11 +192,20 @@ func (a *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession if err != nil { return nil, fmt.Errorf("failed to read session: %w", err) } + + // Parse to extract computed fields + modifiedFiles, err := ExtractModifiedFiles(data) + if err != nil { + // Non-fatal: we can still return the session without modified files + modifiedFiles = nil + } + return &agent.AgentSession{ - AgentName: a.Name(), - SessionID: input.SessionID, - SessionRef: input.SessionRef, - NativeData: data, + AgentName: a.Name(), + SessionID: input.SessionID, + SessionRef: input.SessionRef, + NativeData: data, + ModifiedFiles: modifiedFiles, }, nil } diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go index b02ccaf72..d14c8afd7 100644 --- a/cmd/entire/cli/agent/opencode/transcript.go +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -85,6 +85,39 @@ func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset return files, len(t.Messages), nil } +// ExtractModifiedFiles extracts modified file paths from raw transcript bytes. +// This is the bytes-based equivalent of ExtractModifiedFilesFromOffset, used by ReadSession. +func ExtractModifiedFiles(data []byte) ([]string, error) { + t, err := ParseTranscript(data) + if err != nil { + return nil, err + } + + seen := make(map[string]bool) + var files []string + + for _, msg := range t.Messages { + if msg.Role != roleAssistant { + continue + } + for _, part := range msg.Parts { + if part.Type != "tool" || part.State == nil { + continue + } + if !slices.Contains(FileModificationTools, part.Tool) { + continue + } + filePath := extractFilePathFromInput(part.State.Input) + if filePath != "" && !seen[filePath] { + seen[filePath] = true + files = append(files, filePath) + } + } + } + + return files, nil +} + // extractFilePathFromInput extracts the file path from a tool's input map. func extractFilePathFromInput(input map[string]interface{}) string { for _, key := range []string{"file_path", "path", "file", "filename"} { diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go index 4367c5916..4b24e0df1 100644 --- a/cmd/entire/cli/agent/opencode/transcript_test.go +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -248,3 +248,218 @@ func TestCalculateTokenUsage_NonexistentFile(t *testing.T) { t.Errorf("expected nil usage for nonexistent file, got %+v", usage) } } + +func TestSliceFromMessage_ReturnsFullWhenZeroOffset(t *testing.T) { + t.Parallel() + data := []byte(testTranscriptJSON) + + result := SliceFromMessage(data, 0) + if string(result) != string(data) { + t.Error("expected full transcript returned for offset 0") + } +} + +func TestSliceFromMessage_SlicesFromOffset(t *testing.T) { + t.Parallel() + data := []byte(testTranscriptJSON) + + // Offset 2 should return messages starting from index 2 (msg-3, msg-4) + result := SliceFromMessage(data, 2) + if result == nil { + t.Fatal("expected non-nil result") + } + + parsed, err := ParseTranscript(result) + if err != nil { + t.Fatalf("failed to parse sliced transcript: %v", err) + } + if len(parsed.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(parsed.Messages)) + } + if parsed.Messages[0].ID != "msg-3" { + t.Errorf("expected first message ID 'msg-3', got %q", parsed.Messages[0].ID) + } + if parsed.SessionID != "test-session" { + t.Errorf("expected session ID preserved, got %q", parsed.SessionID) + } +} + +func TestSliceFromMessage_OffsetBeyondLength(t *testing.T) { + t.Parallel() + data := []byte(testTranscriptJSON) + + result := SliceFromMessage(data, 100) + if result != nil { + t.Errorf("expected nil for offset beyond message count, got %d bytes", len(result)) + } +} + +func TestSliceFromMessage_EmptyData(t *testing.T) { + t.Parallel() + + result := SliceFromMessage(nil, 0) + if result != nil { + t.Errorf("expected nil for nil data, got %d bytes", len(result)) + } + + // []byte{} with offset 0 returns the input as-is (passthrough) + result = SliceFromMessage([]byte{}, 0) + if len(result) != 0 { + t.Errorf("expected empty bytes, got %d bytes", len(result)) + } +} + +func TestSliceFromMessage_InvalidJSON(t *testing.T) { + t.Parallel() + + result := SliceFromMessage([]byte("not json"), 1) + if result != nil { + t.Errorf("expected nil for invalid JSON, got %d bytes", len(result)) + } +} + +func TestChunkTranscript_SmallContent(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + content := []byte(testTranscriptJSON) + + // maxSize larger than content — should return single chunk + chunks, err := ag.ChunkTranscript(content, len(content)+1000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk for small content, got %d", len(chunks)) + } + if string(chunks[0]) != string(content) { + t.Error("expected chunk to match original content") + } +} + +func TestChunkTranscript_SplitsLargeContent(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + content := []byte(testTranscriptJSON) + + // Use a small maxSize to force splitting (each message is ~200-300 bytes) + chunks, err := ag.ChunkTranscript(content, 350) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks for small maxSize, got %d", len(chunks)) + } + + // Each chunk should be valid JSON + for i, chunk := range chunks { + parsed, parseErr := ParseTranscript(chunk) + if parseErr != nil { + t.Fatalf("chunk %d: failed to parse: %v", i, parseErr) + } + if parsed.SessionID != "test-session" { + t.Errorf("chunk %d: expected session_id 'test-session', got %q", i, parsed.SessionID) + } + if len(parsed.Messages) == 0 { + t.Errorf("chunk %d: expected at least 1 message", i) + } + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + content := []byte(testTranscriptJSON) + + // Split into chunks + chunks, err := ag.ChunkTranscript(content, 350) + if err != nil { + t.Fatalf("chunk error: %v", err) + } + + // Reassemble + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("reassemble error: %v", err) + } + + // Parse both and compare messages + original, parseErr := ParseTranscript(content) + if parseErr != nil { + t.Fatalf("failed to parse original: %v", parseErr) + } + result, parseErr := ParseTranscript(reassembled) + if parseErr != nil { + t.Fatalf("failed to parse reassembled: %v", parseErr) + } + + if result.SessionID != original.SessionID { + t.Errorf("session_id mismatch: %q vs %q", result.SessionID, original.SessionID) + } + if len(result.Messages) != len(original.Messages) { + t.Fatalf("message count mismatch: %d vs %d", len(result.Messages), len(original.Messages)) + } + for i, msg := range result.Messages { + if msg.ID != original.Messages[i].ID { + t.Errorf("message %d: ID mismatch %q vs %q", i, msg.ID, original.Messages[i].ID) + } + } +} + +func TestChunkTranscript_EmptyMessages(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + content := []byte(`{"session_id": "empty", "messages": []}`) + + chunks, err := ag.ChunkTranscript(content, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk for empty messages, got %d", len(chunks)) + } +} + +func TestReassembleTranscript_SingleChunk(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + content := []byte(testTranscriptJSON) + + result, err := ag.ReassembleTranscript([][]byte{content}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result) != string(content) { + t.Error("single chunk reassembly should return original content") + } +} + +func TestReassembleTranscript_Empty(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + + result, err := ag.ReassembleTranscript(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil for empty chunks, got %d bytes", len(result)) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + files, err := ExtractModifiedFiles([]byte(testTranscriptJSON)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d: %v", len(files), files) + } + if files[0] != "main.go" { + t.Errorf("expected first file 'main.go', got %q", files[0]) + } + if files[1] != "util.go" { + t.Errorf("expected second file 'util.go', got %q", files[1]) + } +} diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 1d501b271..70a4497d9 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -465,19 +465,21 @@ func ReadAgentTypeFromTree(tree *object.Tree, checkpointPath string) agent.Agent } } - // Fall back to detecting agent from config files (shadow branches don't have metadata.json) - // Check for OpenCode config - if _, err := tree.Tree(".opencode"); err == nil { - return agent.AgentTypeOpenCode - } - // Check for Gemini config + // Fall back to detecting agent from config files (shadow branches don't have metadata.json). + // Order: Gemini (most specific check), Claude (established default), OpenCode (newest/preview). if _, err := tree.File(".gemini/settings.json"); err == nil { return agent.AgentTypeGemini } - // Check for Claude config (either settings.local.json or settings.json in .claude/) if _, err := tree.Tree(".claude"); err == nil { return agent.AgentTypeClaudeCode } + // OpenCode: .opencode directory or opencode.json config + if _, err := tree.Tree(".opencode"); err == nil { + return agent.AgentTypeOpenCode + } + if _, err := tree.File("opencode.json"); err == nil { + return agent.AgentTypeOpenCode + } return agent.AgentTypeUnknown } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 014a0afb4..e567fea59 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -160,7 +160,7 @@ func buildCondensedTranscriptFromGemini(content []byte) ([]Entry, error) { entries = append(entries, Entry{ Type: EntryTypeTool, ToolName: tc.Name, - ToolDetail: extractGeminiToolDetail(tc.Args), + ToolDetail: extractGenericToolDetail(tc.Args), }) } } @@ -198,7 +198,7 @@ func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) { entries = append(entries, Entry{ Type: EntryTypeTool, ToolName: part.Tool, - ToolDetail: extractOpenCodeToolDetail(part.State.Input), + ToolDetail: extractGenericToolDetail(part.State.Input), }) } } @@ -208,8 +208,9 @@ func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) { return entries, nil } -// extractOpenCodeToolDetail extracts an appropriate detail string from OpenCode tool input. -func extractOpenCodeToolDetail(input map[string]interface{}) string { +// extractGenericToolDetail extracts an appropriate detail string from a tool's input/args map. +// Checks common fields in order of preference. Used by both OpenCode and Gemini condensation. +func extractGenericToolDetail(input map[string]interface{}) string { for _, key := range []string{"description", "command", "file_path", "path", "pattern"} { if v, ok := input[key].(string); ok && v != "" { return v @@ -218,17 +219,6 @@ func extractOpenCodeToolDetail(input map[string]interface{}) string { return "" } -// extractGeminiToolDetail extracts an appropriate detail string from Gemini tool args. -func extractGeminiToolDetail(args map[string]interface{}) string { - // Check common fields in order of preference - for _, key := range []string{"description", "command", "file_path", "path", "pattern"} { - if v, ok := args[key].(string); ok && v != "" { - return v - } - } - return "" -} - // BuildCondensedTranscript extracts a condensed view of the transcript. // It processes user prompts, assistant responses, and tool calls into // a simplified format suitable for LLM summarization. diff --git a/cmd/entire/cli/summarize/summarize_test.go b/cmd/entire/cli/summarize/summarize_test.go index debf68e49..729f203c5 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -519,7 +519,7 @@ func TestBuildCondensedTranscriptFromBytes_GeminiToolCallArgShapes(t *testing.T) t.Errorf("entry 0: expected tool detail /tmp/out.txt, got %s", entries[0].ToolDetail) } - // "description" arg (checked before "pattern" in extractGeminiToolDetail) + // "description" arg (checked before "pattern" in extractGenericToolDetail) if entries[1].ToolDetail != "Search for TODOs" { t.Errorf("entry 1: expected tool detail 'Search for TODOs', got %s", entries[1].ToolDetail) } @@ -695,6 +695,114 @@ func TestGenerateFromTranscript_NilGenerator(t *testing.T) { } } +func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T) { + ocJSON := `{ + "session_id": "test-session", + "messages": [ + {"id": "msg-1", "role": "user", "content": "Fix the bug in main.go", "time": {"created": 1708300000}}, + {"id": "msg-2", "role": "assistant", "content": "I'll fix the bug.", "time": {"created": 1708300001}} + ] + }` + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + if entries[0].Type != EntryTypeUser { + t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type) + } + if entries[0].Content != "Fix the bug in main.go" { + t.Errorf("entry 0: unexpected content: %s", entries[0].Content) + } + + if entries[1].Type != EntryTypeAssistant { + t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type) + } + if entries[1].Content != "I'll fix the bug." { + t.Errorf("entry 1: unexpected content: %s", entries[1].Content) + } +} + +func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) { + ocJSON := `{ + "session_id": "test-session", + "messages": [ + {"id": "msg-1", "role": "user", "content": "Edit main.go", "time": {"created": 1708300000}}, + {"id": "msg-2", "role": "assistant", "content": "Editing now.", "time": {"created": 1708300001}, + "parts": [ + {"type": "text", "text": "Editing now."}, + {"type": "tool", "tool": "edit_file", "callID": "call-1", + "state": {"status": "completed", "input": {"file_path": "main.go"}, "output": "Applied"}}, + {"type": "tool", "tool": "run_command", "callID": "call-2", + "state": {"status": "completed", "input": {"command": "go test ./..."}, "output": "PASS"}} + ] + } + ] + }` + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // user + assistant + 2 tool calls + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + if entries[2].Type != EntryTypeTool { + t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type) + } + if entries[2].ToolName != "edit_file" { + t.Errorf("entry 2: expected tool name edit_file, got %s", entries[2].ToolName) + } + if entries[2].ToolDetail != "main.go" { + t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail) + } + + if entries[3].ToolName != "run_command" { + t.Errorf("entry 3: expected tool name run_command, got %s", entries[3].ToolName) + } + if entries[3].ToolDetail != "go test ./..." { + t.Errorf("entry 3: expected tool detail 'go test ./...', got %s", entries[3].ToolDetail) + } +} + +func TestBuildCondensedTranscriptFromBytes_OpenCodeSkipsEmptyContent(t *testing.T) { + ocJSON := `{ + "session_id": "test-session", + "messages": [ + {"id": "msg-1", "role": "user", "content": "", "time": {"created": 1708300000}}, + {"id": "msg-2", "role": "assistant", "content": "", "time": {"created": 1708300001}}, + {"id": "msg-3", "role": "user", "content": "Real prompt", "time": {"created": 1708300010}} + ] + }` + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("expected 1 entry (empty content skipped), got %d", len(entries)) + } + if entries[0].Content != "Real prompt" { + t.Errorf("expected 'Real prompt', got %s", entries[0].Content) + } +} + +func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSON(t *testing.T) { + _, err := BuildCondensedTranscriptFromBytes([]byte(`not json`), agent.AgentTypeOpenCode) + if err == nil { + t.Error("expected error for invalid OpenCode JSON") + } +} + // mustMarshal is a test helper that marshals v to JSON, failing the test on error. func mustMarshal(t *testing.T, v interface{}) json.RawMessage { t.Helper() From c766e65015a457b62bb590b510a27279c912f2a5 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 18 Feb 2026 21:09:33 -0800 Subject: [PATCH 3/7] Store OpenCode transcripts outside repo --- .../cli/agent/opencode/entire_plugin.ts | 5 +++- cmd/entire/cli/agent/opencode/opencode.go | 27 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts index da0328bed..d7298455c 100644 --- a/cmd/entire/cli/agent/opencode/entire_plugin.ts +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -6,7 +6,10 @@ import type { Plugin } from "@opencode-ai/plugin" export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { const ENTIRE_CMD = "__ENTIRE_CMD__" - const transcriptDir = `${directory}/.opencode/sessions/entire` + // Store transcripts outside the repo (matches Go SanitizePathForOpenCode). + const home = Bun.env.HOME ?? "" + const sanitized = directory.replace(/[^a-zA-Z0-9]/g, "-") + const transcriptDir = `${home}/.opencode/sessions/entire/${sanitized}` const seenUserMessages = new Set() // In-memory stores — used to write transcripts without relying on the SDK API, diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 0e02c44fe..a9a3a3441 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "regexp" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -175,9 +176,22 @@ func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } +// GetSessionDir returns the directory where Entire stores OpenCode session transcripts. +// Transcripts are stored outside the repo at ~/.opencode/sessions/entire// +// to avoid polluting the working tree and checkpoint metadata. func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) { - // OpenCode transcript files are written by the plugin to .opencode/sessions/entire/ - return filepath.Join(repoPath, ".opencode", "sessions", "entire"), nil + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + projectDir := SanitizePathForOpenCode(repoPath) + return filepath.Join(homeDir, ".opencode", "sessions", "entire", projectDir), nil } func (a *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { @@ -233,3 +247,12 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { func (a *OpenCodeAgent) FormatResumeCommand(sessionID string) string { return "opencode --session " + sessionID } + +// nonAlphanumericRegex matches any non-alphanumeric character. +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// SanitizePathForOpenCode converts a path to a safe directory name. +// Replaces any non-alphanumeric character with a dash (same approach as Claude/Gemini). +func SanitizePathForOpenCode(path string) string { + return nonAlphanumericRegex.ReplaceAllString(path, "-") +} From cbd187da552c3432a682dcd6f015a26c8c382901 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Thu, 19 Feb 2026 19:51:34 +0100 Subject: [PATCH 4/7] Review Feedback (#423) * use tmpDir for generated OpenCode session logs, we don't need them to stay around Entire-Checkpoint: 5c2efb4784e9 * use JSONL instead of JSON, some test fixes Entire-Checkpoint: 61dc94e5a51e --- .../cli/agent/opencode/entire_plugin.ts | 42 ++- .../cli/agent/opencode/lifecycle_test.go | 4 +- cmd/entire/cli/agent/opencode/opencode.go | 110 +------ cmd/entire/cli/agent/opencode/transcript.go | 129 ++++---- .../cli/agent/opencode/transcript_test.go | 260 ++++++--------- cmd/entire/cli/agent/opencode/types.go | 18 +- cmd/entire/cli/explain.go | 13 +- cmd/entire/cli/integration_test/agent_test.go | 307 ++++++++++++++++++ cmd/entire/cli/integration_test/hooks.go | 229 +++++++++++++ .../integration_test/opencode_hooks_test.go | 276 ++++++++++++++++ cmd/entire/cli/integration_test/testenv.go | 51 +-- .../strategy/manual_commit_condensation.go | 13 +- cmd/entire/cli/summarize/summarize.go | 8 +- cmd/entire/cli/summarize/summarize_test.go | 69 ++-- 14 files changed, 1076 insertions(+), 453 deletions(-) create mode 100644 cmd/entire/cli/integration_test/opencode_hooks_test.go diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts index d7298455c..d1c6b5f3d 100644 --- a/cmd/entire/cli/agent/opencode/entire_plugin.ts +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -3,13 +3,15 @@ // Do not edit manually — changes will be overwritten on next install. // Requires Bun runtime (used by OpenCode's plugin system for loading ESM plugins). import type { Plugin } from "@opencode-ai/plugin" +import { tmpdir } from "node:os" export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { const ENTIRE_CMD = "__ENTIRE_CMD__" - // Store transcripts outside the repo (matches Go SanitizePathForOpenCode). - const home = Bun.env.HOME ?? "" + // Store transcripts in a temp directory — these are ephemeral handoff files + // between the plugin and the Go hook handler. Once checkpointed, the data + // lives on git refs and the file is disposable. const sanitized = directory.replace(/[^a-zA-Z0-9]/g, "-") - const transcriptDir = `${home}/.opencode/sessions/entire/${sanitized}` + const transcriptDir = `${tmpdir()}/entire-opencode/${sanitized}` const seenUserMessages = new Set() // In-memory stores — used to write transcripts without relying on the SDK API, @@ -44,7 +46,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { .join("\n") } - /** Format a message for the transcript using its accumulated parts. */ + /** Format a message object from its accumulated parts. */ function formatMessageFromStore(msg: any) { const parts = partStore.get(msg.id) ?? [] return { @@ -84,20 +86,17 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { } /** - * Write transcript from in-memory stores (messageStore + partStore). + * Write transcript as JSONL (one message per line) from in-memory stores. * This does NOT call the SDK API, so it works even during shutdown. */ async function writeTranscriptFromMemory(sessionID: string): Promise { - const transcriptPath = `${transcriptDir}/${sessionID}.json` + const transcriptPath = `${transcriptDir}/${sessionID}.jsonl` try { const messages = Array.from(messageStore.values()) .sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0)) - const transcript = { - session_id: sessionID, - messages: messages.map(formatMessageFromStore), - } - await Bun.write(transcriptPath, JSON.stringify(transcript)) + const lines = messages.map(msg => JSON.stringify(formatMessageFromStore(msg))) + await Bun.write(transcriptPath, lines.join("\n") + "\n") } catch { // Silently ignore write failures } @@ -106,20 +105,19 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { /** * Try to fetch messages via the SDK API (returns messages with parts inline) - * and write transcript. Falls back to in-memory stores if the API is unavailable. + * and write transcript as JSONL. Falls back to in-memory stores if the API is unavailable. */ async function writeTranscriptWithFallback(sessionID: string): Promise { - const transcriptPath = `${transcriptDir}/${sessionID}.json` + const transcriptPath = `${transcriptDir}/${sessionID}.jsonl` try { const response = await client.session.message.list({ path: { id: sessionID } }) // API returns Array<{ info: Message, parts: Array }> const items = response.data ?? [] - const transcript = { - session_id: sessionID, - messages: items.map((item: any) => formatMessageFromAPI(item.info, item.parts ?? [])), - } - await Bun.write(transcriptPath, JSON.stringify(transcript)) + const lines = items.map((item: any) => + JSON.stringify(formatMessageFromAPI(item.info, item.parts ?? [])) + ) + await Bun.write(transcriptPath, lines.join("\n") + "\n") return transcriptPath } catch { // API unavailable (likely shutting down) — fall back to in-memory stores @@ -136,7 +134,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { currentSessionID = session.id await callHook("session-start", { session_id: session.id, - transcript_path: `${transcriptDir}/${session.id}.json`, + transcript_path: `${transcriptDir}/${session.id}.jsonl`, }) break } @@ -173,7 +171,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { if (sessionID) { await callHook("turn-start", { session_id: sessionID, - transcript_path: `${transcriptDir}/${sessionID}.json`, + transcript_path: `${transcriptDir}/${sessionID}.jsonl`, prompt: part.text ?? "", }) } @@ -197,7 +195,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { if (!sessionID) break await callHook("compaction", { session_id: sessionID, - transcript_path: `${transcriptDir}/${sessionID}.json`, + transcript_path: `${transcriptDir}/${sessionID}.jsonl`, }) break } @@ -215,7 +213,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { currentSessionID = null await callHook("session-end", { session_id: session.id, - transcript_path: `${transcriptDir}/${session.id}.json`, + transcript_path: `${transcriptDir}/${session.id}.jsonl`, }) break } diff --git a/cmd/entire/cli/agent/opencode/lifecycle_test.go b/cmd/entire/cli/agent/opencode/lifecycle_test.go index c0a372462..2713ab540 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle_test.go +++ b/cmd/entire/cli/agent/opencode/lifecycle_test.go @@ -11,7 +11,7 @@ func TestParseHookEvent_SessionStart(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - input := `{"session_id": "sess-abc123", "transcript_path": "/tmp/project/.opencode/sessions/entire/sess-abc123.json"}` + input := `{"session_id": "sess-abc123", "transcript_path": "/tmp/entire-opencode/-project/sess-abc123.json"}` event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) @@ -27,7 +27,7 @@ func TestParseHookEvent_SessionStart(t *testing.T) { if event.SessionID != "sess-abc123" { t.Errorf("expected session_id 'sess-abc123', got %q", event.SessionID) } - if event.SessionRef != "/tmp/project/.opencode/sessions/entire/sess-abc123.json" { + if event.SessionRef != "/tmp/entire-opencode/-project/sess-abc123.json" { t.Errorf("unexpected session ref: %q", event.SessionRef) } } diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index a9a3a3441..63773e41f 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -2,7 +2,6 @@ package opencode import ( - "encoding/json" "errors" "fmt" "io" @@ -61,99 +60,17 @@ func (a *OpenCodeAgent) ReadTranscript(sessionRef string) ([]byte, error) { } func (a *OpenCodeAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { - // OpenCode uses JSON format (like Gemini). Parse and split by messages. - if len(content) <= maxSize { - return [][]byte{content}, nil - } - - var transcript Transcript - if err := json.Unmarshal(content, &transcript); err != nil { - // Fallback to JSONL chunking if not valid JSON - chunks, chunkErr := agent.ChunkJSONL(content, maxSize) - if chunkErr != nil { - return nil, fmt.Errorf("failed to chunk transcript as JSONL: %w", chunkErr) - } - return chunks, nil - } - - if len(transcript.Messages) == 0 { - return [][]byte{content}, nil - } - - // Pre-marshal each message to avoid O(n²) re-serialization. - // Track running size and split at chunk boundaries (same approach as Gemini). - var chunks [][]byte - var currentMessages []Message - baseSize := len(fmt.Sprintf(`{"session_id":%q,"messages":[]}`, transcript.SessionID)) - currentSize := baseSize - - for _, msg := range transcript.Messages { - msgBytes, err := json.Marshal(msg) - if err != nil { - continue // Skip messages that fail to marshal - } - msgSize := len(msgBytes) + 1 // +1 for comma separator - - if currentSize+msgSize > maxSize && len(currentMessages) > 0 { - // Save current chunk - chunkData, marshalErr := json.Marshal(Transcript{ - SessionID: transcript.SessionID, - Messages: currentMessages, - }) - if marshalErr != nil { - return nil, fmt.Errorf("failed to marshal transcript chunk: %w", marshalErr) - } - chunks = append(chunks, chunkData) - - // Start new chunk - currentMessages = nil - currentSize = baseSize - } - - currentMessages = append(currentMessages, msg) - currentSize += msgSize - } - - // Save remaining messages - if len(currentMessages) > 0 { - chunkData, err := json.Marshal(Transcript{ - SessionID: transcript.SessionID, - Messages: currentMessages, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal final transcript chunk: %w", err) - } - chunks = append(chunks, chunkData) + // OpenCode uses JSONL (one message per line) — use the shared JSONL chunker. + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk opencode transcript: %w", err) } - return chunks, nil } func (a *OpenCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { - if len(chunks) == 0 { - return nil, nil - } - if len(chunks) == 1 { - return chunks[0], nil - } - - var combined Transcript - for i, chunk := range chunks { - var t Transcript - if err := json.Unmarshal(chunk, &t); err != nil { - return nil, fmt.Errorf("failed to parse transcript chunk %d: %w", i, err) - } - if i == 0 { - combined.SessionID = t.SessionID - } - combined.Messages = append(combined.Messages, t.Messages...) - } - - data, err := json.Marshal(combined) - if err != nil { - return nil, fmt.Errorf("failed to marshal reassembled transcript: %w", err) - } - return data, nil + // JSONL reassembly is simple concatenation. + return agent.ReassembleJSONL(chunks), nil } // --- Legacy methods --- @@ -177,25 +94,22 @@ func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { } // GetSessionDir returns the directory where Entire stores OpenCode session transcripts. -// Transcripts are stored outside the repo at ~/.opencode/sessions/entire// -// to avoid polluting the working tree and checkpoint metadata. +// Transcripts are ephemeral handoff files between the TS plugin and the Go hook handler. +// Once checkpointed, the data lives on git refs and the file is disposable. +// Stored in os.TempDir()/entire-opencode// to avoid squatting on +// OpenCode's own directories (~/.opencode/ is project-level, not home-level). func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) { // Check for test environment override if override := os.Getenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR"); override != "" { return override, nil } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - projectDir := SanitizePathForOpenCode(repoPath) - return filepath.Join(homeDir, ".opencode", "sessions", "entire", projectDir), nil + return filepath.Join(os.TempDir(), "entire-opencode", projectDir), nil } func (a *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { - return filepath.Join(sessionDir, agentSessionID+".json") + return filepath.Join(sessionDir, agentSessionID+".jsonl") } func (a *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go index d14c8afd7..17d9075bd 100644 --- a/cmd/entire/cli/agent/opencode/transcript.go +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -1,8 +1,11 @@ package opencode import ( + "bufio" + "bytes" "encoding/json" "fmt" + "io" "os" "slices" @@ -15,43 +18,60 @@ var ( _ agent.TokenCalculator = (*OpenCodeAgent)(nil) ) -// ParseTranscript parses raw JSON content into a transcript structure. -func ParseTranscript(data []byte) (*Transcript, error) { - var t Transcript - if err := json.Unmarshal(data, &t); err != nil { - return nil, fmt.Errorf("failed to parse opencode transcript: %w", err) +// ParseMessages parses JSONL content (one Message per line) into a slice of Messages. +func ParseMessages(data []byte) ([]Message, error) { + if len(data) == 0 { + return nil, nil } - return &t, nil + + var messages []Message + reader := bufio.NewReader(bytes.NewReader(data)) + + for { + lineBytes, err := reader.ReadBytes('\n') + if err != nil && err != io.EOF { + return nil, fmt.Errorf("failed to read opencode transcript: %w", err) + } + + if len(bytes.TrimSpace(lineBytes)) > 0 { + var msg Message + if jsonErr := json.Unmarshal(lineBytes, &msg); jsonErr == nil { + messages = append(messages, msg) + } + } + + if err == io.EOF { + break + } + } + + return messages, nil } -// parseTranscriptFile reads and parses a transcript JSON file. -func parseTranscriptFile(path string) (*Transcript, error) { +// parseMessagesFromFile reads and parses a JSONL transcript file. +func parseMessagesFromFile(path string) ([]Message, error) { data, err := os.ReadFile(path) //nolint:gosec // Path from agent hook if err != nil { return nil, err //nolint:wrapcheck // Callers check os.IsNotExist on this error } - var t Transcript - if err := json.Unmarshal(data, &t); err != nil { - return nil, fmt.Errorf("failed to parse opencode transcript: %w", err) - } - return &t, nil + return ParseMessages(data) } -// GetTranscriptPosition returns the number of messages in the transcript. +// GetTranscriptPosition returns the number of JSONL lines in the transcript. func (a *OpenCodeAgent) GetTranscriptPosition(path string) (int, error) { - t, err := parseTranscriptFile(path) + messages, err := parseMessagesFromFile(path) if err != nil { if os.IsNotExist(err) { return 0, nil } return 0, err } - return len(t.Messages), nil + return len(messages), nil } // ExtractModifiedFilesFromOffset extracts files modified by tool calls from the given message offset. func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { - t, err := parseTranscriptFile(path) + messages, err := parseMessagesFromFile(path) if err != nil { if os.IsNotExist(err) { return nil, 0, nil @@ -62,8 +82,8 @@ func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset seen := make(map[string]bool) var files []string - for i := startOffset; i < len(t.Messages); i++ { - msg := t.Messages[i] + for i := startOffset; i < len(messages); i++ { + msg := messages[i] if msg.Role != roleAssistant { continue } @@ -82,13 +102,13 @@ func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset } } - return files, len(t.Messages), nil + return files, len(messages), nil } -// ExtractModifiedFiles extracts modified file paths from raw transcript bytes. +// ExtractModifiedFiles extracts modified file paths from raw JSONL transcript bytes. // This is the bytes-based equivalent of ExtractModifiedFilesFromOffset, used by ReadSession. func ExtractModifiedFiles(data []byte) ([]string, error) { - t, err := ParseTranscript(data) + messages, err := ParseMessages(data) if err != nil { return nil, err } @@ -96,7 +116,7 @@ func ExtractModifiedFiles(data []byte) ([]string, error) { seen := make(map[string]bool) var files []string - for _, msg := range t.Messages { + for _, msg := range messages { if msg.Role != roleAssistant { continue } @@ -132,7 +152,7 @@ func extractFilePathFromInput(input map[string]interface{}) string { // ExtractPrompts extracts user prompt strings from the transcript starting at the given offset. func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { - t, err := parseTranscriptFile(sessionRef) + messages, err := parseMessagesFromFile(sessionRef) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -141,8 +161,8 @@ func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]str } var prompts []string - for i := fromOffset; i < len(t.Messages); i++ { - msg := t.Messages[i] + for i := fromOffset; i < len(messages); i++ { + msg := messages[i] if msg.Role == roleUser && msg.Content != "" { prompts = append(prompts, msg.Content) } @@ -153,7 +173,7 @@ func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]str // ExtractSummary extracts the last assistant message content as a summary. func (a *OpenCodeAgent) ExtractSummary(sessionRef string) (string, error) { - t, err := parseTranscriptFile(sessionRef) + messages, err := parseMessagesFromFile(sessionRef) if err != nil { if os.IsNotExist(err) { return "", nil @@ -161,8 +181,8 @@ func (a *OpenCodeAgent) ExtractSummary(sessionRef string) (string, error) { return "", err } - for i := len(t.Messages) - 1; i >= 0; i-- { - msg := t.Messages[i] + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] if msg.Role == roleAssistant && msg.Content != "" { return msg.Content, nil } @@ -171,44 +191,16 @@ func (a *OpenCodeAgent) ExtractSummary(sessionRef string) (string, error) { return "", nil } -// SliceFromMessage returns a JSON transcript containing only messages from startMessageIndex onward. -// This is used by explain to scope a full transcript to a specific checkpoint's portion. -func SliceFromMessage(data []byte, startMessageIndex int) []byte { - if len(data) == 0 || startMessageIndex <= 0 { - return data - } - - t, err := ParseTranscript(data) - if err != nil { - return nil - } - - if startMessageIndex >= len(t.Messages) { - return nil - } - - scoped := &Transcript{ - SessionID: t.SessionID, - Messages: t.Messages[startMessageIndex:], - } - - out, err := json.Marshal(scoped) - if err != nil { - return nil - } - return out -} - -// ExtractAllUserPrompts extracts all user prompts from raw transcript bytes. +// ExtractAllUserPrompts extracts all user prompts from raw JSONL transcript bytes. // This is a package-level function used by the condensation path. func ExtractAllUserPrompts(data []byte) ([]string, error) { - t, err := ParseTranscript(data) + messages, err := ParseMessages(data) if err != nil { return nil, err } var prompts []string - for _, msg := range t.Messages { + for _, msg := range messages { if msg.Role == roleUser && msg.Content != "" { prompts = append(prompts, msg.Content) } @@ -216,17 +208,18 @@ func ExtractAllUserPrompts(data []byte) ([]string, error) { return prompts, nil } -// CalculateTokenUsageFromBytes computes token usage from raw transcript bytes starting at the given message offset. +// CalculateTokenUsageFromBytes computes token usage from raw JSONL transcript bytes +// starting at the given message offset. // This is a package-level function used by the condensation path (which has bytes, not a file path). func CalculateTokenUsageFromBytes(data []byte, startMessageIndex int) *agent.TokenUsage { - t, err := ParseTranscript(data) - if err != nil || t == nil { + messages, err := ParseMessages(data) + if err != nil || messages == nil { return &agent.TokenUsage{} } usage := &agent.TokenUsage{} - for i := startMessageIndex; i < len(t.Messages); i++ { - msg := t.Messages[i] + for i := startMessageIndex; i < len(messages); i++ { + msg := messages[i] if msg.Role != roleAssistant || msg.Tokens == nil { continue } @@ -242,7 +235,7 @@ func CalculateTokenUsageFromBytes(data []byte, startMessageIndex int) *agent.Tok // CalculateTokenUsage computes token usage from assistant messages starting at the given offset. func (a *OpenCodeAgent) CalculateTokenUsage(sessionRef string, fromOffset int) (*agent.TokenUsage, error) { - t, err := parseTranscriptFile(sessionRef) + messages, err := parseMessagesFromFile(sessionRef) if err != nil { if os.IsNotExist(err) { return nil, nil //nolint:nilnil // nil usage for nonexistent file is expected @@ -251,8 +244,8 @@ func (a *OpenCodeAgent) CalculateTokenUsage(sessionRef string, fromOffset int) ( } usage := &agent.TokenUsage{} - for i := fromOffset; i < len(t.Messages); i++ { - msg := t.Messages[i] + for i := fromOffset; i < len(messages); i++ { + msg := messages[i] if msg.Role != roleAssistant || msg.Tokens == nil { continue } diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go index 4b24e0df1..1fa4f825e 100644 --- a/cmd/entire/cli/agent/opencode/transcript_test.go +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -8,70 +8,82 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) -// Compile-time checks -var ( - _ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil) - _ agent.TokenCalculator = (*OpenCodeAgent)(nil) -) - -const testTranscriptJSON = `{ - "session_id": "test-session", - "messages": [ - { - "id": "msg-1", - "role": "user", - "content": "Fix the bug in main.go", - "time": {"created": 1708300000} - }, - { - "id": "msg-2", - "role": "assistant", - "content": "I'll fix the bug.", - "time": {"created": 1708300001, "completed": 1708300005}, - "tokens": {"input": 150, "output": 80, "reasoning": 10, "cache": {"read": 5, "write": 15}}, - "cost": 0.003, - "parts": [ - {"type": "text", "text": "I'll fix the bug."}, - {"type": "tool", "tool": "edit_file", "callID": "call-1", - "state": {"status": "completed", "input": {"file_path": "main.go"}, "output": "Applied edit"}} - ] - }, - { - "id": "msg-3", - "role": "user", - "content": "Also fix util.go", - "time": {"created": 1708300010} - }, - { - "id": "msg-4", - "role": "assistant", - "content": "Done fixing util.go.", - "time": {"created": 1708300011, "completed": 1708300015}, - "tokens": {"input": 200, "output": 100, "reasoning": 5, "cache": {"read": 10, "write": 20}}, - "cost": 0.005, - "parts": [ - {"type": "tool", "tool": "write_file", "callID": "call-2", - "state": {"status": "completed", "input": {"file_path": "util.go"}, "output": "File written"}}, - {"type": "text", "text": "Done fixing util.go."} - ] - } - ] -}` +// testTranscriptJSONL is a JSONL transcript with 4 messages (one per line). +const testTranscriptJSONL = `{"id":"msg-1","role":"user","content":"Fix the bug in main.go","time":{"created":1708300000}} +{"id":"msg-2","role":"assistant","content":"I'll fix the bug.","time":{"created":1708300001,"completed":1708300005},"tokens":{"input":150,"output":80,"reasoning":10,"cache":{"read":5,"write":15}},"cost":0.003,"parts":[{"type":"text","text":"I'll fix the bug."},{"type":"tool","tool":"edit","callID":"call-1","state":{"status":"completed","input":{"file_path":"main.go"},"output":"Applied edit"}}]} +{"id":"msg-3","role":"user","content":"Also fix util.go","time":{"created":1708300010}} +{"id":"msg-4","role":"assistant","content":"Done fixing util.go.","time":{"created":1708300011,"completed":1708300015},"tokens":{"input":200,"output":100,"reasoning":5,"cache":{"read":10,"write":20}},"cost":0.005,"parts":[{"type":"tool","tool":"write","callID":"call-2","state":{"status":"completed","input":{"file_path":"util.go"},"output":"File written"}},{"type":"text","text":"Done fixing util.go."}]} +` func writeTestTranscript(t *testing.T, content string) string { t.Helper() dir := t.TempDir() - path := filepath.Join(dir, "test-session.json") + path := filepath.Join(dir, "test-session.jsonl") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write test transcript: %v", err) } return path } +func TestParseMessages(t *testing.T) { + t.Parallel() + + messages, err := ParseMessages([]byte(testTranscriptJSONL)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(messages) != 4 { + t.Fatalf("expected 4 messages, got %d", len(messages)) + } + if messages[0].ID != "msg-1" { + t.Errorf("expected first message ID 'msg-1', got %q", messages[0].ID) + } + if messages[0].Role != "user" { + t.Errorf("expected first message role 'user', got %q", messages[0].Role) + } +} + +func TestParseMessages_Empty(t *testing.T) { + t.Parallel() + + messages, err := ParseMessages(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if messages != nil { + t.Errorf("expected nil for nil data, got %d messages", len(messages)) + } + + messages, err = ParseMessages([]byte("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if messages != nil { + t.Errorf("expected nil for empty data, got %d messages", len(messages)) + } +} + +func TestParseMessages_InvalidLines(t *testing.T) { + t.Parallel() + + // Invalid lines are silently skipped + data := "not json\n{\"id\":\"msg-1\",\"role\":\"user\",\"content\":\"hello\"}\n" + messages, err := ParseMessages([]byte(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected 1 valid message, got %d", len(messages)) + } + if messages[0].Content != "hello" { + t.Errorf("expected content 'hello', got %q", messages[0].Content) + } +} + func TestGetTranscriptPosition(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) pos, err := ag.GetTranscriptPosition(path) if err != nil { @@ -86,7 +98,7 @@ func TestGetTranscriptPosition_NonexistentFile(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - pos, err := ag.GetTranscriptPosition("/nonexistent/path.json") + pos, err := ag.GetTranscriptPosition("/nonexistent/path.jsonl") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -98,7 +110,7 @@ func TestGetTranscriptPosition_NonexistentFile(t *testing.T) { func TestExtractModifiedFilesFromOffset(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) // From offset 0 — should get both main.go and util.go files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) @@ -131,7 +143,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { func TestExtractPrompts(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) // From offset 0 — both prompts prompts, err := ag.ExtractPrompts(path, 0) @@ -161,7 +173,7 @@ func TestExtractPrompts(t *testing.T) { func TestExtractSummary(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) summary, err := ag.ExtractSummary(path) if err != nil { @@ -175,7 +187,7 @@ func TestExtractSummary(t *testing.T) { func TestExtractSummary_EmptyTranscript(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, `{"session_id": "empty", "messages": []}`) + path := writeTestTranscript(t, "") summary, err := ag.ExtractSummary(path) if err != nil { @@ -189,7 +201,7 @@ func TestExtractSummary_EmptyTranscript(t *testing.T) { func TestCalculateTokenUsage(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) // From offset 0 — both assistant messages usage, err := ag.CalculateTokenUsage(path, 0) @@ -219,7 +231,7 @@ func TestCalculateTokenUsage(t *testing.T) { func TestCalculateTokenUsage_FromOffset(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - path := writeTestTranscript(t, testTranscriptJSON) + path := writeTestTranscript(t, testTranscriptJSONL) usage, err := ag.CalculateTokenUsage(path, 2) if err != nil { @@ -240,7 +252,7 @@ func TestCalculateTokenUsage_NonexistentFile(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - usage, err := ag.CalculateTokenUsage("/nonexistent/path.json", 0) + usage, err := ag.CalculateTokenUsage("/nonexistent/path.jsonl", 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -249,79 +261,10 @@ func TestCalculateTokenUsage_NonexistentFile(t *testing.T) { } } -func TestSliceFromMessage_ReturnsFullWhenZeroOffset(t *testing.T) { - t.Parallel() - data := []byte(testTranscriptJSON) - - result := SliceFromMessage(data, 0) - if string(result) != string(data) { - t.Error("expected full transcript returned for offset 0") - } -} - -func TestSliceFromMessage_SlicesFromOffset(t *testing.T) { - t.Parallel() - data := []byte(testTranscriptJSON) - - // Offset 2 should return messages starting from index 2 (msg-3, msg-4) - result := SliceFromMessage(data, 2) - if result == nil { - t.Fatal("expected non-nil result") - } - - parsed, err := ParseTranscript(result) - if err != nil { - t.Fatalf("failed to parse sliced transcript: %v", err) - } - if len(parsed.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(parsed.Messages)) - } - if parsed.Messages[0].ID != "msg-3" { - t.Errorf("expected first message ID 'msg-3', got %q", parsed.Messages[0].ID) - } - if parsed.SessionID != "test-session" { - t.Errorf("expected session ID preserved, got %q", parsed.SessionID) - } -} - -func TestSliceFromMessage_OffsetBeyondLength(t *testing.T) { - t.Parallel() - data := []byte(testTranscriptJSON) - - result := SliceFromMessage(data, 100) - if result != nil { - t.Errorf("expected nil for offset beyond message count, got %d bytes", len(result)) - } -} - -func TestSliceFromMessage_EmptyData(t *testing.T) { - t.Parallel() - - result := SliceFromMessage(nil, 0) - if result != nil { - t.Errorf("expected nil for nil data, got %d bytes", len(result)) - } - - // []byte{} with offset 0 returns the input as-is (passthrough) - result = SliceFromMessage([]byte{}, 0) - if len(result) != 0 { - t.Errorf("expected empty bytes, got %d bytes", len(result)) - } -} - -func TestSliceFromMessage_InvalidJSON(t *testing.T) { - t.Parallel() - - result := SliceFromMessage([]byte("not json"), 1) - if result != nil { - t.Errorf("expected nil for invalid JSON, got %d bytes", len(result)) - } -} - func TestChunkTranscript_SmallContent(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - content := []byte(testTranscriptJSON) + content := []byte(testTranscriptJSONL) // maxSize larger than content — should return single chunk chunks, err := ag.ChunkTranscript(content, len(content)+1000) @@ -339,10 +282,10 @@ func TestChunkTranscript_SmallContent(t *testing.T) { func TestChunkTranscript_SplitsLargeContent(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - content := []byte(testTranscriptJSON) + content := []byte(testTranscriptJSONL) - // Use a small maxSize to force splitting (each message is ~200-300 bytes) - chunks, err := ag.ChunkTranscript(content, 350) + // Use a maxSize that fits individual lines but forces splitting (assistant lines are ~370-400 bytes) + chunks, err := ag.ChunkTranscript(content, 500) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -350,16 +293,13 @@ func TestChunkTranscript_SplitsLargeContent(t *testing.T) { t.Fatalf("expected multiple chunks for small maxSize, got %d", len(chunks)) } - // Each chunk should be valid JSON + // Each chunk should contain valid JSONL for i, chunk := range chunks { - parsed, parseErr := ParseTranscript(chunk) + messages, parseErr := ParseMessages(chunk) if parseErr != nil { t.Fatalf("chunk %d: failed to parse: %v", i, parseErr) } - if parsed.SessionID != "test-session" { - t.Errorf("chunk %d: expected session_id 'test-session', got %q", i, parsed.SessionID) - } - if len(parsed.Messages) == 0 { + if len(messages) == 0 { t.Errorf("chunk %d: expected at least 1 message", i) } } @@ -368,10 +308,10 @@ func TestChunkTranscript_SplitsLargeContent(t *testing.T) { func TestChunkTranscript_RoundTrip(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - content := []byte(testTranscriptJSON) + content := []byte(testTranscriptJSONL) - // Split into chunks - chunks, err := ag.ChunkTranscript(content, 350) + // Split into chunks (maxSize must fit individual JSONL lines) + chunks, err := ag.ChunkTranscript(content, 500) if err != nil { t.Fatalf("chunk error: %v", err) } @@ -383,46 +323,42 @@ func TestChunkTranscript_RoundTrip(t *testing.T) { } // Parse both and compare messages - original, parseErr := ParseTranscript(content) + original, parseErr := ParseMessages(content) if parseErr != nil { t.Fatalf("failed to parse original: %v", parseErr) } - result, parseErr := ParseTranscript(reassembled) + result, parseErr := ParseMessages(reassembled) if parseErr != nil { t.Fatalf("failed to parse reassembled: %v", parseErr) } - if result.SessionID != original.SessionID { - t.Errorf("session_id mismatch: %q vs %q", result.SessionID, original.SessionID) + if len(result) != len(original) { + t.Fatalf("message count mismatch: %d vs %d", len(result), len(original)) } - if len(result.Messages) != len(original.Messages) { - t.Fatalf("message count mismatch: %d vs %d", len(result.Messages), len(original.Messages)) - } - for i, msg := range result.Messages { - if msg.ID != original.Messages[i].ID { - t.Errorf("message %d: ID mismatch %q vs %q", i, msg.ID, original.Messages[i].ID) + for i, msg := range result { + if msg.ID != original[i].ID { + t.Errorf("message %d: ID mismatch %q vs %q", i, msg.ID, original[i].ID) } } } -func TestChunkTranscript_EmptyMessages(t *testing.T) { +func TestChunkTranscript_EmptyContent(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - content := []byte(`{"session_id": "empty", "messages": []}`) - chunks, err := ag.ChunkTranscript(content, 100) + chunks, err := ag.ChunkTranscript([]byte(""), 100) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(chunks) != 1 { - t.Fatalf("expected 1 chunk for empty messages, got %d", len(chunks)) + if len(chunks) != 0 { + t.Fatalf("expected 0 chunks for empty content, got %d", len(chunks)) } } func TestReassembleTranscript_SingleChunk(t *testing.T) { t.Parallel() ag := &OpenCodeAgent{} - content := []byte(testTranscriptJSON) + content := []byte(testTranscriptJSONL) result, err := ag.ReassembleTranscript([][]byte{content}) if err != nil { @@ -441,15 +377,15 @@ func TestReassembleTranscript_Empty(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if result != nil { - t.Errorf("expected nil for empty chunks, got %d bytes", len(result)) + if len(result) != 0 { + t.Errorf("expected empty result for nil chunks, got %d bytes", len(result)) } } func TestExtractModifiedFiles(t *testing.T) { t.Parallel() - files, err := ExtractModifiedFiles([]byte(testTranscriptJSON)) + files, err := ExtractModifiedFiles([]byte(testTranscriptJSONL)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -463,3 +399,7 @@ func TestExtractModifiedFiles(t *testing.T) { t.Errorf("expected second file 'util.go', got %q", files[1]) } } + +// Compile-time interface checks are in transcript.go. +// Verify the unused import guard by referencing the agent package. +var _ = agent.AgentNameOpenCode diff --git a/cmd/entire/cli/agent/opencode/types.go b/cmd/entire/cli/agent/opencode/types.go index f7ad7ed1b..f9364d7ab 100644 --- a/cmd/entire/cli/agent/opencode/types.go +++ b/cmd/entire/cli/agent/opencode/types.go @@ -13,7 +13,7 @@ type turnStartRaw struct { Prompt string `json:"prompt"` } -// --- Transcript types (JSON format, similar to Gemini CLI) --- +// --- Transcript types (JSONL format — one Message per line) --- // Message role constants. const ( @@ -21,13 +21,7 @@ const ( roleUser = "user" ) -// Transcript represents the full transcript JSON written by the plugin. -type Transcript struct { - SessionID string `json:"session_id"` - Messages []Message `json:"messages"` -} - -// Message represents a single message in the transcript. +// Message represents a single message (one line) in the JSONL transcript. type Message struct { ID string `json:"id"` Role string `json:"role"` // "user" or "assistant" @@ -75,10 +69,12 @@ type ToolState struct { } // FileModificationTools are tools in OpenCode that modify files on disk. +// These match the actual tool names from OpenCode's source: +// - edit: internal/llm/tools/edit.go (EditToolName) +// - write: internal/llm/tools/write.go (WriteToolName) +// - patch: internal/llm/tools/patch.go (PatchToolName) var FileModificationTools = []string{ - "edit_file", + "edit", "write", - "write_file", - "create_file", "patch", } diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index d6a12ac02..97aea8d65 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -13,7 +13,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" - "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/logging" @@ -537,9 +536,7 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT switch agentType { case agent.AgentTypeGemini: return geminicli.SliceFromMessage(fullTranscript, startOffset) - case agent.AgentTypeOpenCode: - return opencode.SliceFromMessage(fullTranscript, startOffset) - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeUnknown: return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) @@ -1539,13 +1536,7 @@ func transcriptOffset(transcriptBytes []byte, agentType agent.AgentType) int { return 0 } return len(t.Messages) - case agent.AgentTypeOpenCode: - t, err := opencode.ParseTranscript(transcriptBytes) - if err != nil { - return 0 - } - return len(t.Messages) - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeUnknown: return countLines(transcriptBytes) } return countLines(transcriptBytes) diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index 92fe48865..2ec51deb5 100644 --- a/cmd/entire/cli/integration_test/agent_test.go +++ b/cmd/entire/cli/integration_test/agent_test.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" // Register OpenCode agent "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -818,3 +819,309 @@ func TestGeminiCLIHelperMethods(t *testing.T) { }) } + +// --- OpenCode Agent Tests --- + +// TestOpenCodeAgentDetection verifies OpenCode agent detection and default behavior. +func TestOpenCodeAgentDetection(t *testing.T) { + + t.Run("opencode agent is registered", func(t *testing.T) { + t.Parallel() + + agents := agent.List() + found := false + for _, name := range agents { + if name == "opencode" { + found = true + break + } + } + if !found { + t.Errorf("agent.List() = %v, want to contain 'opencode'", agents) + } + }) + + t.Run("opencode detects presence when .opencode exists", func(t *testing.T) { + // Not parallel - uses os.Chdir which is process-global + env := NewTestEnv(t) + env.InitRepo() + + // Create .opencode directory + opencodeDir := filepath.Join(env.RepoDir, ".opencode") + if err := os.MkdirAll(opencodeDir, 0o755); err != nil { + t.Fatalf("failed to create .opencode dir: %v", err) + } + + // Change to repo dir for detection + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("opencode") + if err != nil { + t.Fatalf("Get(opencode) error = %v", err) + } + + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true when .opencode exists") + } + }) + + t.Run("opencode detects presence when opencode.json exists", func(t *testing.T) { + // Not parallel - uses os.Chdir which is process-global + env := NewTestEnv(t) + env.InitRepo() + + // Create opencode.json config file + configPath := filepath.Join(env.RepoDir, "opencode.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("failed to write opencode.json: %v", err) + } + + // Change to repo dir for detection + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("opencode") + if err != nil { + t.Fatalf("Get(opencode) error = %v", err) + } + + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true when opencode.json exists") + } + }) +} + +// TestOpenCodeHookInstallation verifies hook installation via OpenCode agent interface. +// Not parallel - uses os.Chdir which is process-global. +func TestOpenCodeHookInstallation(t *testing.T) { + + t.Run("installs plugin file", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("opencode") + if err != nil { + t.Fatalf("Get(opencode) error = %v", err) + } + + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + t.Fatal("opencode agent does not implement HookSupport") + } + + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Should install 1 plugin file + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + // Verify hooks are installed + if !hookAgent.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false after InstallHooks()") + } + + // Verify plugin file was created + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + t.Error("entire.ts plugin was not created") + } + }) + + t.Run("idempotent - second install returns 0", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("opencode") + hookAgent := ag.(agent.HookSupport) + + // First install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install should be idempotent + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count) + } + }) +} + +// TestOpenCodeSessionOperations verifies ReadSession/WriteSession via OpenCode agent interface. +func TestOpenCodeSessionOperations(t *testing.T) { + t.Parallel() + + t.Run("ReadSession parses JSONL transcript and computes ModifiedFiles", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + // Create an OpenCode JSONL transcript file + transcriptPath := filepath.Join(env.RepoDir, "test-transcript.jsonl") + transcriptContent := `{"id":"msg-1","role":"user","content":"Fix the bug","time":{"created":1708300000}} +{"id":"msg-2","role":"assistant","content":"I'll fix it.","time":{"created":1708300001,"completed":1708300005},"tokens":{"input":100,"output":50,"reasoning":5,"cache":{"read":3,"write":10}},"parts":[{"type":"text","text":"I'll fix it."},{"type":"tool","tool":"write","callID":"call-1","state":{"status":"completed","input":{"file_path":"main.go"},"output":"written"}}]} +{"id":"msg-3","role":"user","content":"Also fix util.go","time":{"created":1708300010}} +{"id":"msg-4","role":"assistant","content":"Done.","time":{"created":1708300011,"completed":1708300015},"tokens":{"input":120,"output":60,"reasoning":3,"cache":{"read":5,"write":12}},"parts":[{"type":"tool","tool":"edit","callID":"call-2","state":{"status":"completed","input":{"file_path":"util.go"},"output":"edited"}}]} +` + if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag, _ := agent.Get("opencode") + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify session metadata + if session.SessionID != "test-session" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session") + } + if session.AgentName != "opencode" { + t.Errorf("AgentName = %q, want %q", session.AgentName, "opencode") + } + + // Verify NativeData is populated + if len(session.NativeData) == 0 { + t.Error("NativeData is empty, want transcript content") + } + + // Verify ModifiedFiles computed from tool calls + if len(session.ModifiedFiles) != 2 { + t.Errorf("ModifiedFiles = %v, want 2 files (main.go, util.go)", session.ModifiedFiles) + } + }) + + t.Run("WriteSession writes NativeData to file", 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.jsonl") + srcContent := `{"id":"msg-1","role":"user","content":"hello","time":{"created":1708300000}} +` + 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.jsonl") + 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 string(data) != srcContent { + t.Errorf("written content = %q, want %q", string(data), srcContent) + } + }) +} + +// TestOpenCodeHelperMethods verifies OpenCode-specific helper methods. +func TestOpenCodeHelperMethods(t *testing.T) { + t.Parallel() + + t.Run("FormatResumeCommand returns opencode --session", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + cmd := ag.FormatResumeCommand("abc123") + + if cmd != "opencode --session abc123" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode --session abc123") + } + }) + + t.Run("GetHookConfigPath returns empty string", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + path := ag.GetHookConfigPath() + + // OpenCode uses a plugin file, not a JSON config + if path != "" { + t.Errorf("GetHookConfigPath() = %q, want empty string", path) + } + }) + + t.Run("ProtectedDirs includes .opencode", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + dirs := ag.ProtectedDirs() + + found := false + for _, d := range dirs { + if d == ".opencode" { + found = true + break + } + } + if !found { + t.Errorf("ProtectedDirs() = %v, want to contain '.opencode'", dirs) + } + }) + + t.Run("IsPreview returns true", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + if !ag.IsPreview() { + t.Error("IsPreview() = false, want true") + } + }) +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index cdac30c4b..0f73774d2 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/entireio/cli/cmd/entire/cli/strategy" ) @@ -751,3 +752,231 @@ func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) e runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath) } + +// --- OpenCode Hook Runner --- + +// OpenCodeHookRunner executes OpenCode hooks in the test environment. +type OpenCodeHookRunner struct { + RepoDir string + OpenCodeProjectDir string + T interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) + } +} + +// NewOpenCodeHookRunner creates a new OpenCode hook runner for the given repo directory. +func NewOpenCodeHookRunner(repoDir, openCodeProjectDir string, t interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) +}) *OpenCodeHookRunner { + return &OpenCodeHookRunner{ + RepoDir: repoDir, + OpenCodeProjectDir: openCodeProjectDir, + T: t, + } +} + +func (r *OpenCodeHookRunner) runOpenCodeHookWithInput(hookName string, input interface{}) error { + r.T.Helper() + + inputJSON, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal hook input: %w", err) + } + + return r.runOpenCodeHookInRepoDir(hookName, inputJSON) +} + +func (r *OpenCodeHookRunner) runOpenCodeHookInRepoDir(hookName string, inputJSON []byte) error { + // Command structure: entire hooks opencode + cmd := exec.Command(getTestBinary(), "hooks", "opencode", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_OPENCODE_PROJECT_DIR="+r.OpenCodeProjectDir, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s", + hookName, err, inputJSON, output) + } + + r.T.Logf("OpenCode hook %s output: %s", hookName, output) + return nil +} + +// SimulateOpenCodeSessionStart simulates the session-start hook for OpenCode. +func (r *OpenCodeHookRunner) SimulateOpenCodeSessionStart(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runOpenCodeHookWithInput("session-start", input) +} + +// SimulateOpenCodeTurnStart simulates the turn-start hook for OpenCode. +// This is equivalent to Claude Code's UserPromptSubmit. +func (r *OpenCodeHookRunner) SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + "prompt": prompt, + } + + return r.runOpenCodeHookWithInput("turn-start", input) +} + +// SimulateOpenCodeTurnEnd simulates the turn-end hook for OpenCode. +// This is equivalent to Claude Code's Stop hook. +func (r *OpenCodeHookRunner) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runOpenCodeHookWithInput("turn-end", input) +} + +// SimulateOpenCodeSessionEnd simulates the session-end hook for OpenCode. +func (r *OpenCodeHookRunner) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runOpenCodeHookWithInput("session-end", input) +} + +// OpenCodeSession represents a simulated OpenCode session. +type OpenCodeSession struct { + ID string // Raw session ID (e.g., "opencode-session-1") + TranscriptPath string + env *TestEnv + msgCounter int +} + +// NewOpenCodeSession creates a new simulated OpenCode session. +func (env *TestEnv) NewOpenCodeSession() *OpenCodeSession { + env.T.Helper() + + env.SessionCounter++ + sessionID := fmt.Sprintf("opencode-session-%d", env.SessionCounter) + transcriptPath := filepath.Join(env.OpenCodeProjectDir, sessionID+".jsonl") + + return &OpenCodeSession{ + ID: sessionID, + TranscriptPath: transcriptPath, + env: env, + } +} + +// CreateOpenCodeTranscript creates an OpenCode JSONL transcript file for the session. +// Each line is a JSON message in OpenCode's format (id, role, content, time, tokens, parts). +func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []FileChange) string { + var lines []string + + // User message + s.msgCounter++ + userMsg := map[string]interface{}{ + "id": fmt.Sprintf("msg-%d", s.msgCounter), + "role": "user", + "content": prompt, + "time": map[string]interface{}{"created": 1708300000 + s.msgCounter}, + } + userJSON, _ := json.Marshal(userMsg) + lines = append(lines, string(userJSON)) + + // Assistant message with tool calls for file changes + s.msgCounter++ + var parts []map[string]interface{} + parts = append(parts, map[string]interface{}{ + "type": "text", + "text": "I'll help you with that.", + }) + for i, change := range changes { + parts = append(parts, map[string]interface{}{ + "type": "tool", + "tool": "write", + "callID": fmt.Sprintf("call-%d", i+1), + "state": map[string]interface{}{ + "status": "completed", + "input": map[string]string{"file_path": change.Path}, + "output": "File written: " + change.Path, + }, + }) + } + + asstMsg := map[string]interface{}{ + "id": fmt.Sprintf("msg-%d", s.msgCounter), + "role": "assistant", + "content": "Done!", + "time": map[string]interface{}{ + "created": 1708300000 + s.msgCounter, + "completed": 1708300000 + s.msgCounter + 5, + }, + "tokens": map[string]interface{}{ + "input": 150, + "output": 80, + "reasoning": 10, + "cache": map[string]int{"read": 5, "write": 15}, + }, + "cost": 0.003, + "parts": parts, + } + asstJSON, _ := json.Marshal(asstMsg) + lines = append(lines, string(asstJSON)) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil { + s.env.T.Fatalf("failed to create transcript dir: %v", err) + } + + // Write JSONL transcript (one message per line) + content := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(s.TranscriptPath, []byte(content), 0o644); err != nil { + s.env.T.Fatalf("failed to write transcript: %v", err) + } + + return s.TranscriptPath +} + +// SimulateOpenCodeSessionStart is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpenCodeSessionStart(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T) + return runner.SimulateOpenCodeSessionStart(sessionID, transcriptPath) +} + +// SimulateOpenCodeTurnStart is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt string) error { + env.T.Helper() + runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T) + return runner.SimulateOpenCodeTurnStart(sessionID, transcriptPath, prompt) +} + +// SimulateOpenCodeTurnEnd is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpenCodeTurnEnd(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T) + return runner.SimulateOpenCodeTurnEnd(sessionID, transcriptPath) +} + +// SimulateOpenCodeSessionEnd is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewOpenCodeHookRunner(env.RepoDir, env.OpenCodeProjectDir, env.T) + return runner.SimulateOpenCodeSessionEnd(sessionID, transcriptPath) +} diff --git a/cmd/entire/cli/integration_test/opencode_hooks_test.go b/cmd/entire/cli/integration_test/opencode_hooks_test.go new file mode 100644 index 000000000..c2eacd0cb --- /dev/null +++ b/cmd/entire/cli/integration_test/opencode_hooks_test.go @@ -0,0 +1,276 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// TestOpenCodeHookFlow verifies the full hook flow for OpenCode: +// session-start → turn-start → file changes → turn-end → checkpoint → commit → condense → session-end. +func TestOpenCodeHookFlow(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + env.InitEntireWithAgent(strategyName, agent.AgentNameOpenCode) + + // Create OpenCode session + 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 (equivalent to UserPromptSubmit — captures pre-prompt state) + if err := env.SimulateOpenCodeTurnStart(session.ID, session.TranscriptPath, "Add a feature"); err != nil { + t.Fatalf("turn-start error: %v", err) + } + + // 3. Agent makes file changes (AFTER turn-start so they're detected as new) + env.WriteFile("feature.go", "package main\n// new feature") + + // 4. Create transcript with the file change + session.CreateOpenCodeTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, + }) + + // 5. turn-end (equivalent to Stop — creates checkpoint) + if err := env.SimulateOpenCodeTurnEnd(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("turn-end error: %v", err) + } + + // 6. Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after turn-end") + } + + // 7. For manual-commit, user commits manually (triggers condensation). + // For auto-commit, the commit was already made during turn-end. + if strategyName == strategy.StrategyNameManualCommit { + env.GitCommitWithShadowHooks("Add feature", "feature.go") + } + + // 8. session-end + if err := env.SimulateOpenCodeSessionEnd(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("session-end error: %v", err) + } + + // 9. Verify condensation happened (checkpoint on metadata branch) + checkpointID := env.TryGetLatestCheckpointID() + if checkpointID == "" { + t.Fatal("expected checkpoint on metadata branch after commit") + } + + // 10. Verify condensed data + transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) + _, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) + if !found { + t.Error("condensed transcript should exist on metadata branch") + } + }) +} + +// TestOpenCodeAgentStrategyComposition verifies that the OpenCode agent and strategy +// work together correctly — agent parses session, strategy saves checkpoint, rewind works. +func TestOpenCodeAgentStrategyComposition(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + env.InitEntireWithAgent(strategyName, agent.AgentNameOpenCode) + + ag, err := agent.Get("opencode") + if err != nil { + t.Fatalf("Get(opencode) error = %v", err) + } + + _, err = strategy.Get(strategyName) + if err != nil { + t.Fatalf("Get(%s) error = %v", strategyName, err) + } + + // Create session and transcript for agent interface testing. + // The transcript references feature.go but the actual file doesn't need + // to exist for ReadSession — it only parses the transcript JSONL. + session := env.NewOpenCodeSession() + transcriptPath := session.CreateOpenCodeTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, + }) + + // Read session via agent interface + agentSession, err := ag.ReadSession(&agent.HookInput{ + SessionID: session.ID, + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify agent computed modified files + if len(agentSession.ModifiedFiles) == 0 { + t.Error("agent.ReadSession() should compute ModifiedFiles") + } + + // Simulate session flow: session-start → turn-start → file changes → turn-end + if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { + t.Fatalf("session-start error = %v", err) + } + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Add a feature"); err != nil { + t.Fatalf("turn-start error = %v", err) + } + + // Create the actual file AFTER turn-start so the strategy detects it as new + env.WriteFile("feature.go", "package main\n// new feature") + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end error = %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after turn-end") + } + }) +} + +// TestOpenCodeRewind verifies that rewind works with OpenCode checkpoints. +func TestOpenCodeRewind(t *testing.T) { + t.Parallel() + + // Test with manual-commit strategy as it has full file restoration on rewind + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode) + + // First session + session := env.NewOpenCodeSession() + transcriptPath := session.TranscriptPath + + if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { + t.Fatalf("session-start error: %v", err) + } + + // Turn 1: create file1.go (AFTER turn-start so it's detected as new) + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Create file1"); err != nil { + t.Fatalf("turn-start error: %v", err) + } + + env.WriteFile("file1.go", "package main\n// file1 v1") + session.CreateOpenCodeTranscript("Create file1", []FileChange{ + {Path: "file1.go", Content: "package main\n// file1 v1"}, + }) + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end error: %v", err) + } + + points1 := env.GetRewindPoints() + if len(points1) == 0 { + t.Fatal("no rewind point after first turn") + } + checkpoint1ID := points1[0].ID + + // Turn 2: modify file1 and create file2 (AFTER turn-start) + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Modify file1"); err != nil { + t.Fatalf("turn-start error: %v", err) + } + + env.WriteFile("file1.go", "package main\n// file1 v2") + env.WriteFile("file2.go", "package main\n// file2") + session.CreateOpenCodeTranscript("Modify file1, create file2", []FileChange{ + {Path: "file1.go", Content: "package main\n// file1 v2"}, + {Path: "file2.go", Content: "package main\n// file2"}, + }) + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end error: %v", err) + } + + // Verify 2 checkpoints + points2 := env.GetRewindPoints() + if len(points2) < 2 { + t.Fatalf("expected at least 2 rewind points, got %d", len(points2)) + } + + // Rewind to first checkpoint + if err := env.Rewind(checkpoint1ID); err != nil { + t.Fatalf("Rewind() error = %v", err) + } + + // Verify file1 is restored to v1 + content := env.ReadFile("file1.go") + if content != "package main\n// file1 v1" { + t.Errorf("file1.go after rewind = %q, want v1 content", content) + } + + // file2 should not exist after rewind to checkpoint 1 + if env.FileExists("file2.go") { + t.Error("file2.go should not exist after rewind to checkpoint 1") + } +} + +// TestOpenCodeMultiTurnCondensation verifies that multiple turns in a session +// are correctly condensed when the user commits. +func TestOpenCodeMultiTurnCondensation(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode) + + session := env.NewOpenCodeSession() + transcriptPath := session.TranscriptPath + + // session-start + if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { + t.Fatalf("session-start error: %v", err) + } + + // Turn 1: create file (AFTER turn-start so it's detected as new) + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Create app.go"); err != nil { + t.Fatalf("turn-start error: %v", err) + } + + env.WriteFile("app.go", "package main\nfunc main() {}") + session.CreateOpenCodeTranscript("Create app.go", []FileChange{ + {Path: "app.go", Content: "package main\nfunc main() {}"}, + }) + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end error: %v", err) + } + + // Verify checkpoint + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected rewind point after first turn") + } + + // Commit with hooks (triggers condensation) + env.GitCommitWithShadowHooks("Implement app", "app.go") + + // session-end + if err := env.SimulateOpenCodeSessionEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("session-end error: %v", err) + } + + // Verify checkpoint was condensed to metadata branch + checkpointID := env.TryGetLatestCheckpointID() + if checkpointID == "" { + t.Fatal("expected checkpoint on metadata branch after commit") + } + + // Verify files are on metadata branch + env.ValidateCheckpoint(CheckpointValidation{ + CheckpointID: checkpointID, + Strategy: strategy.StrategyNameManualCommit, + FilesTouched: []string{"app.go"}, + ExpectedTranscriptContent: []string{ + "Create app.go", // User prompt should appear in transcript + }, + }) +} diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index e51b9b297..a33bdc9c8 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -44,11 +44,12 @@ func getTestBinary() string { // TestEnv manages an isolated test environment for integration tests. type TestEnv struct { - T *testing.T - RepoDir string - ClaudeProjectDir string - GeminiProjectDir string - SessionCounter int + T *testing.T + RepoDir string + ClaudeProjectDir string + GeminiProjectDir string + OpenCodeProjectDir string + SessionCounter int } // NewTestEnv creates a new isolated test environment. @@ -73,16 +74,21 @@ func NewTestEnv(t *testing.T) *TestEnv { if resolved, err := filepath.EvalSymlinks(geminiProjectDir); err == nil { geminiProjectDir = resolved } + openCodeProjectDir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(openCodeProjectDir); err == nil { + openCodeProjectDir = resolved + } env := &TestEnv{ - T: t, - RepoDir: repoDir, - ClaudeProjectDir: claudeProjectDir, - GeminiProjectDir: geminiProjectDir, + T: t, + RepoDir: repoDir, + ClaudeProjectDir: claudeProjectDir, + GeminiProjectDir: geminiProjectDir, + OpenCodeProjectDir: openCodeProjectDir, } // Note: Don't use t.Setenv here - it's incompatible with t.Parallel() - // CLI commands receive ENTIRE_TEST_CLAUDE_PROJECT_DIR or ENTIRE_TEST_GEMINI_PROJECT_DIR via cmd.Env instead + // CLI commands receive ENTIRE_TEST_*_PROJECT_DIR via cmd.Env instead return env } @@ -103,11 +109,12 @@ func (env *TestEnv) Cleanup() { } // cliEnv returns the environment variables for CLI execution. -// Includes both Claude and Gemini project dirs so tests work for any agent. +// Includes Claude, Gemini, and OpenCode project dirs so tests work for any agent. func (env *TestEnv) cliEnv() []string { return append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, "ENTIRE_TEST_GEMINI_PROJECT_DIR="+env.GeminiProjectDir, + "ENTIRE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir, "ENTIRE_TEST_TTY=0", // Prevent interactive prompts from blocking in tests ) } @@ -287,24 +294,25 @@ func (env *TestEnv) InitEntire(strategyName string) { // InitEntireWithOptions initializes the .entire directory with the specified strategy and options. func (env *TestEnv) InitEntireWithOptions(strategyName string, strategyOptions map[string]any) { env.T.Helper() - env.initEntireInternal(strategyName, "", strategyOptions) + env.initEntireInternal(strategyName, strategyOptions) } // InitEntireWithAgent initializes an Entire test environment with a specific agent. -// If agentName is empty, defaults to claude-code. -func (env *TestEnv) InitEntireWithAgent(strategyName string, agentName agent.AgentName) { +// The agent name is for test documentation only — the CLI resolves the agent from +// hook commands and checkpoint metadata, not from settings.json. +func (env *TestEnv) InitEntireWithAgent(strategyName string, _ agent.AgentName) { env.T.Helper() - env.initEntireInternal(strategyName, agentName, nil) + env.initEntireInternal(strategyName, nil) } // InitEntireWithAgentAndOptions initializes Entire with the specified strategy, agent, and options. -func (env *TestEnv) InitEntireWithAgentAndOptions(strategyName string, agentName agent.AgentName, strategyOptions map[string]any) { +func (env *TestEnv) InitEntireWithAgentAndOptions(strategyName string, _ agent.AgentName, strategyOptions map[string]any) { env.T.Helper() - env.initEntireInternal(strategyName, agentName, strategyOptions) + env.initEntireInternal(strategyName, strategyOptions) } // initEntireInternal is the common implementation for InitEntire variants. -func (env *TestEnv) initEntireInternal(strategyName string, agentName agent.AgentName, strategyOptions map[string]any) { +func (env *TestEnv) initEntireInternal(strategyName string, strategyOptions map[string]any) { env.T.Helper() // Create .entire directory structure @@ -320,14 +328,13 @@ func (env *TestEnv) initEntireInternal(strategyName string, agentName agent.Agen } // Write settings.json + // Note: The agent name is NOT stored in settings.json — the CLI determines + // the agent from installed hooks (detect presence) or checkpoint metadata. + // The settings parser uses DisallowUnknownFields(), so only recognized fields are allowed. settings := map[string]any{ "strategy": strategyName, "local_dev": true, // Note: git-triggered hooks won't work (path is relative); tests call hooks via getTestBinary() instead } - // Only add agent if specified (otherwise defaults to claude-code) - if agentName != "" { - settings["agent"] = string(agentName) - } if strategyOptions != nil { settings["strategy_options"] = strategyOptions } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index c3fd97ab7..73c1d78fc 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -481,15 +481,6 @@ func countTranscriptItems(agentType agent.AgentType, content string) int { return 0 } - // OpenCode uses JSON with messages array (like Gemini) - if agentType == agent.AgentTypeOpenCode { - t, err := opencode.ParseTranscript([]byte(content)) - if err == nil && t != nil { - return len(t.Messages) - } - return 0 - } - // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { transcript, err := geminicli.ParseTranscript([]byte(content)) @@ -519,7 +510,7 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { return nil } - // OpenCode uses JSON with messages array + // OpenCode uses JSONL with a different per-line schema than Claude Code if agentType == agent.AgentTypeOpenCode { prompts, err := opencode.ExtractAllUserPrompts([]byte(content)) if err == nil && len(prompts) > 0 { @@ -567,7 +558,7 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } - // OpenCode uses JSON with token info on assistant messages + // OpenCode uses JSONL with token info on assistant messages (different schema from Claude Code) if agentType == agent.AgentTypeOpenCode { return opencode.CalculateTokenUsageFromBytes(data, startOffset) } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index e567fea59..c112d454d 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -112,7 +112,7 @@ var minimalDetailTools = map[string]bool{ // BuildCondensedTranscriptFromBytes parses transcript bytes and extracts a condensed view. // This is a convenience function that combines parsing and condensing. -// The agentType parameter determines which parser to use (Claude JSONL vs Gemini JSON). +// The agentType parameter determines which parser to use (Claude/OpenCode JSONL vs Gemini JSON). func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType) ([]Entry, error) { switch agentType { case agent.AgentTypeGemini: @@ -169,15 +169,15 @@ func buildCondensedTranscriptFromGemini(content []byte) ([]Entry, error) { return entries, nil } -// buildCondensedTranscriptFromOpenCode parses OpenCode JSON transcript and extracts a condensed view. +// buildCondensedTranscriptFromOpenCode parses OpenCode JSONL transcript and extracts a condensed view. func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) { - ocTranscript, err := opencode.ParseTranscript(content) + messages, err := opencode.ParseMessages(content) if err != nil { return nil, fmt.Errorf("failed to parse OpenCode transcript: %w", err) } var entries []Entry - for _, msg := range ocTranscript.Messages { + for _, msg := range messages { switch msg.Role { case "user": if msg.Content != "" { diff --git a/cmd/entire/cli/summarize/summarize_test.go b/cmd/entire/cli/summarize/summarize_test.go index 729f203c5..5891aa32c 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -696,15 +696,10 @@ func TestGenerateFromTranscript_NilGenerator(t *testing.T) { } func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T) { - ocJSON := `{ - "session_id": "test-session", - "messages": [ - {"id": "msg-1", "role": "user", "content": "Fix the bug in main.go", "time": {"created": 1708300000}}, - {"id": "msg-2", "role": "assistant", "content": "I'll fix the bug.", "time": {"created": 1708300001}} - ] - }` - - entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + ocJSONL := "{\"id\":\"msg-1\",\"role\":\"user\",\"content\":\"Fix the bug in main.go\",\"time\":{\"created\":1708300000}}\n" + + "{\"id\":\"msg-2\",\"role\":\"assistant\",\"content\":\"I'll fix the bug.\",\"time\":{\"created\":1708300001}}\n" + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSONL), agent.AgentTypeOpenCode) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -729,23 +724,10 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T } func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) { - ocJSON := `{ - "session_id": "test-session", - "messages": [ - {"id": "msg-1", "role": "user", "content": "Edit main.go", "time": {"created": 1708300000}}, - {"id": "msg-2", "role": "assistant", "content": "Editing now.", "time": {"created": 1708300001}, - "parts": [ - {"type": "text", "text": "Editing now."}, - {"type": "tool", "tool": "edit_file", "callID": "call-1", - "state": {"status": "completed", "input": {"file_path": "main.go"}, "output": "Applied"}}, - {"type": "tool", "tool": "run_command", "callID": "call-2", - "state": {"status": "completed", "input": {"command": "go test ./..."}, "output": "PASS"}} - ] - } - ] - }` - - entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + ocJSONL := "{\"id\":\"msg-1\",\"role\":\"user\",\"content\":\"Edit main.go\",\"time\":{\"created\":1708300000}}\n" + + "{\"id\":\"msg-2\",\"role\":\"assistant\",\"content\":\"Editing now.\",\"time\":{\"created\":1708300001},\"parts\":[{\"type\":\"text\",\"text\":\"Editing now.\"},{\"type\":\"tool\",\"tool\":\"edit\",\"callID\":\"call-1\",\"state\":{\"status\":\"completed\",\"input\":{\"file_path\":\"main.go\"},\"output\":\"Applied\"}},{\"type\":\"tool\",\"tool\":\"bash\",\"callID\":\"call-2\",\"state\":{\"status\":\"completed\",\"input\":{\"command\":\"go test ./...\"},\"output\":\"PASS\"}}]}\n" + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSONL), agent.AgentTypeOpenCode) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -758,15 +740,15 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) { if entries[2].Type != EntryTypeTool { t.Errorf("entry 2: expected type %s, got %s", EntryTypeTool, entries[2].Type) } - if entries[2].ToolName != "edit_file" { - t.Errorf("entry 2: expected tool name edit_file, got %s", entries[2].ToolName) + if entries[2].ToolName != "edit" { + t.Errorf("entry 2: expected tool name edit, got %s", entries[2].ToolName) } if entries[2].ToolDetail != "main.go" { t.Errorf("entry 2: expected tool detail main.go, got %s", entries[2].ToolDetail) } - if entries[3].ToolName != "run_command" { - t.Errorf("entry 3: expected tool name run_command, got %s", entries[3].ToolName) + if entries[3].ToolName != "bash" { + t.Errorf("entry 3: expected tool name bash, got %s", entries[3].ToolName) } if entries[3].ToolDetail != "go test ./..." { t.Errorf("entry 3: expected tool detail 'go test ./...', got %s", entries[3].ToolDetail) @@ -774,16 +756,11 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeToolCalls(t *testing.T) { } func TestBuildCondensedTranscriptFromBytes_OpenCodeSkipsEmptyContent(t *testing.T) { - ocJSON := `{ - "session_id": "test-session", - "messages": [ - {"id": "msg-1", "role": "user", "content": "", "time": {"created": 1708300000}}, - {"id": "msg-2", "role": "assistant", "content": "", "time": {"created": 1708300001}}, - {"id": "msg-3", "role": "user", "content": "Real prompt", "time": {"created": 1708300010}} - ] - }` - - entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSON), agent.AgentTypeOpenCode) + ocJSONL := "{\"id\":\"msg-1\",\"role\":\"user\",\"content\":\"\",\"time\":{\"created\":1708300000}}\n" + + "{\"id\":\"msg-2\",\"role\":\"assistant\",\"content\":\"\",\"time\":{\"created\":1708300001}}\n" + + "{\"id\":\"msg-3\",\"role\":\"user\",\"content\":\"Real prompt\",\"time\":{\"created\":1708300010}}\n" + + entries, err := BuildCondensedTranscriptFromBytes([]byte(ocJSONL), agent.AgentTypeOpenCode) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -796,10 +773,14 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeSkipsEmptyContent(t *testing. } } -func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSON(t *testing.T) { - _, err := BuildCondensedTranscriptFromBytes([]byte(`not json`), agent.AgentTypeOpenCode) - if err == nil { - t.Error("expected error for invalid OpenCode JSON") +func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSONL(t *testing.T) { + // Invalid JSONL lines are silently skipped, producing 0 entries (not an error). + entries, err := BuildCondensedTranscriptFromBytes([]byte("not json\n"), agent.AgentTypeOpenCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 entries for invalid JSONL, got %d", len(entries)) } } From 67a0e0d7d0077b437205e12103965c4baf7ee742 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 19 Feb 2026 14:52:53 -0800 Subject: [PATCH 5/7] Enable OpenCode resume/rewind via SQLite import --- .../cli/agent/opencode/entire_plugin.ts | 33 +++++- .../cli/agent/opencode/lifecycle_test.go | 23 ++++ cmd/entire/cli/agent/opencode/opencode.go | 53 ++++++++- cmd/entire/cli/agent/opencode/sqlite.go | 110 ++++++++++++++++++ cmd/entire/cli/agent/session.go | 6 + cmd/entire/cli/checkpoint/checkpoint.go | 6 + cmd/entire/cli/checkpoint/committed.go | 24 ++-- cmd/entire/cli/checkpoint/temporary.go | 23 ++++ cmd/entire/cli/integration_test/agent_test.go | 18 +-- cmd/entire/cli/lifecycle.go | 11 ++ cmd/entire/cli/paths/paths.go | 1 + cmd/entire/cli/resume.go | 3 +- cmd/entire/cli/rewind.go | 51 +++++++- 13 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 cmd/entire/cli/agent/opencode/sqlite.go diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts index d1c6b5f3d..ebf7f6a6e 100644 --- a/cmd/entire/cli/agent/opencode/entire_plugin.ts +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -21,6 +21,8 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { // partStore: keyed by message ID, stores accumulated parts from message.part.updated events const partStore = new Map() let currentSessionID: string | null = null + // Full session info from session.created — needed for OpenCode export format on resume/rewind + let currentSessionInfo: any = null // Ensure transcript directory exists await $`mkdir -p ${transcriptDir}`.quiet().nothrow() @@ -125,6 +127,31 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { } } + /** + * Write session in OpenCode's native export format (JSON). + * This file is used by `opencode import` during resume/rewind to restore + * the session into OpenCode's SQLite database with the original session ID. + */ + async function writeExportJSON(sessionID: string): Promise { + const exportPath = `${transcriptDir}/${sessionID}.export.json` + try { + const messages = Array.from(messageStore.values()) + .sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0)) + + const exportData = { + info: currentSessionInfo ?? { id: sessionID }, + messages: messages.map(msg => ({ + info: msg, + parts: (partStore.get(msg.id) ?? []), + })), + } + await Bun.write(exportPath, JSON.stringify(exportData)) + } catch { + // Silently ignore — plugin failures must not crash OpenCode + } + return exportPath + } + return { event: async ({ event }) => { switch (event.type) { @@ -132,6 +159,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { const session = (event as any).properties?.info if (!session?.id) break currentSessionID = session.id + currentSessionInfo = session await callHook("session-start", { session_id: session.id, transcript_path: `${transcriptDir}/${session.id}.jsonl`, @@ -183,6 +211,7 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { const sessionID = (event as any).properties?.sessionID if (!sessionID) break const transcriptPath = await writeTranscriptWithFallback(sessionID) + await writeExportJSON(sessionID) await callHook("turn-end", { session_id: sessionID, transcript_path: transcriptPath, @@ -203,14 +232,16 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => { case "session.deleted": { const session = (event as any).properties?.info if (!session?.id) break - // Write final transcript before signaling session end + // Write final transcript + export JSON before signaling session end if (messageStore.size > 0) { await writeTranscriptFromMemory(session.id) + await writeExportJSON(session.id) } seenUserMessages.clear() messageStore.clear() partStore.clear() currentSessionID = null + currentSessionInfo = null await callHook("session-end", { session_id: session.id, transcript_path: `${transcriptDir}/${session.id}.jsonl`, diff --git a/cmd/entire/cli/agent/opencode/lifecycle_test.go b/cmd/entire/cli/agent/opencode/lifecycle_test.go index 2713ab540..802160d7d 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle_test.go +++ b/cmd/entire/cli/agent/opencode/lifecycle_test.go @@ -159,6 +159,29 @@ func TestParseHookEvent_MalformedJSON(t *testing.T) { } } +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + cmd := ag.FormatResumeCommand("sess-abc123") + + expected := "opencode -s sess-abc123" + if cmd != expected { + t.Errorf("expected %q, got %q", expected, cmd) + } +} + +func TestFormatResumeCommand_Empty(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + cmd := ag.FormatResumeCommand("") + + if cmd != "opencode" { + t.Errorf("expected %q, got %q", "opencode", cmd) + } +} + func TestHookNames(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 63773e41f..394b5ff59 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -147,6 +148,8 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { if len(session.NativeData) == 0 { return errors.New("no session data to write") } + + // 1. Write JSONL 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 { @@ -155,11 +158,59 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { 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's SQLite. + // This enables `opencode -s ` for both resume and rewind. + if len(session.ExportData) == 0 { + return nil // No export data — skip SQLite import (graceful degradation) + } + + if err := a.importSessionIntoSQLite(session.SessionID, session.ExportData); err != nil { + // Non-fatal: SQLite import is best-effort. The JSONL file is written, + // and the user can always run `opencode import ` manually. + fmt.Fprintf(os.Stderr, "warning: could not import session into OpenCode: %v\n", err) + } + return nil } +// importSessionIntoSQLite writes the export JSON to a temp file and runs +// `opencode import` to restore the session into OpenCode's SQLite database. +// For rewind (session already exists), messages are deleted first so the +// reimport replaces them with the checkpoint-state messages. +func (a *OpenCodeAgent) importSessionIntoSQLite(sessionID string, exportData []byte) error { + // If the session already exists in SQLite, delete its messages first. + // opencode import uses ON CONFLICT DO NOTHING, so existing messages + // would be skipped without this step (breaking rewind). + if sessionExistsInSQLite(sessionID) { + if err := deleteMessagesFromSQLite(sessionID); err != nil { + return fmt.Errorf("failed to clear existing messages: %w", err) + } + } + + // Write export JSON to a temp file for opencode import + tmpFile, err := os.CreateTemp("", "entire-opencode-export-*.json") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(exportData); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write export data: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + return runOpenCodeImport(tmpFile.Name()) +} + func (a *OpenCodeAgent) FormatResumeCommand(sessionID string) string { - return "opencode --session " + sessionID + if strings.TrimSpace(sessionID) == "" { + return "opencode" + } + return "opencode -s " + sessionID } // nonAlphanumericRegex matches any non-alphanumeric character. diff --git a/cmd/entire/cli/agent/opencode/sqlite.go b/cmd/entire/cli/agent/opencode/sqlite.go new file mode 100644 index 000000000..0388e9dac --- /dev/null +++ b/cmd/entire/cli/agent/opencode/sqlite.go @@ -0,0 +1,110 @@ +package opencode + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// openCodeImportTimeout is the maximum time to wait for `opencode import` to complete. +const openCodeImportTimeout = 30 * time.Second + +// getOpenCodeDBPath returns the path to OpenCode's SQLite database. +// OpenCode always uses ~/.local/share/opencode/opencode.db (XDG default) +// regardless of platform — it does NOT use ~/Library/Application Support on macOS. +// +// XDG_DATA_HOME overrides the default on all platforms. +func getOpenCodeDBPath() (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + dataDir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataDir, "opencode", "opencode.db"), nil +} + +// runSQLiteQuery executes a SQL query against OpenCode's SQLite database. +// Returns the combined stdout/stderr output. +func runSQLiteQuery(query string, timeout time.Duration) ([]byte, error) { + dbPath, err := getOpenCodeDBPath() + if err != nil { + return nil, fmt.Errorf("failed to get OpenCode DB path: %w", err) + } + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return nil, fmt.Errorf("OpenCode database not found: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + //nolint:gosec // G204: query is constructed from sanitized inputs (escapeSQLiteString) + cmd := exec.CommandContext(ctx, "sqlite3", dbPath, query) + output, err := cmd.CombinedOutput() + if err != nil { + return output, fmt.Errorf("sqlite3 query failed: %w", err) + } + return output, nil +} + +// sessionExistsInSQLite checks whether a session with the given ID exists +// in OpenCode's SQLite database. +func sessionExistsInSQLite(sessionID string) bool { + query := fmt.Sprintf("SELECT count(*) FROM session WHERE id = '%s';", escapeSQLiteString(sessionID)) + output, err := runSQLiteQuery(query, 5*time.Second) + if err != nil { + return false + } + return len(output) > 0 && output[0] != '0' +} + +// deleteMessagesFromSQLite removes all messages (and cascading parts) for a session. +// This is used before reimporting a session during rewind so that `opencode import` +// can insert the checkpoint-state messages (import uses ON CONFLICT DO NOTHING). +func deleteMessagesFromSQLite(sessionID string) error { + // Enable foreign keys so CASCADE deletes work (parts are deleted with messages). + query := fmt.Sprintf( + "PRAGMA foreign_keys = ON; DELETE FROM message WHERE session_id = '%s';", + escapeSQLiteString(sessionID), + ) + if output, err := runSQLiteQuery(query, 5*time.Second); err != nil { + return fmt.Errorf("failed to delete messages from OpenCode DB: %w (output: %s)", err, string(output)) + } + return nil +} + +// runOpenCodeImport runs `opencode import ` to import a session into +// OpenCode's SQLite database. The import preserves the original session ID +// from the export file. +func runOpenCodeImport(exportFilePath string) error { + ctx, cancel := context.WithTimeout(context.Background(), openCodeImportTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "import", exportFilePath) + if output, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode import timed out after %s", openCodeImportTimeout) + } + return fmt.Errorf("opencode import failed: %w (output: %s)", err, string(output)) + } + + return nil +} + +// escapeSQLiteString escapes single quotes in a string for safe use in SQLite queries. +func escapeSQLiteString(s string) string { + result := make([]byte, 0, len(s)) + for i := range len(s) { + if s[i] == '\'' { + result = append(result, '\'', '\'') + } else { + result = append(result, s[i]) + } + } + return string(result) +} diff --git a/cmd/entire/cli/agent/session.go b/cmd/entire/cli/agent/session.go index a058210aa..88ad4b13f 100644 --- a/cmd/entire/cli/agent/session.go +++ b/cmd/entire/cli/agent/session.go @@ -26,6 +26,12 @@ 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 diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index aaed64c55..9cede5628 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -357,6 +357,12 @@ type SessionContent struct { // Context is the context.md content Context string + + // ExportData holds the agent's native export format (e.g., OpenCode export JSON). + // Used by agents whose primary storage isn't file-based (OpenCode uses SQLite). + // At resume/rewind time, this is imported back into the agent's storage. + // Empty for agents where the transcript file is sufficient (Claude, Gemini). + ExportData []byte } // CommittedMetadata contains the metadata stored in metadata.json for each checkpoint. diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index ceb759270..93a9cf430 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -798,6 +798,13 @@ func (s *GitStore) ReadSessionContent(ctx context.Context, checkpointID id.Check } } + // Read export data (optional — only present for agents with non-file storage, e.g., OpenCode) + if file, fileErr := sessionTree.File(paths.ExportDataFileName); fileErr == nil { + if content, contentErr := file.Contents(); contentErr == nil { + result.ExportData = []byte(content) + } + } + return result, nil } @@ -953,22 +960,23 @@ func (s *GitStore) GetTranscript(ctx context.Context, checkpointID id.Checkpoint return content.Transcript, nil } -// GetSessionLog retrieves the session transcript and session ID for a checkpoint. +// GetSessionLog retrieves the session transcript, session ID, and export data for a checkpoint. // This is the primary method for looking up session logs by checkpoint ID. +// The export data is optional — only present for agents with non-file storage (e.g., OpenCode). // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. // Returns ErrNoTranscript if the checkpoint exists but has no transcript. -func (s *GitStore) GetSessionLog(cpID id.CheckpointID) ([]byte, string, error) { +func (s *GitStore) GetSessionLog(cpID id.CheckpointID) ([]byte, string, []byte, error) { content, err := s.ReadLatestSessionContent(context.Background(), cpID) if err != nil { if errors.Is(err, ErrCheckpointNotFound) { - return nil, "", ErrCheckpointNotFound + return nil, "", nil, ErrCheckpointNotFound } - return nil, "", fmt.Errorf("failed to read checkpoint: %w", err) + return nil, "", nil, fmt.Errorf("failed to read checkpoint: %w", err) } if len(content.Transcript) == 0 { - return nil, "", ErrNoTranscript + return nil, "", nil, ErrNoTranscript } - return content.Transcript, content.Metadata.SessionID, nil + return content.Transcript, content.Metadata.SessionID, content.ExportData, nil } // LookupSessionLog is a convenience function that opens the repository and retrieves @@ -976,10 +984,10 @@ func (s *GitStore) GetSessionLog(cpID id.CheckpointID) ([]byte, string, error) { // don't already have a GitStore instance. // Returns ErrCheckpointNotFound if the checkpoint doesn't exist. // Returns ErrNoTranscript if the checkpoint exists but has no transcript. -func LookupSessionLog(cpID id.CheckpointID) ([]byte, string, error) { +func LookupSessionLog(cpID id.CheckpointID) ([]byte, string, []byte, error) { repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) if err != nil { - return nil, "", fmt.Errorf("failed to open git repository: %w", err) + return nil, "", nil, fmt.Errorf("failed to open git repository: %w", err) } store := NewGitStore(repo) return store.GetSessionLog(cpID) diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index 1fc1b64c0..3ff864901 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -609,6 +609,29 @@ func (s *GitStore) GetTranscriptFromCommit(commitHash plumbing.Hash, metadataDir return nil, ErrNoTranscript } +// GetExportDataFromCommit reads the export data file from a commit tree's metadata directory. +// Returns nil if not found (most agents don't produce export data). +func (s *GitStore) GetExportDataFromCommit(commitHash plumbing.Hash, metadataDir string) []byte { + commit, err := s.repo.CommitObject(commitHash) + if err != nil { + return nil + } + tree, err := commit.Tree() + if err != nil { + return nil + } + exportPath := metadataDir + "/" + paths.ExportDataFileName + file, err := tree.File(exportPath) + if err != nil { + return nil + } + content, err := file.Contents() + if err != nil { + return nil + } + return []byte(content) +} + // ShadowBranchExists checks if a shadow branch exists for the given base commit and worktree. // worktreeID should be empty for main worktree or the internal git worktree name for linked worktrees. func (s *GitStore) ShadowBranchExists(baseCommit, worktreeID string) bool { diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index 2ec51deb5..b9c4264fd 100644 --- a/cmd/entire/cli/integration_test/agent_test.go +++ b/cmd/entire/cli/integration_test/agent_test.go @@ -1075,26 +1075,14 @@ func TestOpenCodeSessionOperations(t *testing.T) { func TestOpenCodeHelperMethods(t *testing.T) { t.Parallel() - t.Run("FormatResumeCommand returns opencode --session", func(t *testing.T) { + t.Run("FormatResumeCommand returns opencode -s", func(t *testing.T) { t.Parallel() ag, _ := agent.Get("opencode") cmd := ag.FormatResumeCommand("abc123") - if cmd != "opencode --session abc123" { - t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode --session abc123") - } - }) - - t.Run("GetHookConfigPath returns empty string", func(t *testing.T) { - t.Parallel() - - ag, _ := agent.Get("opencode") - path := ag.GetHookConfigPath() - - // OpenCode uses a plugin file, not a JSON config - if path != "" { - t.Errorf("GetHookConfigPath() = %q, want empty string", path) + if cmd != "opencode -s abc123" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode -s abc123") } }) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9c34d9041..fe924f124 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -206,6 +206,17 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { } fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", sessionDir+"/"+paths.TranscriptFileName) + // Copy export JSON if it exists alongside the transcript (written by OpenCode plugin). + // This is used by `opencode import` during resume/rewind to restore the session + // into OpenCode's SQLite database with the original session ID. + exportSrc := strings.TrimSuffix(transcriptRef, filepath.Ext(transcriptRef)) + ".export.json" + if exportData, readErr := os.ReadFile(exportSrc); readErr == nil { //nolint:gosec // Path derived from agent hook + exportDest := filepath.Join(sessionDirAbs, paths.ExportDataFileName) + if writeErr := os.WriteFile(exportDest, exportData, 0o600); writeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write export data: %v\n", writeErr) + } + } + // Load pre-prompt state (captured on TurnStart) preState, err := LoadPrePromptState(sessionID) if err != nil { diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 2581eb897..641e420bc 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -30,6 +30,7 @@ const ( MetadataFileName = "metadata.json" CheckpointFileName = "checkpoint.json" ContentHashFileName = "content_hash.txt" + ExportDataFileName = "export.json" SettingsFileName = "settings.json" ) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index b06d229fb..7d597f6b1 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -488,7 +488,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, return nil } - logContent, _, err := checkpoint.LookupSessionLog(checkpointID) + logContent, _, exportData, err := checkpoint.LookupSessionLog(checkpointID) if err != nil { if errors.Is(err, checkpoint.ErrCheckpointNotFound) || errors.Is(err, checkpoint.ErrNoTranscript) { logging.Debug(ctx, "resume session completed (no metadata)", @@ -544,6 +544,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, RepoPath: repoRoot, SessionRef: sessionLogPath, NativeData: logContent, + ExportData: exportData, } // Write the session using the agent's WriteSession method diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index 0a6a9ff24..c7c0d38a0 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -669,7 +669,7 @@ func restoreSessionTranscript(transcriptFile, sessionID string, agent agentpkg.A // Returns the session ID that was actually used (may differ from input if checkpoint provides one). func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string, agent agentpkg.Agent) (string, error) { // Get transcript content from checkpoint storage - content, returnedSessionID, err := checkpoint.LookupSessionLog(cpID) + content, returnedSessionID, exportData, err := checkpoint.LookupSessionLog(cpID) if err != nil { return "", fmt.Errorf("failed to get session log: %w", err) } @@ -680,6 +680,29 @@ func restoreSessionTranscriptFromStrategy(cpID id.CheckpointID, sessionID string sessionID = returnedSessionID } + // If export data is available (e.g., OpenCode), use WriteSession which handles + // both file writing and native storage import (SQLite for OpenCode). + if len(exportData) > 0 { + sessionFile, err := resolveTranscriptPath(sessionID, agent) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil { + return "", fmt.Errorf("failed to create agent session directory: %w", err) + } + agentSession := &agentpkg.AgentSession{ + SessionID: sessionID, + AgentName: agent.Name(), + SessionRef: sessionFile, + NativeData: content, + ExportData: exportData, + } + if err := agent.WriteSession(agentSession); err != nil { + return "", fmt.Errorf("failed to write session: %w", err) + } + return sessionID, nil + } + return writeTranscriptToAgentSession(content, sessionID, agent) } @@ -705,6 +728,32 @@ func restoreSessionTranscriptFromShadow(commitHash, metadataDir, sessionID strin return "", fmt.Errorf("failed to get transcript from shadow branch: %w", err) } + // Read export data from shadow branch tree if available (e.g., OpenCode export JSON). + exportData := store.GetExportDataFromCommit(hash, metadataDir) + + // If export data is available, use WriteSession which handles both file writing + // and native storage import (SQLite for OpenCode). + if len(exportData) > 0 { + sessionFile, err := resolveTranscriptPath(sessionID, agent) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil { + return "", fmt.Errorf("failed to create agent session directory: %w", err) + } + agentSession := &agentpkg.AgentSession{ + SessionID: sessionID, + AgentName: agent.Name(), + SessionRef: sessionFile, + NativeData: content, + ExportData: exportData, + } + if err := agent.WriteSession(agentSession); err != nil { + return "", fmt.Errorf("failed to write session: %w", err) + } + return sessionID, nil + } + return writeTranscriptToAgentSession(content, sessionID, agent) } From b2c38dee6fe93af485e86b6735eb4f6f172c3417 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 19 Feb 2026 21:23:26 -0800 Subject: [PATCH 6/7] Fix OpenCode resume/rewind: store export data in checkpoints --- cmd/entire/cli/agent/opencode/lifecycle.go | 10 ---------- cmd/entire/cli/agent/opencode/opencode.go | 15 -------------- cmd/entire/cli/checkpoint/checkpoint.go | 5 +++++ cmd/entire/cli/checkpoint/committed.go | 13 ++++++++++++ .../strategy/manual_commit_condensation.go | 20 +++++++++++++++++++ .../cli/strategy/manual_commit_rewind.go | 14 ++++++++++--- .../cli/strategy/manual_commit_types.go | 1 + 7 files changed, 50 insertions(+), 28 deletions(-) diff --git a/cmd/entire/cli/agent/opencode/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go index 1c8270ca5..3a921326c 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle.go +++ b/cmd/entire/cli/agent/opencode/lifecycle.go @@ -7,11 +7,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) -// Compile-time interface assertions -var ( - _ agent.HookHandler = (*OpenCodeAgent)(nil) -) - // Hook name constants — these become CLI subcommands under `entire hooks opencode`. const ( HookNameSessionStart = "session-start" @@ -32,11 +27,6 @@ func (a *OpenCodeAgent) HookNames() []string { } } -// GetHookNames implements agent.HookHandler for CLI hook registration. -func (a *OpenCodeAgent) GetHookNames() []string { - return a.HookNames() -} - // ParseHookEvent translates OpenCode hook calls into normalized lifecycle events. func (a *OpenCodeAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { switch hookName { diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index 394b5ff59..de9f0de72 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -4,7 +4,6 @@ package opencode import ( "errors" "fmt" - "io" "os" "path/filepath" "regexp" @@ -76,20 +75,6 @@ func (a *OpenCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { // --- Legacy methods --- -func (a *OpenCodeAgent) GetHookConfigPath() string { return "" } // Plugin file, not a JSON config -func (a *OpenCodeAgent) SupportsHooks() bool { return true } - -func (a *OpenCodeAgent) ParseHookInput(_ agent.HookType, r io.Reader) (*agent.HookInput, error) { - raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](r) - if err != nil { - return nil, err - } - return &agent.HookInput{ - SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, - }, nil -} - func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 9cede5628..15da60f9e 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -278,6 +278,11 @@ type WriteCommittedOptions struct { // comparing checkpoint tree (agent work) to committed tree (may include human edits) InitialAttribution *InitialAttribution + // ExportData is optional agent-specific export data (e.g., OpenCode SQLite export). + // When present, it is stored as export.json in the checkpoint tree and restored + // during resume/rewind so agents with non-file storage can re-import sessions. + ExportData []byte + // Summary is an optional AI-generated summary for this checkpoint. // This field may be nil when: // - summarization is disabled in settings diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 93a9cf430..b070e3f90 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -376,6 +376,19 @@ func (s *GitStore) writeSessionToSubdirectory(opts WriteCommittedOptions, sessio } filePaths.Metadata = "/" + sessionPath + paths.MetadataFileName + // Write export data (optional — for agents with non-file storage, e.g., OpenCode) + if len(opts.ExportData) > 0 { + exportHash, err := CreateBlobFromContent(s.repo, opts.ExportData) + if err != nil { + return filePaths, fmt.Errorf("failed to create export data blob: %w", err) + } + entries[sessionPath+paths.ExportDataFileName] = object.TreeEntry{ + Name: sessionPath + paths.ExportDataFileName, + Mode: filemode.Regular, + Hash: exportHash, + } + } + return filePaths, nil } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 73c1d78fc..741f64a8c 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -240,6 +240,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart, CheckpointTranscriptStart: state.CheckpointTranscriptStart, TokenUsage: sessionData.TokenUsage, + ExportData: sessionData.ExportData, InitialAttribution: attribution, Summary: summary, }); err != nil { @@ -422,6 +423,16 @@ func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRe // Use tracked files from session state (not all files in tree) data.FilesTouched = filesTouched + // Read export data from local metadata directory (e.g., OpenCode SQLite export). + // This is written by lifecycle.go during TurnEnd and must be stored in the + // committed checkpoint so resume/rewind can re-import the session. + exportRelPath := metadataDir + "/" + paths.ExportDataFileName + if exportAbsPath, absErr := paths.AbsPath(exportRelPath); absErr == nil { + if exportBytes, readErr := os.ReadFile(exportAbsPath); readErr == nil && len(exportBytes) > 0 { //nolint:gosec // path from session metadata + data.ExportData = exportBytes + } + } + // Calculate token usage from the extracted transcript portion if len(data.Transcript) > 0 { data.TokenUsage = calculateTokenUsage(agentType, data.Transcript, checkpointTranscriptStart) @@ -464,6 +475,15 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(state *Sessi data.FilesTouched = s.extractModifiedFilesFromLiveTranscript(state, state.CheckpointTranscriptStart) } + // Read export data from local metadata directory + metadataDir := paths.SessionMetadataDirFromSessionID(state.SessionID) + exportRelPath := metadataDir + "/" + paths.ExportDataFileName + if exportAbsPath, absErr := paths.AbsPath(exportRelPath); absErr == nil { + if exportBytes, readErr := os.ReadFile(exportAbsPath); readErr == nil && len(exportBytes) > 0 { //nolint:gosec // path from session metadata + data.ExportData = exportBytes + } + } + // Calculate token usage from the extracted transcript portion if len(data.Transcript) > 0 { data.TokenUsage = calculateTokenUsage(state.AgentType, data.Transcript, state.CheckpointTranscriptStart) diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index ba11932c7..54ec9a596 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -679,12 +679,20 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(point RewindPoint, force bool) ([ continue } - if writeErr := os.WriteFile(sessionFile, content.Transcript, 0o600); writeErr != nil { + agentSession := &agent.AgentSession{ + SessionID: sessionID, + AgentName: sessionAgent.Name(), + RepoPath: repoRoot, + SessionRef: sessionFile, + NativeData: content.Transcript, + ExportData: content.ExportData, + } + if writeErr := sessionAgent.WriteSession(agentSession); writeErr != nil { if totalSessions > 1 { - fmt.Fprintf(os.Stderr, " Warning: failed to write transcript: %v\n", writeErr) + fmt.Fprintf(os.Stderr, " Warning: failed to write session: %v\n", writeErr) continue } - return nil, fmt.Errorf("failed to write transcript: %w", writeErr) + return nil, fmt.Errorf("failed to write session: %w", writeErr) } restored = append(restored, RestoredSession{ diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index e071974e8..d7d6ad58e 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -62,4 +62,5 @@ type ExtractedSessionData struct { Context []byte // Generated context.md content FilesTouched []string TokenUsage *agent.TokenUsage // Token usage calculated from transcript (since CheckpointTranscriptStart) + ExportData []byte // Agent-specific export data (e.g., OpenCode SQLite export) } From 79ec168e0e24b88f3a1b5a54b5ba83dd77a8614e Mon Sep 17 00:00:00 2001 From: gtrrz-victor Date: Fri, 20 Feb 2026 19:32:52 +1100 Subject: [PATCH 7/7] use opencode cli native commands instead of sqlite queries (#439) Entire-Checkpoint: d5cbd5b0342d --- cmd/entire/cli/agent/opencode/cli_commands.go | 52 +++++++++ cmd/entire/cli/agent/opencode/opencode.go | 27 +++-- cmd/entire/cli/agent/opencode/sqlite.go | 110 ------------------ 3 files changed, 65 insertions(+), 124 deletions(-) create mode 100644 cmd/entire/cli/agent/opencode/cli_commands.go delete mode 100644 cmd/entire/cli/agent/opencode/sqlite.go diff --git a/cmd/entire/cli/agent/opencode/cli_commands.go b/cmd/entire/cli/agent/opencode/cli_commands.go new file mode 100644 index 000000000..e12ac7ad4 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/cli_commands.go @@ -0,0 +1,52 @@ +package opencode + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// openCodeCommandTimeout is the maximum time to wait for opencode CLI commands. +const openCodeCommandTimeout = 30 * time.Second + +// runOpenCodeSessionDelete runs `opencode session delete ` to remove +// a session from OpenCode's database. Treats "Session not found" as success +// (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) + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout) + } + // Treat "Session not found" as success — nothing to delete. + if strings.Contains(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 ` to import a session into +// OpenCode's database. The import preserves the original session ID +// from the export file. +func runOpenCodeImport(exportFilePath string) error { + ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "import", exportFilePath) + if output, err := cmd.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("opencode import timed out after %s", openCodeCommandTimeout) + } + return fmt.Errorf("opencode import failed: %w (output: %s)", err, string(output)) + } + + return nil +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index de9f0de72..e1b169eaf 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -144,14 +144,14 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { return fmt.Errorf("failed to write session data: %w", err) } - // 2. If we have export data, import the session into OpenCode's SQLite. + // 2. If we have export data, import the session into OpenCode. // This enables `opencode -s ` for both resume and rewind. if len(session.ExportData) == 0 { - return nil // No export data — skip SQLite import (graceful degradation) + return nil // No export data — skip import (graceful degradation) } - if err := a.importSessionIntoSQLite(session.SessionID, session.ExportData); err != nil { - // Non-fatal: SQLite import is best-effort. The JSONL file is written, + 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 ` manually. fmt.Fprintf(os.Stderr, "warning: could not import session into OpenCode: %v\n", err) } @@ -159,18 +159,17 @@ func (a *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { return nil } -// importSessionIntoSQLite writes the export JSON to a temp file and runs -// `opencode import` to restore the session into OpenCode's SQLite database. -// For rewind (session already exists), messages are deleted first so the -// reimport replaces them with the checkpoint-state messages. -func (a *OpenCodeAgent) importSessionIntoSQLite(sessionID string, exportData []byte) error { - // If the session already exists in SQLite, delete its messages first. +// 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 the 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). - if sessionExistsInSQLite(sessionID) { - if err := deleteMessagesFromSQLite(sessionID); err != nil { - return fmt.Errorf("failed to clear existing messages: %w", err) - } + // runOpenCodeSessionDelete treats "not found" as success. + if err := runOpenCodeSessionDelete(sessionID); err != nil { + return fmt.Errorf("failed to delete existing session: %w", err) } // Write export JSON to a temp file for opencode import diff --git a/cmd/entire/cli/agent/opencode/sqlite.go b/cmd/entire/cli/agent/opencode/sqlite.go deleted file mode 100644 index 0388e9dac..000000000 --- a/cmd/entire/cli/agent/opencode/sqlite.go +++ /dev/null @@ -1,110 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" -) - -// openCodeImportTimeout is the maximum time to wait for `opencode import` to complete. -const openCodeImportTimeout = 30 * time.Second - -// getOpenCodeDBPath returns the path to OpenCode's SQLite database. -// OpenCode always uses ~/.local/share/opencode/opencode.db (XDG default) -// regardless of platform — it does NOT use ~/Library/Application Support on macOS. -// -// XDG_DATA_HOME overrides the default on all platforms. -func getOpenCodeDBPath() (string, error) { - dataDir := os.Getenv("XDG_DATA_HOME") - if dataDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - dataDir = filepath.Join(home, ".local", "share") - } - return filepath.Join(dataDir, "opencode", "opencode.db"), nil -} - -// runSQLiteQuery executes a SQL query against OpenCode's SQLite database. -// Returns the combined stdout/stderr output. -func runSQLiteQuery(query string, timeout time.Duration) ([]byte, error) { - dbPath, err := getOpenCodeDBPath() - if err != nil { - return nil, fmt.Errorf("failed to get OpenCode DB path: %w", err) - } - if _, err := os.Stat(dbPath); os.IsNotExist(err) { - return nil, fmt.Errorf("OpenCode database not found: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - //nolint:gosec // G204: query is constructed from sanitized inputs (escapeSQLiteString) - cmd := exec.CommandContext(ctx, "sqlite3", dbPath, query) - output, err := cmd.CombinedOutput() - if err != nil { - return output, fmt.Errorf("sqlite3 query failed: %w", err) - } - return output, nil -} - -// sessionExistsInSQLite checks whether a session with the given ID exists -// in OpenCode's SQLite database. -func sessionExistsInSQLite(sessionID string) bool { - query := fmt.Sprintf("SELECT count(*) FROM session WHERE id = '%s';", escapeSQLiteString(sessionID)) - output, err := runSQLiteQuery(query, 5*time.Second) - if err != nil { - return false - } - return len(output) > 0 && output[0] != '0' -} - -// deleteMessagesFromSQLite removes all messages (and cascading parts) for a session. -// This is used before reimporting a session during rewind so that `opencode import` -// can insert the checkpoint-state messages (import uses ON CONFLICT DO NOTHING). -func deleteMessagesFromSQLite(sessionID string) error { - // Enable foreign keys so CASCADE deletes work (parts are deleted with messages). - query := fmt.Sprintf( - "PRAGMA foreign_keys = ON; DELETE FROM message WHERE session_id = '%s';", - escapeSQLiteString(sessionID), - ) - if output, err := runSQLiteQuery(query, 5*time.Second); err != nil { - return fmt.Errorf("failed to delete messages from OpenCode DB: %w (output: %s)", err, string(output)) - } - return nil -} - -// runOpenCodeImport runs `opencode import ` to import a session into -// OpenCode's SQLite database. The import preserves the original session ID -// from the export file. -func runOpenCodeImport(exportFilePath string) error { - ctx, cancel := context.WithTimeout(context.Background(), openCodeImportTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "opencode", "import", exportFilePath) - if output, err := cmd.CombinedOutput(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("opencode import timed out after %s", openCodeImportTimeout) - } - return fmt.Errorf("opencode import failed: %w (output: %s)", err, string(output)) - } - - return nil -} - -// escapeSQLiteString escapes single quotes in a string for safe use in SQLite queries. -func escapeSQLiteString(s string) string { - result := make([]byte, 0, len(s)) - for i := range len(s) { - if s[i] == '\'' { - result = append(result, '\'', '\'') - } else { - result = append(result, s[i]) - } - } - return string(result) -}