From eb67474440f530423229ebe798353fe90564a701 Mon Sep 17 00:00:00 2001 From: Adrian Mato Date: Sun, 15 Feb 2026 00:33:26 -0800 Subject: [PATCH 1/3] add OpenCode agent support with plugin, hooks, and registry wiring --- README.md | 24 +- cmd/entire/cli/agent/opencode/entire.ts | 178 +++++++ cmd/entire/cli/agent/opencode/hooks.go | 114 +++++ cmd/entire/cli/agent/opencode/hooks_test.go | 308 ++++++++++++ cmd/entire/cli/agent/opencode/opencode.go | 307 ++++++++++++ .../cli/agent/opencode/opencode_test.go | 287 +++++++++++ cmd/entire/cli/agent/opencode/types.go | 130 +++++ cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/hook_registry.go | 38 +- cmd/entire/cli/hooks_cmd.go | 1 + cmd/entire/cli/hooks_opencode_handlers.go | 462 ++++++++++++++++++ cmd/entire/cli/setup.go | 87 +--- cmd/entire/cli/state.go | 72 +++ cmd/entire/cli/summarize/summarize.go | 2 + 14 files changed, 1946 insertions(+), 66 deletions(-) create mode 100644 cmd/entire/cli/agent/opencode/entire.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/opencode.go create mode 100644 cmd/entire/cli/agent/opencode/opencode_test.go create mode 100644 cmd/entire/cli/agent/opencode/types.go create mode 100644 cmd/entire/cli/hooks_opencode_handlers.go diff --git a/README.md b/README.md index 737fcf450..d3de72af4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ With Entire, you can: - Git - macOS or Linux (Windows via WSL) -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and authenticated +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [OpenCode](https://opencode.ai) installed and authenticated ## Quick Start @@ -55,7 +55,7 @@ entire status entire enable ``` -This installs agent and git hooks to work with your AI agent (Claude Code or Gemini CLI). The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch. +This installs agent and git hooks to work with your AI agent (Claude Code, Gemini CLI, or OpenCode). The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch. **When checkpoints are created** depends on your chosen strategy (default is `manual-commit`): - **Manual-commit**: Checkpoints are created when you or the agent make a git commit @@ -63,7 +63,7 @@ This installs agent and git hooks to work with your AI agent (Claude Code or Gem ### 2. Work with Your AI Agent -Just use Claude Code or Gemini CLI normally. Entire runs in the background, tracking your session: +Just use Claude Code, Gemini CLI, or OpenCode normally. Entire runs in the background, tracking your session: ``` entire status # Check current session status anytime @@ -179,7 +179,7 @@ Multiple AI sessions can run on the same commit. If you start a second session w | Flag | Description | |------------------------|--------------------------------------------------------------------| -| `--agent ` | AI agent to setup hooks for: `claude-code` (default) or `gemini` | +| `--agent ` | AI agent to setup hooks for: `claude-code` (default), `gemini`, or `opencode` | | `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) | | `--local` | Write settings to `settings.local.json` instead of `settings.json` | | `--project` | Write settings to `settings.json` even if it already exists | @@ -276,6 +276,20 @@ All commands (`rewind`, `status`, `doctor`, etc.) work the same regardless of wh If you run into any issues with Gemini CLI integration, please [open an issue](https://github.com/entireio/cli/issues). +### OpenCode (Preview) + +OpenCode support is currently in preview. Entire can work with [OpenCode](https://opencode.ai) as an alternative to Claude Code or Gemini CLI, or alongside them — you can have multiple agents' hooks enabled at the same time. + +To enable: + +```bash +entire enable --agent opencode +``` + +OpenCode integration uses a TypeScript plugin (`entire.ts`) installed into `.opencode/plugins/`, which OpenCode auto-loads at startup. All commands (`rewind`, `status`, `doctor`, etc.) work the same regardless of which agent is configured. + +If you run into any issues with OpenCode integration, please [open an issue](https://github.com/entireio/cli/issues). + ## Troubleshooting ### Common Issues @@ -284,7 +298,7 @@ If you run into any issues with Gemini CLI integration, please [open an issue](h |--------------------------|-------------------------------------------------------------------------------------------| | "Not a git repository" | Navigate to a Git repository first | | "Entire is disabled" | Run `entire enable` | -| "No rewind points found" | Work with Claude Code and commit (manual-commit) or wait for agent response (auto-commit) | +| "No rewind points found" | Work with your AI agent and commit (manual-commit) or wait for agent response (auto-commit) | | "shadow branch conflict" | Run `entire reset --force` | ### SSH Authentication Errors diff --git a/cmd/entire/cli/agent/opencode/entire.ts b/cmd/entire/cli/agent/opencode/entire.ts new file mode 100644 index 000000000..24d8b5d6f --- /dev/null +++ b/cmd/entire/cli/agent/opencode/entire.ts @@ -0,0 +1,178 @@ +// Entire integration plugin for OpenCode. +// This file is auto-generated by `entire enable` — do not edit manually. +// It hooks into OpenCode's plugin system to notify Entire of session lifecycle events. + +import { execSync, execFileSync } from "child_process"; +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +interface HookPayload { + session_id: string; + session_ref: string; + timestamp: string; + transcript_path: string; + tool_name?: string; + tool_use_id?: string; + tool_input?: Record; + tool_response?: Record; + subagent_transcript_path?: string; +} + +// Resolve the entire binary path once at load time. +// Supports ENTIRE_BIN env var override for development/testing. +function resolveBinary(): string { + const envBin = process.env.ENTIRE_BIN; + if (envBin) return envBin; + try { + return execSync("which entire", { encoding: "utf-8" }).trim(); + } catch { + return "entire"; + } +} + +const entireBin = resolveBinary(); + +function callEntire( + log: (msg: string) => void, + verb: string, + payload: HookPayload, +): void { + try { + const input = JSON.stringify(payload); + log(`calling: ${entireBin} hooks opencode ${verb}`); + execFileSync(entireBin, ["hooks", "opencode", verb], { + input, + stdio: ["pipe", "pipe", "pipe"], + timeout: 30_000, + }); + log(`hook ${verb} completed`); + } catch (e: any) { + log(`hook ${verb} failed: ${e?.message ?? e}`); + } +} + +async function exportTranscript( + client: any, + log: (msg: string) => void, + sessionId: string, +): Promise { + const dir = join(tmpdir(), "entire-opencode"); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, `${sessionId}.jsonl`); + try { + log(`exporting transcript for session ${sessionId}`); + const response = await client.session.messages({ + path: { id: sessionId }, + }); + const messages: any[] = response.data ?? response ?? []; + const lines = messages.map((m: any) => JSON.stringify(m)); + writeFileSync(filePath, lines.join("\n") + "\n"); + log(`transcript exported: ${messages.length} messages → ${filePath}`); + } catch (e: any) { + log(`transcript export failed: ${e?.message ?? e}`); + // Write empty file so hook handler still runs + writeFileSync(filePath, ""); + } + return filePath; +} + +export const EntirePlugin = async (ctx: { + project: any; + client: any; + $: any; + directory: string; + worktree: string; +}) => { + // Structured logging via OpenCode SDK + const log = (message: string) => { + ctx.client.app + .log({ + body: { + service: "entire", + level: "info", + message, + }, + }) + .catch(() => {}); + }; + + log(`plugin loaded, binary: ${entireBin}`); + + return { + event: async ({ event }: { event: { type: string; properties: any } }) => { + if (event.type === "session.created") { + const sessionId = event.properties.info?.id; + log(`session.created event, sessionId=${sessionId}`); + if (!sessionId) return; + const payload: HookPayload = { + session_id: sessionId, + session_ref: sessionId, + timestamp: new Date().toISOString(), + transcript_path: "", + }; + callEntire(log, "session-start", payload); + } + + if (event.type === "session.idle") { + const sessionId = event.properties.sessionID; + log(`session.idle event, sessionId=${sessionId}`); + if (!sessionId) return; + const transcriptPath = await exportTranscript( + ctx.client, + log, + sessionId, + ); + const payload: HookPayload = { + session_id: sessionId, + session_ref: sessionId, + timestamp: new Date().toISOString(), + transcript_path: transcriptPath, + }; + callEntire(log, "stop", payload); + } + }, + + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, + ) => { + if (input.tool !== "task") return; + log(`tool.execute.before: task ${input.callID}`); + const payload: HookPayload = { + session_id: input.sessionID, + session_ref: input.sessionID, + timestamp: new Date().toISOString(), + transcript_path: "", + tool_name: input.tool, + tool_use_id: input.callID, + tool_input: output.args, + }; + callEntire(log, "task-start", payload); + }, + + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: any }, + ) => { + if (input.tool !== "task") return; + log(`tool.execute.after: task ${input.callID}`); + const transcriptPath = await exportTranscript( + ctx.client, + log, + input.sessionID, + ); + const payload: HookPayload = { + session_id: input.sessionID, + session_ref: input.sessionID, + timestamp: new Date().toISOString(), + transcript_path: transcriptPath, + tool_name: input.tool, + tool_use_id: input.callID, + tool_response: { output: output.output }, + subagent_transcript_path: transcriptPath, + }; + callEntire(log, "task-complete", payload); + }, + }; +}; diff --git a/cmd/entire/cli/agent/opencode/hooks.go b/cmd/entire/cli/agent/opencode/hooks.go new file mode 100644 index 000000000..8649c6614 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/hooks.go @@ -0,0 +1,114 @@ +package opencode + +import ( + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure OpenCodeAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*OpenCodeAgent)(nil) + _ agent.HookHandler = (*OpenCodeAgent)(nil) +) + +// pluginFileName is the name of the Entire plugin file installed into OpenCode. +const pluginFileName = "entire.ts" + +// pluginDir is the directory within .opencode where plugins are stored. +const pluginDir = "plugins" + +//go:embed entire.ts +var pluginFS embed.FS + +// GetHookNames returns the hook verbs OpenCode supports. +// These become subcommands: entire hooks opencode +func (o *OpenCodeAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameStop, + HookNameTaskStart, + HookNameTaskComplete, + } +} + +// InstallHooks installs the Entire plugin into .opencode/plugins/entire.ts. +// OpenCode auto-loads TypeScript plugins from .opencode/plugins/ at startup. +// If force is true, overwrites the existing plugin file. +// Returns the number of hooks installed (1 plugin file = 1). +func (o *OpenCodeAgent) InstallHooks(_ bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName) + + // Idempotency: if plugin already exists and force is false, skip + if !force { + if _, err := os.Stat(pluginPath); err == nil { + return 0, nil + } + } + + // Read the embedded plugin source + pluginContent, err := pluginFS.ReadFile(pluginFileName) + if err != nil { + return 0, fmt.Errorf("failed to read embedded plugin: %w", err) + } + + // Create the plugins directory + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create plugins directory: %w", err) + } + + // Write the plugin file + if err := os.WriteFile(pluginPath, pluginContent, 0o600); err != nil { + return 0, fmt.Errorf("failed to write plugin file: %w", err) + } + + return 1, nil +} + +// UninstallHooks removes the Entire plugin from .opencode/plugins/. +func (o *OpenCodeAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName) + 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. +func (o *OpenCodeAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName) + _, err = os.Stat(pluginPath) + return err == nil +} + +// GetSupportedHooks returns the hook types OpenCode supports. +func (o *OpenCodeAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookStop, + agent.HookPreToolUse, + agent.HookPostToolUse, + } +} 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..c0c54a309 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/hooks_test.go @@ -0,0 +1,308 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface assertions +var ( + _ agent.HookSupport = (*OpenCodeAgent)(nil) + _ agent.HookHandler = (*OpenCodeAgent)(nil) +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + // Verify plugin file was created + pluginPath := filepath.Join(tempDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + t.Error("plugin file was not created") + } + + // Verify content is not empty + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + if len(data) == 0 { + t.Error("plugin file is empty") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + + // First install + count1, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 1 { + t.Errorf("first InstallHooks() count = %d, want 1", count1) + } + + // Second install should skip (file exists) + count2, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) + } +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + + // First install + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should replace + count, err := ag.InstallHooks(false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 1 { + t.Errorf("force InstallHooks() count = %d, want 1", count) + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + + // First install + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify hooks are installed + if !ag.AreHooksInstalled() { + t.Error("hooks should be installed before uninstall") + } + + // Uninstall + err = ag.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify hooks are removed + if ag.AreHooksInstalled() { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoPluginFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + + // Should not error when no plugin file exists + err := ag.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() should not error when no plugin file: %v", err) + } +} + +func TestAreHooksInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + + // Should be false when no plugin file + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be false when no plugin file") + } + + // Install hooks + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Should be true after installation + if !ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be true after installation") + } +} + +func TestGetHookNames(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + names := ag.GetHookNames() + + expected := []string{ + HookNameSessionStart, + HookNameStop, + HookNameTaskStart, + HookNameTaskComplete, + } + + if len(names) != len(expected) { + t.Fatalf("GetHookNames() returned %d names, want %d", len(names), len(expected)) + } + + for i, name := range expected { + if names[i] != name { + t.Errorf("GetHookNames()[%d] = %q, want %q", i, names[i], name) + } + } +} + +func TestGetSupportedHooks(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + hooks := ag.GetSupportedHooks() + + if len(hooks) != 4 { + t.Errorf("GetSupportedHooks() returned %d hooks, want 4", len(hooks)) + } + + expected := []agent.HookType{ + agent.HookSessionStart, + agent.HookStop, + agent.HookPreToolUse, + agent.HookPostToolUse, + } + + for i, hook := range expected { + if hooks[i] != hook { + t.Errorf("GetSupportedHooks()[%d] = %q, want %q", i, hooks[i], hook) + } + } +} + +func TestInstallHooks_PluginContent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read the installed plugin + pluginPath := filepath.Join(tempDir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + + content := string(data) + + // Verify key content from the embedded plugin + mustContain := []string{ + "hooks", "opencode", + "session.created", + "session.idle", + "tool.execute.before", + "tool.execute.after", + "ENTIRE_BIN", + "execFileSync", + } + + for _, check := range mustContain { + if !contains(content, check) { + t.Errorf("plugin file missing expected content: %q", check) + } + } +} + +func TestInstallHooks_PluginAPIContract(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + pluginPath := filepath.Join(tempDir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + + content := string(data) + + // Structural assertions — plugin must follow OpenCode's plugin API + structural := []struct { + check string + desc string + }{ + {"export const EntirePlugin", "must use named export (not default export)"}, + {"async (", "must be async function"}, + {"return {", "must return hooks object"}, + {"event.type ===", "must dispatch on event.type (not $.on)"}, + {"event.properties.", "must access event.properties (OpenCode event shape)"}, + {"client.session.messages", "must use SDK to export transcript"}, + {"client.app", "must use SDK structured logging (client.app.log)"}, + {"resolveBinary", "must resolve binary path at load time"}, + {"execFileSync", "must use execFileSync (not execSync with string)"}, + } + for _, s := range structural { + if !contains(content, s.check) { + t.Errorf("plugin API contract: %s (missing %q)", s.desc, s.check) + } + } + + // Negative assertions — must NOT use patterns from broken implementations + forbidden := []struct { + check string + desc string + }{ + {"$.on(", "must not use $.on() — $ is Bun shell, not event emitter"}, + {"export default function", "must not use default function export"}, + {"export default async function", "must not use default async function export"}, + } + for _, f := range forbidden { + if contains(content, f.check) { + t.Errorf("plugin API contract violation: %s (found %q)", f.desc, f.check) + } + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go new file mode 100644 index 000000000..cbf1923f1 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -0,0 +1,307 @@ +// Package opencode implements the Agent interface for OpenCode. +package opencode + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "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) +} + +// OpenCodeAgent implements the Agent interface for OpenCode. +// +//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{} +} + +// Name returns the agent registry key. +func (o *OpenCodeAgent) Name() agent.AgentName { + return agent.AgentNameOpenCode +} + +// Type returns the agent type identifier. +func (o *OpenCodeAgent) Type() agent.AgentType { + return agent.AgentTypeOpenCode +} + +// Description returns a human-readable description. +func (o *OpenCodeAgent) Description() string { + return "OpenCode - AI coding agent" +} + +// DetectPresence checks if OpenCode is configured in the repository. +func (o *OpenCodeAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + // Check for .opencode directory + opencodeDir := filepath.Join(repoRoot, ".opencode") + if _, err := os.Stat(opencodeDir); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to OpenCode's plugin file. +func (o *OpenCodeAgent) GetHookConfigPath() string { + return ".opencode/plugins/entire.ts" +} + +// SupportsHooks returns true as OpenCode supports lifecycle hooks via plugins. +func (o *OpenCodeAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses OpenCode hook input from stdin. +// OpenCode uses a uniform JSON structure for all hook events. +func (o *OpenCodeAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + var raw hookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + input.SessionID = raw.SessionID + // Map transcript_path to SessionRef for downstream compatibility. + // SessionRef is used by commitWithMetadata and other shared handlers. + if raw.SessionRef != "" { + input.SessionRef = raw.SessionRef + } + if raw.TranscriptPath != "" { + input.SessionRef = raw.TranscriptPath + input.RawData["transcript_path"] = raw.TranscriptPath + } + + if raw.ToolName != "" { + input.ToolName = raw.ToolName + } + if raw.ToolUseID != "" { + input.ToolUseID = raw.ToolUseID + } + if raw.ToolInput != nil { + input.ToolInput = raw.ToolInput + } + if raw.ToolResponse != nil { + input.ToolResponse = raw.ToolResponse + } + if raw.SubagentTranscriptPath != "" { + input.RawData["subagent_transcript_path"] = raw.SubagentTranscriptPath + } + + if raw.Timestamp != "" { + if ts, parseErr := time.Parse(time.RFC3339, raw.Timestamp); parseErr == nil { + input.Timestamp = ts + } + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (o *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ProtectedDirs returns directories that OpenCode uses for config/state. +func (o *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } + +// ResolveSessionFile returns the path to an OpenCode session file. +// OpenCode uses JSONL transcript files named by session ID. +func (o *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// GetSessionDir returns where OpenCode stores session data. +// OpenCode stores sessions in .opencode/sessions/ within the project directory. +func (o *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR"); override != "" { + return override, nil + } + + return filepath.Join(repoPath, ".opencode", "sessions"), nil +} + +// ReadSession reads a session from OpenCode's storage (JSONL transcript file). +func (o *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + modifiedFiles := ExtractModifiedFiles(data) + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: o.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: modifiedFiles, + }, nil +} + +// WriteSession writes a session to OpenCode's storage (JSONL transcript file). +func (o *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != o.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, o.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an OpenCode session. +func (o *OpenCodeAgent) FormatResumeCommand(sessionID string) string { + return "opencode --resume " + sessionID +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current line count of an OpenCode transcript. +// OpenCode uses JSONL format, so position is the number of lines. +func (o *OpenCodeAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from OpenCode transcript location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + _, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +// For OpenCode (JSONL format), offset is the starting line number. +func (o *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec // Path comes from OpenCode transcript location + if openErr != nil { + if os.IsNotExist(openErr) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + fileSet := make(map[string]bool) + lineNum := 0 + + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(lineData) > 0 { + lineNum++ + if lineNum > startOffset { + var entry TranscriptEntry + if parseErr := json.Unmarshal(lineData, &entry); parseErr == nil { + for _, f := range extractFilesFromEntry(&entry) { + if !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + } + } + + if readErr == io.EOF { + break + } + } + + return files, lineNum, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (o *OpenCodeAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +func (o *OpenCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} diff --git a/cmd/entire/cli/agent/opencode/opencode_test.go b/cmd/entire/cli/agent/opencode/opencode_test.go new file mode 100644 index 000000000..96b62b5d0 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode_test.go @@ -0,0 +1,287 @@ +package opencode + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + result := ag.ResolveSessionFile("/home/user/.opencode/sessions", "sess-abc-123") + expected := "/home/user/.opencode/sessions/sess-abc-123.jsonl" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".opencode" { + t.Errorf("ProtectedDirs() = %v, want [.opencode]", dirs) + } +} + +func TestName(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + if ag.Name() != agent.AgentNameOpenCode { + t.Errorf("Name() = %q, want %q", ag.Name(), agent.AgentNameOpenCode) + } +} + +func TestType(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + if ag.Type() != agent.AgentTypeOpenCode { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeOpenCode) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() should not be empty") + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() should return true") + } +} + +func TestGetHookConfigPath(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + path := ag.GetHookConfigPath() + if path != ".opencode/plugins/entire.ts" { + t.Errorf("GetHookConfigPath() = %q, want %q", path, ".opencode/plugins/entire.ts") + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + cmd := ag.FormatResumeCommand("sess-123") + expected := "opencode --resume sess-123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestGetSessionID(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + input := &agent.HookInput{SessionID: "test-session-id"} + if ag.GetSessionID(input) != "test-session-id" { + t.Errorf("GetSessionID() = %q, want %q", ag.GetSessionID(input), "test-session-id") + } +} + +func TestParseHookInput_SessionStart(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + input := `{"session_id":"sess-123","session_ref":"sess-123","timestamp":"2026-01-13T12:00:00Z","transcript_path":"/tmp/sessions/sess-123.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "sess-123" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "sess-123") + } + if result.SessionRef != "/tmp/sessions/sess-123.jsonl" { + t.Errorf("SessionRef = %q, want %q", result.SessionRef, "/tmp/sessions/sess-123.jsonl") + } + if result.HookType != agent.HookSessionStart { + t.Errorf("HookType = %q, want %q", result.HookType, agent.HookSessionStart) + } +} + +func TestParseHookInput_Stop(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + input := `{"session_id":"sess-456","session_ref":"sess-456","timestamp":"2026-01-13T12:00:00Z","transcript_path":"/tmp/sessions/sess-456.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "sess-456" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "sess-456") + } + if result.HookType != agent.HookStop { + t.Errorf("HookType = %q, want %q", result.HookType, agent.HookStop) + } +} + +func TestParseHookInput_TaskStart(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + input := `{"session_id":"sess-789","session_ref":"sess-789","timestamp":"2026-01-13T12:00:00Z","transcript_path":"/tmp/sessions/sess-789.jsonl","tool_name":"Task","tool_use_id":"tool-abc"}` + + result, err := ag.ParseHookInput(agent.HookPreToolUse, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.ToolName != "Task" { + t.Errorf("ToolName = %q, want %q", result.ToolName, "Task") + } + if result.ToolUseID != "tool-abc" { + t.Errorf("ToolUseID = %q, want %q", result.ToolUseID, "tool-abc") + } +} + +func TestParseHookInput_EmptyInput(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("")) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("not json")) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +func TestParseHookInput_TranscriptPathMapsToSessionRef(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + // When transcript_path is set, it should override session_ref + input := `{"session_id":"sess-1","session_ref":"original-ref","transcript_path":"/path/to/transcript.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionRef != "/path/to/transcript.jsonl" { + t.Errorf("SessionRef = %q, want %q (transcript_path should override session_ref)", result.SessionRef, "/path/to/transcript.jsonl") + } +} + +func TestParseHookInput_SubagentTranscriptPath(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + input := `{"session_id":"sess-1","transcript_path":"/tmp/main.jsonl","subagent_transcript_path":"/tmp/subagent.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookPostToolUse, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.RawData["subagent_transcript_path"] != "/tmp/subagent.jsonl" { + t.Errorf("subagent_transcript_path = %v, want %q", result.RawData["subagent_transcript_path"], "/tmp/subagent.jsonl") + } +} + +func TestReadSession_EmptySessionRef(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + _, err := ag.ReadSession(&agent.HookInput{}) + if err == nil { + t.Error("ReadSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_NilSession(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + err := ag.WriteSession(nil) + if err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + err := ag.WriteSession(&agent.AgentSession{AgentName: "other-agent"}) + if err == nil { + t.Error("WriteSession() should error for wrong agent name") + } +} + +func TestWriteSession_EmptySessionRef(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + err := ag.WriteSession(&agent.AgentSession{ + AgentName: agent.AgentNameOpenCode, + }) + if err == nil { + t.Error("WriteSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_EmptyNativeData(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + err := ag.WriteSession(&agent.AgentSession{ + AgentName: agent.AgentNameOpenCode, + SessionRef: "/tmp/test.jsonl", + }) + if err == nil { + t.Error("WriteSession() should error when NativeData is empty") + } +} + +func TestGetTranscriptPosition_EmptyPath(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + pos, err := ag.GetTranscriptPosition("") + if err != nil { + t.Fatalf("GetTranscriptPosition('') error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition('') = %d, want 0", pos) + } +} + +func TestGetTranscriptPosition_NonexistentFile(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + pos, err := ag.GetTranscriptPosition("/nonexistent/path.jsonl") + if err != nil { + t.Fatalf("GetTranscriptPosition(nonexistent) error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition(nonexistent) = %d, want 0", pos) + } +} + +func TestExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset('', 0) error = %v", err) + } + if len(files) != 0 || pos != 0 { + t.Errorf("ExtractModifiedFilesFromOffset('', 0) = %v, %d; want nil, 0", files, pos) + } +} + +// Compile-time interface assertions +var ( + _ agent.Agent = (*OpenCodeAgent)(nil) + _ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil) + _ agent.TranscriptChunker = (*OpenCodeAgent)(nil) +) diff --git a/cmd/entire/cli/agent/opencode/types.go b/cmd/entire/cli/agent/opencode/types.go new file mode 100644 index 000000000..97292a9b8 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/types.go @@ -0,0 +1,130 @@ +package opencode + +import "encoding/json" + +// Hook names - these become subcommands under `entire hooks opencode` +const ( + HookNameSessionStart = "session-start" + HookNameStop = "stop" + HookNameTaskStart = "task-start" + HookNameTaskComplete = "task-complete" +) + +// hookInputRaw is the JSON structure from all OpenCode hooks. +// OpenCode sends the same shape for all hook events via the plugin system. +type hookInputRaw struct { + SessionID string `json:"session_id"` + SessionRef string `json:"session_ref"` + Timestamp string `json:"timestamp"` + ToolName string `json:"tool_name,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolResponse json.RawMessage `json:"tool_response,omitempty"` + TranscriptPath string `json:"transcript_path"` + SubagentTranscriptPath string `json:"subagent_transcript_path,omitempty"` +} + +// Tool names used in OpenCode that modify files +const ( + ToolWrite = "file_write" + ToolEdit = "file_edit" + ToolPatch = "file_patch" +) + +// FileModificationTools lists tools that create or modify files in OpenCode +var FileModificationTools = []string{ + ToolWrite, + ToolEdit, + ToolPatch, +} + +// Transcript entry types +const ( + MessageRoleUser = "user" + MessageRoleAssistant = "assistant" +) + +// Part types in OpenCode transcripts +const ( + PartTypeText = "text" + PartTypeTool = "tool" + PartTypeStepStart = "step-start" + PartTypeStepFinish = "step-finish" + PartTypePatch = "patch" +) + +// TranscriptEntry represents a single JSONL line in an OpenCode transcript. +type TranscriptEntry struct { + Info TranscriptEntryInfo `json:"info"` + Parts []TranscriptPart `json:"parts"` +} + +// TranscriptEntryInfo contains metadata for a transcript entry. +// Assistant messages include token usage and cost from the provider API. +type TranscriptEntryInfo struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` + Time TranscriptEntryTime `json:"time"` + Summary *TranscriptSummary `json:"summary,omitempty"` + Tokens *TranscriptTokens `json:"tokens,omitempty"` + Cost float64 `json:"cost,omitempty"` +} + +// TranscriptEntryTime contains timing information. +type TranscriptEntryTime struct { + Created int64 `json:"created"` + Completed int64 `json:"completed"` +} + +// TranscriptSummary contains a summary of changes in the entry. +type TranscriptSummary struct { + Title string `json:"title"` + Diffs []TranscriptDiff `json:"diffs,omitempty"` +} + +// TranscriptDiff represents a file change. +type TranscriptDiff struct { + File string `json:"file"` + Before string `json:"before"` + After string `json:"after"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +// TranscriptPart represents a part within a transcript entry. +type TranscriptPart struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + CallID string `json:"callID,omitempty"` + FilePath string `json:"filePath,omitempty"` + State *TranscriptToolState `json:"state,omitempty"` + Tokens *TranscriptTokens `json:"tokens,omitempty"` + Cost float64 `json:"cost,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// TranscriptTokens contains token usage from the provider API. +// Present on assistant message info and step-finish parts. +type TranscriptTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Reasoning int `json:"reasoning"` + Cache TranscriptTokensCache `json:"cache"` +} + +// TranscriptTokensCache contains cache-specific token counts. +type TranscriptTokensCache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +// TranscriptToolState contains tool execution state. +type TranscriptToolState struct { + Status string `json:"status,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Title string `json:"title,omitempty"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..232e8288f 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -80,12 +80,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/hook_registry.go b/cmd/entire/cli/hook_registry.go index 8505c9be5..06e55a890 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -10,6 +10,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" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -43,7 +44,7 @@ func GetHookHandler(agentName agent.AgentName, hookName string) HookHandlerFunc // init registers Claude Code hook handlers. // Each handler checks if Entire is enabled before executing. // -//nolint:gochecknoinits // Hook handler registration at startup is the intended pattern +//nolint:gochecknoinits,maintidx // Hook handler registration at startup is the intended pattern; complexity is inherent to registering all agent handlers func init() { // Register Claude Code handlers RegisterHookHandler(agent.AgentNameClaudeCode, claudecode.HookNameSessionStart, func() error { @@ -190,6 +191,39 @@ func init() { } return handleGeminiNotification() }) + + // Register OpenCode handlers + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameSessionStart, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpencodeSessionStart() + }) + + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameStop, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpencodeStop() + }) + + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameTaskStart, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpencodeTaskStart() + }) + + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameTaskComplete, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpencodeTaskComplete() + }) } // agentHookLogCleanup stores the cleanup function for agent hook logging. @@ -255,6 +289,8 @@ func getHookType(hookName string) string { return "subagent" case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool: return "tool" + case opencode.HookNameTaskStart, opencode.HookNameTaskComplete: + return "subagent" default: return "agent" } diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..918a8848d 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/hooks_opencode_handlers.go b/cmd/entire/cli/hooks_opencode_handlers.go new file mode 100644 index 000000000..985dff84d --- /dev/null +++ b/cmd/entire/cli/hooks_opencode_handlers.go @@ -0,0 +1,462 @@ +// hooks_opencode_handlers.go contains OpenCode specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// handleOpencodeSessionStart handles the SessionStart hook for OpenCode. +// Calls the shared session-start logic and ensures strategy setup. +// OpenCode has no separate per-turn "before-agent" hook, so the first turn +// will not have pre-prompt state; the stop handler re-captures state for +// subsequent turns. +func handleOpencodeSessionStart() error { + if err := handleSessionStartCommon(); err != nil { + return err + } + + // Ensure strategy setup (git hooks, gitignore, metadata branch) + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) + } + + return nil +} + +// handleOpencodeStop handles the Stop hook for OpenCode. +// This fires after the agent finishes processing a user prompt. +// Follows the Gemini handleGeminiAfterAgent pattern: self-contained commit logic +// with agent-specific transcript parsing. +func handleOpencodeStop() error { + ag, err := agent.Get(agent.AgentNameOpenCode) + if err != nil { + return fmt.Errorf("failed to get opencode agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "opencode-stop", + slog.String("hook", "stop"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + ) + + sessionID := input.SessionID + if sessionID == "" { + sessionID = unknownSessionID + } + + transcriptPath := input.SessionRef + if transcriptPath == "" || !fileExists(transcriptPath) { + return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) + } + + // Early check: bail out if the repo has no commits yet. + if repo, err := strategy.OpenRepository(); err == nil && strategy.IsEmptyRepository(repo) { + fmt.Fprintln(os.Stderr, "Entire: skipping checkpoint. Will activate after first commit.") + return NewSilentError(strategy.ErrEmptyRepository) + } + + ctx := &opencodeSessionContext{ + sessionID: sessionID, + transcriptPath: transcriptPath, + } + + if err := setupOpencodeSessionDir(ctx); err != nil { + return err + } + + if err := extractOpencodeMetadata(ctx); err != nil { + return err + } + + if err := commitOpencodeSession(ctx); err != nil { + return err + } + + // Transition session ACTIVE → IDLE + transitionSessionTurnEnd(sessionID) + + // Re-capture pre-prompt state so subsequent turns have a baseline + if err := CaptureOpencodePrePromptState(sessionID, transcriptPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to re-capture pre-prompt state: %v\n", err) + } + + return nil +} + +// handleOpencodeTaskStart handles the TaskStart hook for OpenCode. +// Captures pre-task state for subagent tracking. +func handleOpencodeTaskStart() error { + ag, err := GetCurrentHookAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookPreToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "opencode-task-start", + slog.String("hook", "task-start"), + slog.String("hook_type", "subagent"), + slog.String("model_session_id", input.SessionID), + slog.String("tool_use_id", input.ToolUseID), + ) + + if input.ToolUseID == "" { + return errors.New("no tool_use_id in task-start input") + } + + if err := CapturePreTaskState(input.ToolUseID); err != nil { + return fmt.Errorf("failed to capture pre-task state: %w", err) + } + + return nil +} + +// handleOpencodeTaskComplete handles the TaskComplete hook for OpenCode. +// Commits subagent task checkpoint with transcript data. +func handleOpencodeTaskComplete() error { + ag, err := GetCurrentHookAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookPostToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "opencode-task-complete", + slog.String("hook", "task-complete"), + slog.String("hook_type", "subagent"), + slog.String("model_session_id", input.SessionID), + slog.String("tool_use_id", input.ToolUseID), + ) + + if input.ToolUseID == "" { + return errors.New("no tool_use_id in task-complete input") + } + + sessionID := input.SessionID + if sessionID == "" { + sessionID = unknownSessionID + } + + // Load pre-task state + preTaskState, err := LoadPreTaskState(input.ToolUseID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-task state: %v\n", err) + } + + // Compute file changes + changes, err := DetectFileChanges(preTaskState.PreUntrackedFiles()) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to detect file changes: %v\n", err) + } + + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + var relModifiedFiles, relNewFiles, relDeletedFiles []string + if changes != nil { + relModifiedFiles = FilterAndNormalizePaths(changes.Modified, repoRoot) + relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) + relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) + } + + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + if totalChanges == 0 { + fmt.Fprintf(os.Stderr, "No files modified during task, skipping checkpoint\n") + if cleanupErr := CleanupPreTaskState(input.ToolUseID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-task state: %v\n", cleanupErr) + } + return nil + } + + // Extract subagent type and description from tool input + subagentType, description := ParseSubagentTypeAndDescription(input.ToolInput) + + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + strat := GetStrategy() + agentType := ag.Type() + + saveCtx := strategy.TaskCheckpointContext{ + SessionID: sessionID, + ToolUseID: input.ToolUseID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + TranscriptPath: input.SessionRef, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agentType, + SubagentType: subagentType, + TaskDescription: description, + } + + // Set subagent transcript if available + if subagentPath, ok := input.RawData["subagent_transcript_path"].(string); ok && subagentPath != "" && fileExists(subagentPath) { + saveCtx.SubagentTranscriptPath = subagentPath + } + + if err := strat.SaveTaskCheckpoint(saveCtx); err != nil { + return fmt.Errorf("failed to save task checkpoint: %w", err) + } + + if cleanupErr := CleanupPreTaskState(input.ToolUseID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-task state: %v\n", cleanupErr) + } + + fmt.Fprintf(os.Stderr, "Task checkpoint saved\n") + return nil +} + +// opencodeSessionContext holds parsed session data for OpenCode commits. +type opencodeSessionContext struct { + sessionID string + transcriptPath string + sessionDir string + sessionDirAbs string + transcriptData []byte + allPrompts []string + summary string + modifiedFiles []string + commitMessage string +} + +// setupOpencodeSessionDir creates session directory and copies transcript. +func setupOpencodeSessionDir(ctx *opencodeSessionContext) error { + ctx.sessionDir = paths.SessionMetadataDirFromSessionID(ctx.sessionID) + sessionDirAbs, err := paths.AbsPath(ctx.sessionDir) + if err != nil { + sessionDirAbs = ctx.sessionDir + } + ctx.sessionDirAbs = sessionDirAbs + + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := copyFile(ctx.transcriptPath, logFile); err != nil { + return fmt.Errorf("failed to copy transcript: %w", err) + } + fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", ctx.sessionDir+"/"+paths.TranscriptFileName) + + transcriptData, err := os.ReadFile(ctx.transcriptPath) + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + ctx.transcriptData = transcriptData + + return nil +} + +// extractOpencodeMetadata extracts prompts, summary, and modified files from transcript. +func extractOpencodeMetadata(ctx *opencodeSessionContext) error { + allPrompts, err := opencode.ExtractAllUserPrompts(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to extract prompts: %v\n", err) + } + ctx.allPrompts = allPrompts + + promptFile := filepath.Join(ctx.sessionDirAbs, paths.PromptFileName) + promptContent := strings.Join(allPrompts, "\n\n---\n\n") + if err := os.WriteFile(promptFile, []byte(promptContent), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted %d prompt(s) to: %s\n", len(allPrompts), ctx.sessionDir+"/"+paths.PromptFileName) + + summary, err := opencode.ExtractLastAssistantMessage(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to extract summary: %v\n", err) + } + ctx.summary = summary + + summaryFile := filepath.Join(ctx.sessionDirAbs, paths.SummaryFileName) + if err := os.WriteFile(summaryFile, []byte(summary), 0o600); err != nil { + return fmt.Errorf("failed to write summary file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted summary to: %s\n", ctx.sessionDir+"/"+paths.SummaryFileName) + + ctx.modifiedFiles = opencode.ExtractModifiedFiles(ctx.transcriptData) + + lastPrompt := "" + if len(allPrompts) > 0 { + lastPrompt = allPrompts[len(allPrompts)-1] + } + ctx.commitMessage = generateCommitMessage(lastPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", ctx.commitMessage) + + return nil +} + +// commitOpencodeSession commits the session changes using the strategy. +func commitOpencodeSession(ctx *opencodeSessionContext) error { + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + preState, err := LoadPrePromptState(ctx.sessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) + } + if preState != nil { + fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files, start line: %d\n", len(preState.UntrackedFiles), preState.StepTranscriptStart) + } + + // Get transcript position from pre-prompt state + var startLineIndex int + if preState != nil { + startLineIndex = preState.StepTranscriptStart + } + + // Compute new and deleted files (single git status call) + changes, err := DetectFileChanges(preState.PreUntrackedFiles()) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to compute file changes: %v\n", err) + } + + relModifiedFiles := FilterAndNormalizePaths(ctx.modifiedFiles, repoRoot) + var relNewFiles, relDeletedFiles []string + if changes != nil { + relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) + relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) + } + + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + if totalChanges == 0 { + fmt.Fprintf(os.Stderr, "No files were modified during this session\n") + fmt.Fprintf(os.Stderr, "Skipping commit\n") + if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + return nil + } + + logFileChanges(relModifiedFiles, relNewFiles, relDeletedFiles) + + contextFile := filepath.Join(ctx.sessionDirAbs, paths.ContextFileName) + if err := createContextFileForOpencode(contextFile, ctx.commitMessage, ctx.sessionID, ctx.allPrompts, ctx.summary); err != nil { + return fmt.Errorf("failed to create context file: %w", err) + } + fmt.Fprintf(os.Stderr, "Created context file: %s\n", ctx.sessionDir+"/"+paths.ContextFileName) + + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + strat := GetStrategy() + + hookAgent, agentErr := GetCurrentHookAgent() + if agentErr != nil { + return fmt.Errorf("failed to get agent: %w", agentErr) + } + agentType := hookAgent.Type() + + // Get transcript identifier at start from pre-prompt state + var transcriptIdentifierAtStart string + if preState != nil { + transcriptIdentifierAtStart = preState.LastTranscriptIdentifier + } + + // Calculate token usage for this checkpoint (OpenCode specific) + var tokenUsage *agent.TokenUsage + if ctx.transcriptPath != "" { + usage, tokenErr := opencode.CalculateTokenUsageFromFile(ctx.transcriptPath, startLineIndex) + if tokenErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to calculate token usage: %v\n", tokenErr) + } else if usage != nil && usage.APICallCount > 0 { + tokenUsage = usage + fmt.Fprintf(os.Stderr, "Token usage for this checkpoint: input=%d, output=%d, cache_read=%d, api_calls=%d\n", + tokenUsage.InputTokens, tokenUsage.OutputTokens, tokenUsage.CacheReadTokens, tokenUsage.APICallCount) + } + } + + saveCtx := strategy.SaveContext{ + SessionID: ctx.sessionID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + MetadataDir: ctx.sessionDir, + MetadataDirAbs: ctx.sessionDirAbs, + CommitMessage: ctx.commitMessage, + TranscriptPath: ctx.transcriptPath, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agentType, + StepTranscriptStart: startLineIndex, + StepTranscriptIdentifier: transcriptIdentifierAtStart, + TokenUsage: tokenUsage, + } + + if err := strat.SaveChanges(saveCtx); err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + + fmt.Fprintf(os.Stderr, "Session saved successfully\n") + return nil +} + +// createContextFileForOpencode creates a context.md file for OpenCode sessions. +func createContextFileForOpencode(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { + var sb strings.Builder + + sb.WriteString("# Session Context\n\n") + sb.WriteString(fmt.Sprintf("Session ID: %s\n", sessionID)) + sb.WriteString(fmt.Sprintf("Commit Message: %s\n\n", commitMessage)) + + if len(prompts) > 0 { + sb.WriteString("## Prompts\n\n") + for i, p := range prompts { + sb.WriteString(fmt.Sprintf("### Prompt %d\n\n%s\n\n", i+1, p)) + } + } + + if summary != "" { + sb.WriteString("## Summary\n\n") + sb.WriteString(summary) + sb.WriteString("\n") + } + + if err := os.WriteFile(contextFile, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("failed to write context file: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index bb04eecae..e697ea0e4 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -112,7 +112,7 @@ Strategies: manual-commit (default), auto-commit`, cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") - cmd.Flags().StringVar(&agentName, "agent", "", "Agent to setup hooks for (e.g., claude-code). Enables non-interactive mode.") + cmd.Flags().StringVar(&agentName, "agent", "", "Agent to setup hooks for (claude-code, gemini, opencode). Enables non-interactive mode.") cmd.Flags().StringVar(&strategyFlag, "strategy", "", "Strategy to use (manual-commit or auto-commit)") cmd.Flags().BoolVarP(&forceHooks, "force", "f", false, "Force reinstall hooks (removes existing Entire hooks first)") cmd.Flags().BoolVar(&skipPushSessions, "skip-push-sessions", false, "Disable automatic pushing of session logs on git push") @@ -157,7 +157,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) @@ -921,13 +921,12 @@ func runUninstall(w, errW io.Writer, force bool) error { sessionStateCount := countSessionStates() shadowBranchCount := countShadowBranches() gitHooksInstalled := strategy.IsGitHookInstalled() - claudeHooksInstalled := checkClaudeCodeHooksInstalled() - geminiHooksInstalled := checkGeminiCLIHooksInstalled() + installedAgents := GetAgentsWithHooksInstalled() entireDirExists := checkEntireDirExists() // Check if there's anything to uninstall if !entireDirExists && !gitHooksInstalled && sessionStateCount == 0 && - shadowBranchCount == 0 && !claudeHooksInstalled && !geminiHooksInstalled { + shadowBranchCount == 0 && len(installedAgents) == 0 { fmt.Fprintln(w, "Entire is not installed in this repository.") return nil } @@ -947,13 +946,16 @@ 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)") + if len(installedAgents) > 0 { + agentNames := make([]string, len(installedAgents)) + for i, name := range installedAgents { + if ag, err := agent.Get(name); err == nil { + agentNames[i] = string(ag.Type()) + } else { + agentNames[i] = string(name) + } + } + fmt.Fprintf(w, " - Agent hooks (%s)\n", strings.Join(agentNames, ", ")) } fmt.Fprintln(w) @@ -1042,32 +1044,6 @@ func countShadowBranches() int { return len(branches) } -// checkClaudeCodeHooksInstalled checks if Claude Code hooks are installed. -func checkClaudeCodeHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameClaudeCode) - if err != nil { - return false - } - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return false - } - return hookAgent.AreHooksInstalled() -} - -// checkGeminiCLIHooksInstalled checks if Gemini CLI hooks are installed. -func checkGeminiCLIHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameGemini) - 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) @@ -1082,29 +1058,20 @@ func checkEntireDirExists() bool { func removeAgentHooks(w io.Writer) error { var errs []error - // Remove Claude Code hooks - claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) - if err == nil { - if hookAgent, ok := claudeAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Claude Code hooks") - } + for _, name := range agent.List() { + ag, err := agent.Get(name) + if err != nil { + continue } - } - - // Remove Gemini CLI hooks - geminiAgent, err := agent.Get(agent.AgentNameGemini) - if err == nil { - if hookAgent, ok := geminiAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Gemini CLI hooks") - } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + continue + } + wasInstalled := hookAgent.AreHooksInstalled() + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, err) + } else if wasInstalled { + fmt.Fprintf(w, " Removed %s hooks\n", ag.Type()) } } diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index d7c2b3158..703e2f53d 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -190,6 +190,78 @@ func CaptureGeminiPrePromptState(sessionID, transcriptPath string) error { return nil } +// CaptureOpencodePrePromptState captures current untracked files and transcript position +// before a prompt for OpenCode sessions. OpenCode uses JSONL transcripts, so position +// is the line count and the last entry's info.id. +func CaptureOpencodePrePromptState(sessionID, transcriptPath string) error { + if sessionID == "" { + sessionID = unknownSessionID + } + + // Get absolute path for tmp directory + tmpDirAbs, err := paths.AbsPath(paths.EntireTmpDir) + if err != nil { + tmpDirAbs = paths.EntireTmpDir // Fallback to relative + } + + // Create tmp directory if it doesn't exist + if err := os.MkdirAll(tmpDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create tmp directory: %w", err) + } + + // Get list of untracked files (excluding .entire directory itself) + untrackedFiles, err := getUntrackedFilesForState() + if err != nil { + return fmt.Errorf("failed to get untracked files: %w", err) + } + + // Get transcript position (line count and last entry ID) for OpenCode JSONL + var lineCount int + var lastEntryID string + if transcriptPath != "" { + if data, readErr := os.ReadFile(transcriptPath); readErr == nil && len(data) > 0 { //nolint:gosec // Reading from controlled transcript path + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lineCount++ + var entry struct { + Info struct { + ID string `json:"id"` + } `json:"info"` + } + if jsonErr := json.Unmarshal([]byte(line), &entry); jsonErr == nil && entry.Info.ID != "" { + lastEntryID = entry.Info.ID + } + } + } + } + + // Create state file + stateFile := prePromptStateFile(sessionID) + state := PrePromptState{ + SessionID: sessionID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + UntrackedFiles: untrackedFiles, + StepTranscriptStart: lineCount, + LastTranscriptIdentifier: lastEntryID, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(stateFile, data, 0o600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + fmt.Fprintf(os.Stderr, "Captured OpenCode state before prompt: %d untracked files, transcript line: %d (last entry id: %s)\n", len(untrackedFiles), lineCount, lastEntryID) + return nil +} + // LoadPrePromptState loads previously captured state. // Returns nil if no state file exists. func LoadPrePromptState(sessionID string) (*PrePromptState, error) { diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..0272ef73e 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -116,6 +116,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) + case agent.AgentTypeOpenCode: + return nil, fmt.Errorf("condensed transcript not yet supported for %s", agentType) case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: // Claude format - fall through to shared logic below } From 216deda111191b107525f1a800cbec758db994d0 Mon Sep 17 00:00:00 2001 From: Adrian Mato Date: Sun, 15 Feb 2026 00:33:29 -0800 Subject: [PATCH 2/3] add OpenCode integration tests --- .../integration_test/agent_strategy_test.go | 164 +++++ cmd/entire/cli/integration_test/agent_test.go | 600 ++++++++++++++++++ cmd/entire/cli/integration_test/hooks.go | 295 +++++++++ .../opencode_workflow_test.go | 428 +++++++++++++ .../setup_opencode_hooks_test.go | 195 ++++++ 5 files changed, 1682 insertions(+) create mode 100644 cmd/entire/cli/integration_test/opencode_workflow_test.go create mode 100644 cmd/entire/cli/integration_test/setup_opencode_hooks_test.go diff --git a/cmd/entire/cli/integration_test/agent_strategy_test.go b/cmd/entire/cli/integration_test/agent_strategy_test.go index 6dd9f7bd6..2412af5ac 100644 --- a/cmd/entire/cli/integration_test/agent_strategy_test.go +++ b/cmd/entire/cli/integration_test/agent_strategy_test.go @@ -361,3 +361,167 @@ func TestSetupAgentFlag(t *testing.T) { // Agent field may be omitted if default } } + +// ============================================================ +// OpenCode Agent + Strategy Composition Tests +// ============================================================ + +// TestOpenCodeAgentStrategyComposition verifies that OpenCode agent and strategy work together. +// Tests the full flow: 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) { + 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 test file + session := env.NewOpencodeSession() + env.WriteFile("feature.go", "package main\n// opencode feature") + + // Create transcript via OpenCode format + transcriptPath := session.CreateOpencodeTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// opencode 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 → make changes → stop + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("SimulateOpencodeSessionStart error = %v", err) + } + + if err := env.SimulateOpencodeStop(session.ID, transcriptPath); err != nil { + t.Fatalf("SimulateOpencodeStop error = %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after stop hook") + } + }) +} + +// TestOpenCodeAgentSessionIDTransformation verifies session ID across agent/strategy boundary. +func TestOpenCodeAgentSessionIDTransformation(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + session := env.NewOpencodeSession() + env.WriteFile("test.go", "package main") + transcriptPath := session.CreateOpencodeTranscript("Test", []FileChange{ + {Path: "test.go", Content: "package main"}, + }) + + // Simulate hooks + env.SimulateOpencodeSessionStart(session.ID) + env.SimulateOpencodeStop(session.ID, transcriptPath) + + // Get rewind points and verify we can rewind + points := env.GetRewindPoints() + if len(points) == 0 { + t.Skip("no rewind points created") + } + + // Rewind should work + if err := env.Rewind(points[0].ID); err != nil { + t.Errorf("Rewind() error = %v", err) + } + }) +} + +// TestOpenCodeSetupAgentFlag verifies the --agent opencode flag in enable command. +func TestOpenCodeSetupAgentFlag(t *testing.T) { + t.Parallel() + + env := NewTestEnv(t) + env.InitRepo() + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Run enable with --agent opencode + output, err := env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("enable --agent opencode failed: %v\nOutput: %s", err, output) + } + + // Verify plugin file was created + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + t.Error("enable --agent opencode should create .opencode/plugins/entire.ts") + } + + // Verify .entire/settings exists and has enabled=true + entireSettingsPath := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) + data, err := os.ReadFile(entireSettingsPath) + if err != nil { + t.Fatalf("failed to read .entire/%s: %v", paths.SettingsFileName, err) + } + + if !strings.Contains(string(data), `"enabled"`) { + t.Logf("settings content: %s", data) + t.Error("settings should contain 'enabled' field") + } +} + +// TestOpenCodeHookInputParsing tests that OpenCode's ParseHookInput correctly maps fields. +func TestOpenCodeHookInputParsing(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + + t.Run("transcript_path overrides session_ref", func(t *testing.T) { + t.Parallel() + + input := `{"session_id":"sess-1","session_ref":"/old/ref","transcript_path":"/new/transcript.jsonl","timestamp":"2025-01-01T00:00:00Z"}` + hookInput, err := ag.ParseHookInput(agent.HookStop, newStringReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + // transcript_path should override session_ref + if hookInput.SessionRef != "/new/transcript.jsonl" { + t.Errorf("SessionRef = %q, want %q (transcript_path should override)", hookInput.SessionRef, "/new/transcript.jsonl") + } + }) + + t.Run("empty input returns error", func(t *testing.T) { + t.Parallel() + + _, err := ag.ParseHookInput(agent.HookStop, newStringReader("")) + if err == nil { + t.Error("ParseHookInput() should return error for empty input") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + t.Parallel() + + _, err := ag.ParseHookInput(agent.HookStop, newStringReader("{invalid")) + if err == nil { + t.Error("ParseHookInput() should return error for invalid JSON") + } + }) +} diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index ea68f9577..18a84ef78 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" ) // TestAgentDetection verifies agent detection and default behavior. @@ -827,3 +828,602 @@ func TestGeminiCLIHelperMethods(t *testing.T) { } }) } + +// ============================================================ +// OpenCode Agent Tests +// ============================================================ + +// TestOpenCodeAgentDetection verifies OpenCode agent detection and registration. +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 does not detect presence when .opencode absent", 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) + } + + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false when .opencode doesn't exist") + } + }) +} + +// TestOpenCodeHookInstallation verifies hook installation via OpenCode agent interface. +func TestOpenCodeHookInstallation(t *testing.T) { + // Not parallel - tests use os.Chdir + + 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 hook (the plugin file) + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + // Verify plugin file was created + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + t.Error("plugin file was not created at .opencode/plugins/entire.ts") + } + + // Verify hooks are installed + if !hookAgent.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false after InstallHooks()") + } + }) + + 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) + } + }) + + t.Run("force flag reinstalls plugin", 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) + } + + // Force reinstall should return count > 0 + count, err := hookAgent.InstallHooks(false, true) // force = true + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 1 { + t.Errorf("force InstallHooks() count = %d, want 1", count) + } + }) + + t.Run("uninstall removes 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, _ := agent.Get("opencode") + hookAgent := ag.(agent.HookSupport) + + // Install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Uninstall + if err := hookAgent.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify plugin file is removed + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); !os.IsNotExist(err) { + t.Error("plugin file should be removed after UninstallHooks()") + } + + // Verify hooks are no longer installed + if hookAgent.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true after UninstallHooks()") + } + }) +} + +// TestOpenCodeSessionOperations verifies ReadSession/WriteSession via OpenCode agent interface. +func TestOpenCodeSessionOperations(t *testing.T) { + t.Parallel() + + t.Run("ReadSession parses transcript and computes ModifiedFiles", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + // Create an OpenCode JSONL transcript + transcriptPath := filepath.Join(env.RepoDir, "test-transcript.jsonl") + session := env.NewOpencodeSession() + content := session.CreateOpencodeTranscript("Fix the bug", []FileChange{ + {Path: "main.go", Content: "package main"}, + {Path: "util.go", Content: "package util"}, + }) + + ag, _ := agent.Get("opencode") + agentSession, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session", + SessionRef: content, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify session metadata + if agentSession.SessionID != "test-session" { + t.Errorf("SessionID = %q, want %q", agentSession.SessionID, "test-session") + } + if agentSession.AgentName != "opencode" { + t.Errorf("AgentName = %q, want %q", agentSession.AgentName, "opencode") + } + + // Verify NativeData is populated + if len(agentSession.NativeData) == 0 { + t.Error("NativeData is empty, want transcript content") + } + + // Verify ModifiedFiles computed + if len(agentSession.ModifiedFiles) != 2 { + t.Errorf("ModifiedFiles = %v, want 2 files (main.go, util.go)", agentSession.ModifiedFiles) + } + + _ = transcriptPath // keep for clarity + }) + + t.Run("WriteSession writes NativeData to file", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + ag, _ := agent.Get("opencode") + + // Create source transcript + srcPath := filepath.Join(env.RepoDir, "src.jsonl") + srcContent := `{"info":{"id":"msg-1","sessionID":"s1","role":"user","time":{"created":1,"completed":2}},"parts":[{"type":"text","text":"hello"}]} +` + if err := os.WriteFile(srcPath, []byte(srcContent), 0o644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + session, _ := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: srcPath, + }) + + // Write to a new location + dstPath := filepath.Join(env.RepoDir, "dst.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) + } + }) + + t.Run("WriteSession rejects wrong agent", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + + session := &agent.AgentSession{ + SessionID: "test", + AgentName: "other-agent", // Wrong agent + SessionRef: "/tmp/test.jsonl", + NativeData: []byte("data"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should reject session from different agent") + } + }) +} + +// TestOpenCodeHelperMethods verifies OpenCode-specific helper methods. +func TestOpenCodeHelperMethods(t *testing.T) { + t.Parallel() + + t.Run("FormatResumeCommand returns opencode --resume", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + cmd := ag.FormatResumeCommand("abc123") + + if cmd != "opencode --resume abc123" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "opencode --resume abc123") + } + }) + + t.Run("GetHookConfigPath returns .opencode/plugins/entire.ts", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + path := ag.GetHookConfigPath() + + if path != ".opencode/plugins/entire.ts" { + t.Errorf("GetHookConfigPath() = %q, want %q", path, ".opencode/plugins/entire.ts") + } + }) + + t.Run("Name returns opencode", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + if ag.Name() != "opencode" { + t.Errorf("Name() = %q, want %q", ag.Name(), "opencode") + } + }) + + t.Run("Type returns opencode type", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + if ag.Type() != agent.AgentTypeOpenCode { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeOpenCode) + } + }) + + t.Run("SupportsHooks returns true", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } + }) + + 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("Description is non-empty", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + if ag.Description() == "" { + t.Error("Description() should not be empty") + } + }) + + t.Run("TranscriptAnalyzer - GetTranscriptPosition", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + + ag, _ := agent.Get("opencode") + analyzer, ok := ag.(agent.TranscriptAnalyzer) + if !ok { + t.Fatal("opencode agent does not implement TranscriptAnalyzer") + } + + // Write a 3-line JSONL transcript + path := filepath.Join(env.RepoDir, "pos-test.jsonl") + lines := `{"info":{"id":"1","role":"user"},"parts":[]} +{"info":{"id":"2","role":"assistant"},"parts":[]} +{"info":{"id":"3","role":"user"},"parts":[]} +` + if err := os.WriteFile(path, []byte(lines), 0o644); err != nil { + t.Fatalf("failed to write test transcript: %v", err) + } + + pos, err := analyzer.GetTranscriptPosition(path) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 3 { + t.Errorf("GetTranscriptPosition() = %d, want 3", pos) + } + }) + + t.Run("TranscriptAnalyzer - nonexistent file returns 0", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + analyzer := ag.(agent.TranscriptAnalyzer) + + pos, err := analyzer.GetTranscriptPosition("/nonexistent/path.jsonl") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v (want nil for nonexistent)", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition(nonexistent) = %d, want 0", pos) + } + }) + + t.Run("TranscriptChunker splits JSONL", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + chunker, ok := ag.(agent.TranscriptChunker) + if !ok { + t.Fatal("opencode agent does not implement TranscriptChunker") + } + + // Create content that needs chunking + line1 := `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello"}]}` + line2 := `{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"world"}]}` + content := []byte(line1 + "\n" + line2 + "\n") + + // Chunk with a size that forces a split (each line is ~78 bytes, use just over one line) + chunks, err := chunker.ChunkTranscript(content, len(line1)+10) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) < 2 { + t.Errorf("ChunkTranscript() produced %d chunks, want at least 2", len(chunks)) + } + + // Reassemble and verify + reassembled, err := chunker.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + if string(reassembled) != string(content) { + t.Errorf("reassembled content doesn't match original") + } + }) + + t.Run("ExtractModifiedFiles from transcript", func(t *testing.T) { + t.Parallel() + + transcript := []byte(`{"info":{"id":"1","sessionID":"s1","role":"user","time":{"created":1,"completed":2}},"parts":[{"type":"text","text":"create files"}]} +{"info":{"id":"2","sessionID":"s1","role":"assistant","time":{"created":3,"completed":4}},"parts":[{"type":"tool","tool":"file_write","filePath":"src/main.go","state":{"status":"completed","input":"test","output":"ok"}},{"type":"tool","tool":"file_write","filePath":"src/util.go","state":{"status":"completed","input":"test","output":"ok"}}]} +`) + + files := opencode.ExtractModifiedFiles(transcript) + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() = %v, want 2 files", files) + } + }) +} + +// TestOpenCodeHookParsing verifies hook input parsing via OpenCode agent interface. +func TestOpenCodeHookParsing(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + + tests := []struct { + name string + hookType agent.HookType + input string + wantID string + wantRef string + }{ + { + name: "SessionStart", + hookType: agent.HookSessionStart, + input: `{"session_id":"sess-oc-1","session_ref":"","transcript_path":"","timestamp":"2025-01-01T00:00:00Z"}`, + wantID: "sess-oc-1", + wantRef: "", + }, + { + name: "Stop with transcript_path", + hookType: agent.HookStop, + input: `{"session_id":"sess-oc-2","session_ref":"/tmp/ref","transcript_path":"/tmp/transcript.jsonl","timestamp":"2025-01-01T00:00:00Z"}`, + wantID: "sess-oc-2", + wantRef: "/tmp/transcript.jsonl", // transcript_path overrides session_ref + }, + { + name: "Stop with only session_ref", + hookType: agent.HookStop, + input: `{"session_id":"sess-oc-3","session_ref":"/tmp/ref.jsonl","timestamp":"2025-01-01T00:00:00Z"}`, + wantID: "sess-oc-3", + wantRef: "/tmp/ref.jsonl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + reader := newStringReader(tt.input) + hookInput, err := ag.ParseHookInput(tt.hookType, reader) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != tt.wantID { + t.Errorf("SessionID = %q, want %q", hookInput.SessionID, tt.wantID) + } + if hookInput.SessionRef != tt.wantRef { + t.Errorf("SessionRef = %q, want %q", hookInput.SessionRef, tt.wantRef) + } + }) + } +} + +// TestOpenCodeTaskHookParsing verifies task-start and task-complete hook parsing. +func TestOpenCodeTaskHookParsing(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("opencode") + + t.Run("TaskStart", func(t *testing.T) { + t.Parallel() + + input := `{"session_id":"sess-1","transcript_path":"/tmp/t.jsonl","tool_use_id":"tool-abc","tool_input":{"subagent_type":"dev","description":"test task"},"timestamp":"2025-01-01T00:00:00Z"}` + reader := newStringReader(input) + + hookInput, err := ag.ParseHookInput(agent.HookPreToolUse, reader) + if err != nil { + t.Fatalf("ParseHookInput(PreToolUse) error = %v", err) + } + + if hookInput.ToolUseID != "tool-abc" { + t.Errorf("ToolUseID = %q, want %q", hookInput.ToolUseID, "tool-abc") + } + if hookInput.SessionRef != "/tmp/t.jsonl" { + t.Errorf("SessionRef = %q, want %q", hookInput.SessionRef, "/tmp/t.jsonl") + } + }) + + t.Run("TaskComplete", func(t *testing.T) { + t.Parallel() + + input := `{"session_id":"sess-1","transcript_path":"/tmp/t.jsonl","tool_use_id":"tool-xyz","tool_input":{},"tool_response":{},"timestamp":"2025-01-01T00:00:00Z"}` + reader := newStringReader(input) + + hookInput, err := ag.ParseHookInput(agent.HookPostToolUse, reader) + if err != nil { + t.Fatalf("ParseHookInput(PostToolUse) error = %v", err) + } + + if hookInput.ToolUseID != "tool-xyz" { + t.Errorf("ToolUseID = %q, want %q", hookInput.ToolUseID, "tool-xyz") + } + }) +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index b748203b0..926bc1f58 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" ) @@ -686,3 +687,297 @@ 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 CLI hooks in the test environment. +// Unlike Claude/Gemini, OpenCode hooks don't need a separate project dir +// (the plugin lives in .opencode/plugins/ within the repo). +type OpencodeHookRunner struct { + RepoDir string + T interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) + } +} + +// NewOpencodeHookRunner creates a new OpenCode hook runner. +func NewOpencodeHookRunner(repoDir string, t interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) +}) *OpencodeHookRunner { + return &OpencodeHookRunner{ + RepoDir: repoDir, + 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 { + cmd := exec.Command(getTestBinary(), "hooks", "opencode", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = os.Environ() + + 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 +} + +func (r *OpencodeHookRunner) runOpencodeHookWithOutput(hookName string, inputJSON []byte) HookOutput { + cmd := exec.Command(getTestBinary(), "hooks", "opencode", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = os.Environ() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return HookOutput{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + Err: err, + } +} + +// SimulateOpencodeSessionStart simulates the session-start hook for OpenCode. +func (r *OpencodeHookRunner) SimulateOpencodeSessionStart(sessionID string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "session_ref": "", + "transcript_path": "", + "timestamp": "2025-01-01T00:00:00Z", + } + + return r.runOpencodeHookWithInput("session-start", input) +} + +// SimulateOpencodeSessionStartWithOutput simulates session-start and returns output. +func (r *OpencodeHookRunner) SimulateOpencodeSessionStartWithOutput(sessionID string) HookOutput { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "session_ref": "", + "transcript_path": "", + "timestamp": "2025-01-01T00:00:00Z", + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)} + } + + return r.runOpencodeHookWithOutput("session-start", inputJSON) +} + +// SimulateOpencodeStop simulates the stop hook for OpenCode. +// This is the primary checkpoint creation hook. +func (r *OpencodeHookRunner) SimulateOpencodeStop(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "session_ref": transcriptPath, + "transcript_path": transcriptPath, + "timestamp": "2025-01-01T00:00:00Z", + } + + return r.runOpencodeHookWithInput("stop", input) +} + +// SimulateOpencodeTaskStart simulates the task-start hook for OpenCode. +func (r *OpencodeHookRunner) SimulateOpencodeTaskStart(sessionID, transcriptPath, toolUseID string) error { + r.T.Helper() + + input := map[string]interface{}{ + "session_id": sessionID, + "session_ref": transcriptPath, + "transcript_path": transcriptPath, + "timestamp": "2025-01-01T00:00:00Z", + "tool_use_id": toolUseID, + "tool_input": map[string]string{ + "subagent_type": "dev", + "description": "test task", + }, + } + + return r.runOpencodeHookWithInput("task-start", input) +} + +// SimulateOpencodeTaskComplete simulates the task-complete hook for OpenCode. +func (r *OpencodeHookRunner) SimulateOpencodeTaskComplete(sessionID, transcriptPath, toolUseID string) error { + r.T.Helper() + + input := map[string]interface{}{ + "session_id": sessionID, + "session_ref": transcriptPath, + "transcript_path": transcriptPath, + "timestamp": "2025-01-01T00:00:00Z", + "tool_use_id": toolUseID, + "tool_input": map[string]string{}, + "tool_response": map[string]string{}, + } + + return r.runOpencodeHookWithInput("task-complete", input) +} + +// OpencodeSession represents a simulated OpenCode session. +type OpencodeSession struct { + ID string + TranscriptPath string + env *TestEnv +} + +// 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.RepoDir, ".entire", "tmp", sessionID+".jsonl") + + return &OpencodeSession{ + ID: sessionID, + TranscriptPath: transcriptPath, + env: env, + } +} + +// CreateOpencodeTranscript creates an OpenCode JSONL transcript file for the session. +// OpenCode transcripts use a specific format with "info" and "parts" fields. +func (s *OpencodeSession) CreateOpencodeTranscript(prompt string, changes []FileChange) string { + var lines []string + msgIdx := 0 + + // User message + userEntry := map[string]interface{}{ + "info": map[string]interface{}{ + "id": fmt.Sprintf("msg-%d", msgIdx), + "sessionID": s.ID, + "role": "user", + "time": map[string]int64{ + "created": 1700000000 + int64(msgIdx*2), + "completed": 1700000001 + int64(msgIdx*2), + }, + }, + "parts": []map[string]interface{}{ + {"type": "text", "text": prompt}, + }, + } + userJSON, _ := json.Marshal(userEntry) + lines = append(lines, string(userJSON)) + msgIdx++ + + // Assistant message with file modifications + var parts []map[string]interface{} + for _, change := range changes { + inputJSON, _ := json.Marshal(map[string]string{ + "file_path": change.Path, + "content": change.Content, + }) + parts = append(parts, map[string]interface{}{ + "type": "tool", + "tool": "file_write", + "filePath": change.Path, + "state": map[string]interface{}{ + "status": "completed", + "input": json.RawMessage(inputJSON), + "output": "File written successfully", + }, + }) + } + parts = append(parts, map[string]interface{}{ + "type": "text", + "text": "Done!", + }) + + assistantEntry := map[string]interface{}{ + "info": map[string]interface{}{ + "id": fmt.Sprintf("msg-%d", msgIdx), + "sessionID": s.ID, + "role": "assistant", + "time": map[string]int64{ + "created": 1700000000 + int64(msgIdx*2), + "completed": 1700000001 + int64(msgIdx*2), + }, + "summary": map[string]interface{}{ + "title": "Created files", + }, + }, + "parts": parts, + } + assistantJSON, _ := json.Marshal(assistantEntry) + lines = append(lines, string(assistantJSON)) + + content := strings.Join(lines, "\n") + "\n" + + // 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) + } + + if err := os.WriteFile(s.TranscriptPath, []byte(content), 0o644); err != nil { + s.env.T.Fatalf("failed to write transcript: %v", err) + } + + return s.TranscriptPath +} + +// ---------- OpenCode TestEnv convenience methods ---------- + +// SimulateOpencodeSessionStart is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpencodeSessionStart(sessionID string) error { + env.T.Helper() + runner := NewOpencodeHookRunner(env.RepoDir, env.T) + return runner.SimulateOpencodeSessionStart(sessionID) +} + +// SimulateOpencodeSessionStartWithOutput is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpencodeSessionStartWithOutput(sessionID string) HookOutput { + env.T.Helper() + runner := NewOpencodeHookRunner(env.RepoDir, env.T) + return runner.SimulateOpencodeSessionStartWithOutput(sessionID) +} + +// SimulateOpencodeStop is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpencodeStop(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewOpencodeHookRunner(env.RepoDir, env.T) + return runner.SimulateOpencodeStop(sessionID, transcriptPath) +} + +// SimulateOpencodeTaskStart is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpencodeTaskStart(sessionID, transcriptPath, toolUseID string) error { + env.T.Helper() + runner := NewOpencodeHookRunner(env.RepoDir, env.T) + return runner.SimulateOpencodeTaskStart(sessionID, transcriptPath, toolUseID) +} + +// SimulateOpencodeTaskComplete is a convenience method on TestEnv. +func (env *TestEnv) SimulateOpencodeTaskComplete(sessionID, transcriptPath, toolUseID string) error { + env.T.Helper() + runner := NewOpencodeHookRunner(env.RepoDir, env.T) + return runner.SimulateOpencodeTaskComplete(sessionID, transcriptPath, toolUseID) +} diff --git a/cmd/entire/cli/integration_test/opencode_workflow_test.go b/cmd/entire/cli/integration_test/opencode_workflow_test.go new file mode 100644 index 000000000..fcdf02b24 --- /dev/null +++ b/cmd/entire/cli/integration_test/opencode_workflow_test.go @@ -0,0 +1,428 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// TestOpenCode_BasicWorkflow tests the core OpenCode workflow: +// session-start → modify files → stop → verify checkpoint + rewind point. +func TestOpenCode_BasicWorkflow(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + // Start OpenCode session + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("SimulateOpencodeSessionStart failed: %v", err) + } + + // Create a file (simulating agent work) + env.WriteFile("hello.go", "package main\n\nfunc Hello() string { return \"hello\" }\n") + + // Create transcript and stop + transcriptPath := session.CreateOpencodeTranscript("Create hello function", []FileChange{ + {Path: "hello.go", Content: "package main\n\nfunc Hello() string { return \"hello\" }\n"}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcriptPath); err != nil { + t.Fatalf("SimulateOpencodeStop failed: %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after stop") + } + }) +} + +// TestOpenCode_Rewind tests multiple checkpoints + rewind restores files correctly. +func TestOpenCode_Rewind(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + // Use same session for all checkpoints (follows Claude pattern) + session := env.NewOpencodeSession() + + // Checkpoint 1: create file v1 + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + fileV1 := "package main\n\nfunc Greet() string { return \"v1\" }\n" + env.WriteFile("greet.go", fileV1) + transcript1 := session.CreateOpencodeTranscript("Create greet v1", []FileChange{ + {Path: "greet.go", Content: fileV1}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcript1); err != nil { + t.Fatalf("stop (checkpoint 1) failed: %v", err) + } + + points1 := env.GetRewindPoints() + if len(points1) != 1 { + t.Fatalf("expected 1 rewind point, got %d", len(points1)) + } + checkpoint1ID := points1[0].ID + + // Checkpoint 2: modify file to v2 (same session, new transcript) + fileV2 := "package main\n\nfunc Greet() string { return \"v2\" }\n" + env.WriteFile("greet.go", fileV2) + + // Update transcript with v2 content + transcript2 := session.CreateOpencodeTranscript("Update greet to v2", []FileChange{ + {Path: "greet.go", Content: fileV2}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcript2); err != nil { + t.Fatalf("stop (checkpoint 2) failed: %v", err) + } + + points2 := env.GetRewindPoints() + if len(points2) < 2 { + t.Fatalf("expected at least 2 rewind points, got %d", len(points2)) + } + + // Verify current state + if content := env.ReadFile("greet.go"); content != fileV2 { + t.Errorf("greet.go should be v2 before rewind, got: %q", content) + } + + // Rewind to checkpoint 1 + if err := env.Rewind(checkpoint1ID); err != nil { + t.Fatalf("Rewind failed: %v", err) + } + + // Verify file is restored to v1 + if content := env.ReadFile("greet.go"); content != fileV1 { + t.Errorf("greet.go after rewind = %q, want v1 content", content) + } + }) +} + +// TestOpenCode_Condensation tests that session data is condensed to +// entire/checkpoints/v1 after a git commit. +func TestOpenCode_Condensation(t *testing.T) { + t.Parallel() + + // Only test with manual-commit strategy as it has condensation behavior + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + + // Start session + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + // Create file and checkpoint + env.WriteFile("feature.go", "package main\n// feature\n") + transcript := session.CreateOpencodeTranscript("Add feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// feature\n"}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcript); err != nil { + t.Fatalf("stop failed: %v", err) + } + + // Verify we have a rewind point + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected rewind point before commit") + } + + // User commits (triggers condensation via git hooks) + env.GitAdd("feature.go") + env.GitCommitWithShadowHooks("Add feature") + + // After commit, rewind points should still be available (as logs-only points) + pointsAfter := env.GetRewindPoints() + t.Logf("Rewind points after commit: %d", len(pointsAfter)) + + // At minimum, the pre-commit checkpoint should have become a logs-only point + // or the shadow branch should be cleaned up (strategy-dependent) + if len(pointsAfter) > 0 { + for _, p := range pointsAfter { + t.Logf(" Point: id=%s message=%q logsOnly=%v", p.ID, p.Message, p.IsLogsOnly) + } + } +} + +// TestOpenCode_TaskCheckpoint tests task-start → task-complete → verify subagent checkpoint. +func TestOpenCode_TaskCheckpoint(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + // Start session first + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + // Create transcript (needed for stop) + env.WriteFile("main.go", "package main\n") + transcript := session.CreateOpencodeTranscript("Setup", []FileChange{ + {Path: "main.go", Content: "package main\n"}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcript); err != nil { + t.Fatalf("stop failed: %v", err) + } + + // Start a task (subagent) + toolUseID := "task-tool-123" + if err := env.SimulateOpencodeTaskStart(session.ID, transcript, toolUseID); err != nil { + t.Fatalf("task-start failed: %v", err) + } + + // Create a file during the task + env.WriteFile("task_output.go", "package main\n// created by subagent\n") + + // Complete the task + if err := env.SimulateOpencodeTaskComplete(session.ID, transcript, toolUseID); err != nil { + t.Fatalf("task-complete failed: %v", err) + } + + // Verify the file exists (task didn't undo anything) + if !env.FileExists("task_output.go") { + t.Error("task_output.go should exist after task completion") + } + }) +} + +// TestOpenCode_ConcurrentSessions tests two sessions in the same directory. +func TestOpenCode_ConcurrentSessions(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + // Start first session + session1 := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session1.ID); err != nil { + t.Fatalf("session1 start failed: %v", err) + } + + // Create file and checkpoint for session 1 + env.WriteFile("file1.go", "package main\n// from session 1\n") + transcript1 := session1.CreateOpencodeTranscript("Session 1 work", []FileChange{ + {Path: "file1.go", Content: "package main\n// from session 1\n"}, + }) + + if err := env.SimulateOpencodeStop(session1.ID, transcript1); err != nil { + t.Fatalf("session1 stop failed: %v", err) + } + + // Start second session (while first session's checkpoint exists) + session2 := env.NewOpencodeSession() + out := env.SimulateOpencodeSessionStartWithOutput(session2.ID) + if out.Err != nil { + // Concurrent session may warn but should not error + t.Logf("session2 start output: stdout=%s stderr=%s", out.Stdout, out.Stderr) + } + + // Create file and checkpoint for session 2 + env.WriteFile("file2.go", "package main\n// from session 2\n") + transcript2 := session2.CreateOpencodeTranscript("Session 2 work", []FileChange{ + {Path: "file2.go", Content: "package main\n// from session 2\n"}, + }) + + if err := env.SimulateOpencodeStop(session2.ID, transcript2); err != nil { + t.Fatalf("session2 stop failed: %v", err) + } + + // Both files should exist + if !env.FileExists("file1.go") { + t.Error("file1.go should exist from session 1") + } + if !env.FileExists("file2.go") { + t.Error("file2.go should exist from session 2") + } + + // Should have rewind points from both sessions + points := env.GetRewindPoints() + if len(points) < 2 { + t.Errorf("expected at least 2 rewind points from concurrent sessions, got %d", len(points)) + } + }) +} + +// TestOpenCode_NoChangesSkip tests that stop with no file changes skips checkpoint gracefully. +func TestOpenCode_NoChangesSkip(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + // Start session + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + // Create transcript but don't actually modify any files in the working tree + // The transcript says files were modified, but the working tree is unchanged + transcript := session.CreateOpencodeTranscript("Do nothing", []FileChange{}) + + // Stop should succeed without creating a checkpoint + if err := env.SimulateOpencodeStop(session.ID, transcript); err != nil { + // Some strategies may return an error for no-changes; that's acceptable + t.Logf("stop with no changes returned error (may be expected): %v", err) + } + + // Verify no rewind points were created (or at most 0) + points := env.GetRewindPoints() + t.Logf("rewind points after no-changes stop: %d", len(points)) + }) +} + +// TestOpenCode_SessionStartOutput verifies session-start hook output. +func TestOpenCode_SessionStartOutput(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + session := env.NewOpencodeSession() + out := env.SimulateOpencodeSessionStartWithOutput(session.ID) + + if out.Err != nil { + t.Fatalf("session-start failed: %v\nstdout: %s\nstderr: %s", + out.Err, out.Stdout, out.Stderr) + } + + // Session start should produce some output (setup messages) + combinedOutput := string(out.Stdout) + string(out.Stderr) + t.Logf("session-start output: %s", combinedOutput) + }) +} + +// TestOpenCode_MetadataCreation verifies that the stop hook creates expected metadata files. +func TestOpenCode_MetadataCreation(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + env.WriteFile("app.go", "package main\n\nfunc main() {}\n") + transcript := session.CreateOpencodeTranscript("Create main app", []FileChange{ + {Path: "app.go", Content: "package main\n\nfunc main() {}\n"}, + }) + + if err := env.SimulateOpencodeStop(session.ID, transcript); err != nil { + t.Fatalf("stop failed: %v", err) + } + + // Verify metadata directory structure + metadataDir := filepath.Join(env.RepoDir, ".entire", "metadata", session.ID) + if _, err := os.Stat(metadataDir); os.IsNotExist(err) { + t.Fatalf("metadata dir should exist at %s", metadataDir) + } + + // Check for expected metadata files + expectedFiles := []string{ + paths.TranscriptFileName, // full.jsonl + paths.PromptFileName, // prompt.txt + paths.SummaryFileName, // summary.txt + paths.ContextFileName, // context.md + } + + for _, fname := range expectedFiles { + fpath := filepath.Join(metadataDir, fname) + info, err := os.Stat(fpath) + if os.IsNotExist(err) { + t.Errorf("expected metadata file %s to exist", fname) + continue + } + if err != nil { + t.Errorf("error checking metadata file %s: %v", fname, err) + continue + } + if info.Size() == 0 { + t.Errorf("metadata file %s should not be empty", fname) + } + } + + // Verify prompt file contains the prompt + promptData, err := os.ReadFile(filepath.Join(metadataDir, paths.PromptFileName)) + if err != nil { + t.Fatalf("failed to read prompt file: %v", err) + } + if !strings.Contains(string(promptData), "Create main app") { + t.Errorf("prompt file should contain the user prompt, got: %q", string(promptData)) + } + + // Verify context file has structure + contextData, err := os.ReadFile(filepath.Join(metadataDir, paths.ContextFileName)) + if err != nil { + t.Fatalf("failed to read context file: %v", err) + } + contextStr := string(contextData) + if !strings.Contains(contextStr, "Session Context") { + t.Error("context file should contain 'Session Context' header") + } + if !strings.Contains(contextStr, session.ID) { + t.Errorf("context file should reference session ID %s", session.ID) + } + }) +} + +// TestOpenCode_TranscriptIntegrity verifies the transcript file is copied correctly. +func TestOpenCode_TranscriptIntegrity(t *testing.T) { + t.Parallel() + + RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { + session := env.NewOpencodeSession() + if err := env.SimulateOpencodeSessionStart(session.ID); err != nil { + t.Fatalf("session-start failed: %v", err) + } + + env.WriteFile("code.go", "package main\n") + transcript := session.CreateOpencodeTranscript("Write code", []FileChange{ + {Path: "code.go", Content: "package main\n"}, + }) + + // Read original transcript + originalData, err := os.ReadFile(transcript) + if err != nil { + t.Fatalf("failed to read original transcript: %v", err) + } + + if err := env.SimulateOpencodeStop(session.ID, transcript); err != nil { + t.Fatalf("stop failed: %v", err) + } + + // Verify copied transcript matches original + copiedPath := filepath.Join(env.RepoDir, ".entire", "metadata", session.ID, paths.TranscriptFileName) + copiedData, err := os.ReadFile(copiedPath) + if err != nil { + t.Fatalf("failed to read copied transcript: %v", err) + } + + if string(originalData) != string(copiedData) { + t.Error("copied transcript should match original") + } + + // Verify transcript is valid JSONL (each line parses as JSON) + lines := strings.Split(strings.TrimSpace(string(copiedData)), "\n") + for i, line := range lines { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Errorf("transcript line %d is not valid JSON: %v", i, err) + } + + // Verify OpenCode transcript structure (info + parts) + if _, ok := parsed["info"]; !ok { + t.Errorf("transcript line %d missing 'info' field", i) + } + if _, ok := parsed["parts"]; !ok { + t.Errorf("transcript line %d missing 'parts' field", i) + } + } + }) +} diff --git a/cmd/entire/cli/integration_test/setup_opencode_hooks_test.go b/cmd/entire/cli/integration_test/setup_opencode_hooks_test.go new file mode 100644 index 000000000..d69a24a3b --- /dev/null +++ b/cmd/entire/cli/integration_test/setup_opencode_hooks_test.go @@ -0,0 +1,195 @@ +//go:build integration + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSetupOpencodeHooks_InstallsPluginFile verifies that +// `entire enable --agent opencode` writes the plugin file to .opencode/plugins/entire.ts. +func TestSetupOpencodeHooks_InstallsPluginFile(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Run entire enable --agent opencode + output, err := env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("enable opencode failed: %v\nOutput: %s", err, output) + } + + // Verify plugin file exists + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + info, err := os.Stat(pluginPath) + if err != nil { + t.Fatalf("plugin file should exist at %s: %v", pluginPath, err) + } + if info.Size() == 0 { + t.Error("plugin file should not be empty") + } + + // Verify content contains expected markers + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + content := string(data) + + if !strings.Contains(content, "hooks") || !strings.Contains(content, "opencode") { + t.Error("plugin file should contain hook command references") + } + if !strings.Contains(content, "ENTIRE_BIN") { + t.Error("plugin must support ENTIRE_BIN env var for binary resolution") + } + if !strings.Contains(content, "execFileSync") { + t.Error("plugin must use execFileSync for safe binary execution") + } + if !strings.Contains(content, "session-start") { + t.Error("plugin file should reference session-start hook") + } + if !strings.Contains(content, "session.created") || !strings.Contains(content, "session.idle") { + t.Error("plugin file should subscribe to OpenCode session events") + } + + // Verify plugin follows OpenCode's plugin API contract + if !strings.Contains(content, "export const EntirePlugin") { + t.Error("plugin must use named export (not default export)") + } + if strings.Contains(content, "$.on(") { + t.Error("plugin must not use $.on() — $ is Bun shell, not an event emitter") + } + if !strings.Contains(content, "event.type ===") { + t.Error("plugin must dispatch on event.type in the event handler") + } + if !strings.Contains(content, "client.session.messages") { + t.Error("plugin must use SDK client to export transcripts") + } +} + +// TestSetupOpencodeHooks_Idempotent verifies that running enable twice doesn't error +// and doesn't duplicate the plugin file. +func TestSetupOpencodeHooks_Idempotent(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // First enable + output, err := env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("first enable failed: %v\nOutput: %s", err, output) + } + + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + firstContent, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin after first enable: %v", err) + } + + // Second enable (should be idempotent — file already exists) + output, err = env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("second enable failed: %v\nOutput: %s", err, output) + } + + secondContent, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin after second enable: %v", err) + } + + if string(firstContent) != string(secondContent) { + t.Error("plugin content should be identical after second enable") + } +} + +// TestSetupOpencodeHooks_ForceReinstall verifies that --force overwrites the plugin file. +func TestSetupOpencodeHooks_ForceReinstall(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // First enable + output, err := env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("first enable failed: %v\nOutput: %s", err, output) + } + + // Tamper with the plugin file + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if err := os.WriteFile(pluginPath, []byte("// tampered content"), 0o600); err != nil { + t.Fatalf("failed to tamper plugin file: %v", err) + } + + // Re-enable with --force + output, err = env.RunCLIWithError("enable", "--agent", "opencode", "--force") + if err != nil { + t.Fatalf("force enable failed: %v\nOutput: %s", err, output) + } + + // Verify content is restored (not the tampered content) + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin after force enable: %v", err) + } + + content := string(data) + if strings.Contains(content, "tampered") { + t.Error("force enable should overwrite tampered content") + } + if !strings.Contains(content, "execFileSync") { + t.Error("force enable should restore correct plugin content") + } +} + +// TestSetupOpencodeHooks_DisableRemovesPlugin verifies that `entire disable --uninstall --force` +// removes the plugin file. +func TestSetupOpencodeHooks_DisableRemovesPlugin(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Enable opencode + output, err := env.RunCLIWithError("enable", "--agent", "opencode") + if err != nil { + t.Fatalf("enable failed: %v\nOutput: %s", err, output) + } + + pluginPath := filepath.Join(env.RepoDir, ".opencode", "plugins", "entire.ts") + if _, err := os.Stat(pluginPath); err != nil { + t.Fatalf("plugin should exist after enable: %v", err) + } + + // Uninstall (removes all agent hooks including OpenCode) + output, err = env.RunCLIWithError("disable", "--uninstall", "--force") + if err != nil { + t.Fatalf("uninstall failed: %v\nOutput: %s", err, output) + } + + // Verify plugin is removed + if _, err := os.Stat(pluginPath); !os.IsNotExist(err) { + t.Errorf("plugin file should be removed after uninstall, got err: %v", err) + } +} From d9c92fc378e3c087b9b96546855d8eafe2b733b3 Mon Sep 17 00:00:00 2001 From: Adrian Mato Date: Sun, 15 Feb 2026 00:33:34 -0800 Subject: [PATCH 3/3] add OpenCode token usage calculation and condensation support --- cmd/entire/cli/agent/opencode/transcript.go | 268 +++++++ .../cli/agent/opencode/transcript_test.go | 739 ++++++++++++++++++ .../strategy/manual_commit_condensation.go | 21 + 3 files changed, 1028 insertions(+) create mode 100644 cmd/entire/cli/agent/opencode/transcript.go create mode 100644 cmd/entire/cli/agent/opencode/transcript_test.go diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go new file mode 100644 index 000000000..c0493d993 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -0,0 +1,268 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// ParseTranscript parses raw JSONL content into transcript entries. +// +//nolint:unparam // error return kept for API consistency with other agent parsers +func ParseTranscript(data []byte) ([]TranscriptEntry, error) { + var entries []TranscriptEntry + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var entry TranscriptEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + // Skip malformed lines + continue + } + entries = append(entries, entry) + } + return entries, nil +} + +// ExtractModifiedFiles extracts all files modified by tool calls in the transcript. +func ExtractModifiedFiles(data []byte) []string { + entries, err := ParseTranscript(data) + if err != nil { + return nil + } + return ExtractModifiedFilesFromEntries(entries) +} + +// ExtractModifiedFilesFromEntries extracts modified files from parsed entries. +func ExtractModifiedFilesFromEntries(entries []TranscriptEntry) []string { + fileSet := make(map[string]bool) + var files []string + + for i := range entries { + for _, f := range extractFilesFromEntry(&entries[i]) { + if !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + return files +} + +// extractFilesFromEntry extracts modified file paths from a single transcript entry. +func extractFilesFromEntry(entry *TranscriptEntry) []string { + if entry.Info.Role != MessageRoleAssistant { + return nil + } + + var files []string + fileSet := make(map[string]bool) + + // Check summary diffs + if entry.Info.Summary != nil { + for _, diff := range entry.Info.Summary.Diffs { + if diff.File != "" && !fileSet[diff.File] { + fileSet[diff.File] = true + files = append(files, diff.File) + } + } + } + + // Check tool parts + for _, part := range entry.Parts { + // Check filePath on patch or tool parts (OpenCode sets filePath on both) + if (part.Type == PartTypePatch || part.Type == PartTypeTool) && part.FilePath != "" && !fileSet[part.FilePath] { + fileSet[part.FilePath] = true + files = append(files, part.FilePath) + } + + // Check tool parts for file modification tools (extract from state.input) + if part.Type != PartTypeTool { + continue + } + + isModifyTool := false + for _, name := range FileModificationTools { + if part.Tool == name { + isModifyTool = true + break + } + } + if !isModifyTool { + continue + } + + // Try to extract file path from tool state input + file := extractFilePathFromToolState(part.State) + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + + return files +} + +// extractFilePathFromToolState extracts the file path from a tool's state input. +func extractFilePathFromToolState(state *TranscriptToolState) string { + if state == nil || len(state.Input) == 0 { + return "" + } + + var input map[string]interface{} + if err := json.Unmarshal(state.Input, &input); err != nil { + return "" + } + + // Try common field names for file paths + for _, key := range []string{"file_path", "path", "filePath", "filename"} { + if fp, ok := input[key].(string); ok && fp != "" { + return fp + } + } + return "" +} + +// CalculateTokenUsage calculates token usage from OpenCode transcript entries. +// OpenCode assistant messages carry per-message token counts in info.tokens, +// and step-finish parts carry per-step token counts. +// We use the message-level tokens (deduplicated by message ID) since they +// represent the authoritative totals from the provider API. +// startIndex is the entry index to start counting from (0-based). +func CalculateTokenUsage(entries []TranscriptEntry, startIndex int) *agent.TokenUsage { + usage := &agent.TokenUsage{} + + // Deduplicate by message ID (in case of streaming duplicates) + seen := make(map[string]bool) + + for i := startIndex; i < len(entries); i++ { + entry := &entries[i] + if entry.Info.Role != MessageRoleAssistant { + continue + } + + // Use message-level tokens if available (authoritative) + if entry.Info.Tokens != nil && entry.Info.ID != "" && !seen[entry.Info.ID] { + seen[entry.Info.ID] = true + usage.InputTokens += entry.Info.Tokens.Input + usage.OutputTokens += entry.Info.Tokens.Output + usage.CacheReadTokens += entry.Info.Tokens.Cache.Read + usage.CacheCreationTokens += entry.Info.Tokens.Cache.Write + usage.APICallCount++ + } + } + + return usage +} + +// CalculateTokenUsageFromData calculates token usage from raw JSONL data. +func CalculateTokenUsageFromData(data []byte, startIndex int) *agent.TokenUsage { + entries, err := ParseTranscript(data) + if err != nil || len(entries) == 0 { + return &agent.TokenUsage{} + } + return CalculateTokenUsage(entries, startIndex) +} + +// CalculateTokenUsageFromFile calculates token usage from a transcript file. +func CalculateTokenUsageFromFile(path string, startIndex int) (*agent.TokenUsage, error) { + if path == "" { + return &agent.TokenUsage{}, nil + } + data, err := os.ReadFile(path) //nolint:gosec // Path comes from OpenCode transcript location + if err != nil { + return nil, fmt.Errorf("reading transcript file: %w", err) + } + return CalculateTokenUsageFromData(data, startIndex), nil +} + +// ExtractLastUserPrompt extracts the last user message from transcript data. +func ExtractLastUserPrompt(data []byte) string { + entries, err := ParseTranscript(data) + if err != nil { + return "" + } + return ExtractLastUserPromptFromEntries(entries) +} + +// ExtractLastUserPromptFromEntries extracts the last user prompt from parsed entries. +func ExtractLastUserPromptFromEntries(entries []TranscriptEntry) string { + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].Info.Role != MessageRoleUser { + continue + } + // Collect text parts + var texts []string + for _, part := range entries[i].Parts { + if part.Type == PartTypeText && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n") + } + } + return "" +} + +// ExtractAllUserPrompts extracts all user messages from transcript data. +func ExtractAllUserPrompts(data []byte) ([]string, error) { + entries, err := ParseTranscript(data) + if err != nil { + return nil, err + } + return ExtractAllUserPromptsFromEntries(entries), nil +} + +// ExtractAllUserPromptsFromEntries extracts all user prompts from parsed entries. +func ExtractAllUserPromptsFromEntries(entries []TranscriptEntry) []string { + var prompts []string + for _, entry := range entries { + if entry.Info.Role != MessageRoleUser { + continue + } + var texts []string + for _, part := range entry.Parts { + if part.Type == PartTypeText && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) > 0 { + prompts = append(prompts, strings.Join(texts, "\n")) + } + } + return prompts +} + +// ExtractLastAssistantMessage extracts the last assistant message from transcript data. +func ExtractLastAssistantMessage(data []byte) (string, error) { + entries, err := ParseTranscript(data) + if err != nil { + return "", err + } + return ExtractLastAssistantMessageFromEntries(entries), nil +} + +// ExtractLastAssistantMessageFromEntries extracts the last assistant response from parsed entries. +func ExtractLastAssistantMessageFromEntries(entries []TranscriptEntry) string { + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].Info.Role != MessageRoleAssistant { + continue + } + var texts []string + for _, part := range entries[i].Parts { + if part.Type == PartTypeText && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n") + } + } + return "" +} 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..b7eb9ae7b --- /dev/null +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -0,0 +1,739 @@ +package opencode + +import ( + "encoding/json" + "os" + "testing" +) + +func TestParseTranscript(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","sessionID":"s1","role":"user","time":{"created":1000,"completed":1001}},"parts":[{"type":"text","text":"hello"}]} +{"info":{"id":"2","sessionID":"s1","role":"assistant","time":{"created":1002,"completed":1003}},"parts":[{"type":"text","text":"hi there"}]} +`) + + entries, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(entries) != 2 { + t.Fatalf("ParseTranscript() got %d entries, want 2", len(entries)) + } + + if entries[0].Info.Role != MessageRoleUser || entries[0].Info.ID != "1" { + t.Errorf("First entry = %+v, want role=user, id=1", entries[0].Info) + } + + if entries[1].Info.Role != MessageRoleAssistant || entries[1].Info.ID != "2" { + t.Errorf("Second entry = %+v, want role=assistant, id=2", entries[1].Info) + } +} + +func TestParseTranscript_SkipsMalformed(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"user"},"parts":[]} +not valid json +{"info":{"id":"2","role":"assistant"},"parts":[]} +`) + + entries, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(entries) != 2 { + t.Errorf("ParseTranscript() got %d entries, want 2 (skipping malformed)", len(entries)) + } +} + +func TestParseTranscript_Empty(t *testing.T) { + t.Parallel() + + entries, err := ParseTranscript([]byte("")) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + if len(entries) != 0 { + t.Errorf("ParseTranscript('') got %d entries, want 0", len(entries)) + } +} + +func TestExtractModifiedFiles_FromSummaryDiffs(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"assistant","summary":{"title":"edit files","diffs":[{"file":"foo.go","additions":5,"deletions":0},{"file":"bar.go","additions":3,"deletions":1}]}},"parts":[{"type":"text","text":"done"}]} +`) + + files := ExtractModifiedFiles(data) + + if len(files) != 2 { + t.Fatalf("ExtractModifiedFiles() got %d files, want 2", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("ExtractModifiedFiles() missing foo.go") + } + if !hasFile("bar.go") { + t.Error("ExtractModifiedFiles() missing bar.go") + } +} + +func TestExtractModifiedFiles_FromPatchParts(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"assistant"},"parts":[{"type":"patch","filePath":"main.go"}]} +`) + + files := ExtractModifiedFiles(data) + + if len(files) != 1 || files[0] != "main.go" { + t.Errorf("ExtractModifiedFiles() = %v, want [main.go]", files) + } +} + +func TestExtractModifiedFiles_FromToolState(t *testing.T) { + t.Parallel() + + toolInput, err := json.Marshal(map[string]string{"file_path": "utils.go"}) + if err != nil { + t.Fatalf("failed to marshal tool input: %v", err) + } + entry := TranscriptEntry{ + Info: TranscriptEntryInfo{ID: "1", Role: MessageRoleAssistant}, + Parts: []TranscriptPart{ + { + Type: PartTypeTool, + Tool: ToolWrite, + State: &TranscriptToolState{ + Input: toolInput, + }, + }, + }, + } + + files := extractFilesFromEntry(&entry) + if len(files) != 1 || files[0] != "utils.go" { + t.Errorf("extractFilesFromEntry() = %v, want [utils.go]", files) + } +} + +func TestExtractModifiedFiles_Deduplication(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"assistant","summary":{"title":"edit","diffs":[{"file":"foo.go"},{"file":"foo.go"}]}},"parts":[]} +{"info":{"id":"2","role":"assistant","summary":{"title":"edit again","diffs":[{"file":"foo.go"}]}},"parts":[]} +`) + + files := ExtractModifiedFiles(data) + if len(files) != 1 { + t.Errorf("ExtractModifiedFiles() got %d files, want 1 (deduplicated)", len(files)) + } +} + +func TestExtractModifiedFiles_IgnoresUserRole(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"user","summary":{"title":"user stuff","diffs":[{"file":"should-not-appear.go"}]}},"parts":[]} +`) + + files := ExtractModifiedFiles(data) + if len(files) != 0 { + t.Errorf("ExtractModifiedFiles() = %v, want empty (should ignore user role)", files) + } +} + +func TestExtractModifiedFiles_IgnoresNonModifyTools(t *testing.T) { + t.Parallel() + + toolInput, err := json.Marshal(map[string]string{"command": "ls"}) + if err != nil { + t.Fatalf("failed to marshal tool input: %v", err) + } + entry := TranscriptEntry{ + Info: TranscriptEntryInfo{ID: "1", Role: MessageRoleAssistant}, + Parts: []TranscriptPart{ + { + Type: PartTypeTool, + Tool: "bash", + State: &TranscriptToolState{ + Input: toolInput, + }, + }, + }, + } + + files := extractFilesFromEntry(&entry) + if len(files) != 0 { + t.Errorf("extractFilesFromEntry() = %v, want empty for non-modify tool", files) + } +} + +func TestExtractLastUserPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want string + }{ + { + name: "single user message", + data: `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello world"}]}`, + want: "hello world", + }, + { + name: "multiple user messages returns last", + data: `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"first"}]} +{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"response"}]} +{"info":{"id":"3","role":"user"},"parts":[{"type":"text","text":"second"}]}`, + want: "second", + }, + { + name: "multiple text parts joined", + data: `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"part1"},{"type":"text","text":"part2"}]}`, + want: "part1\npart2", + }, + { + name: "empty transcript", + data: ``, + want: "", + }, + { + name: "only assistant messages", + data: `{"info":{"id":"1","role":"assistant"},"parts":[{"type":"text","text":"response"}]}`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ExtractLastUserPrompt([]byte(tt.data)) + if got != tt.want { + t.Errorf("ExtractLastUserPrompt() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractAllUserPrompts(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"first prompt"}]} +{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"response"}]} +{"info":{"id":"3","role":"user"},"parts":[{"type":"text","text":"second prompt"}]} +`) + + prompts, err := ExtractAllUserPrompts(data) + if err != nil { + t.Fatalf("ExtractAllUserPrompts() error = %v", err) + } + + if len(prompts) != 2 { + t.Fatalf("ExtractAllUserPrompts() got %d prompts, want 2", len(prompts)) + } + + if prompts[0] != "first prompt" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "first prompt") + } + if prompts[1] != "second prompt" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "second prompt") + } +} + +func TestExtractLastAssistantMessage(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello"}]} +{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"first response"}]} +{"info":{"id":"3","role":"user"},"parts":[{"type":"text","text":"another prompt"}]} +{"info":{"id":"4","role":"assistant"},"parts":[{"type":"text","text":"last response"}]} +`) + + msg, err := ExtractLastAssistantMessage(data) + if err != nil { + t.Fatalf("ExtractLastAssistantMessage() error = %v", err) + } + + if msg != "last response" { + t.Errorf("ExtractLastAssistantMessage() = %q, want %q", msg, "last response") + } +} + +func TestExtractLastAssistantMessage_NoAssistant(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello"}]} +`) + + msg, err := ExtractLastAssistantMessage(data) + if err != nil { + t.Fatalf("ExtractLastAssistantMessage() error = %v", err) + } + + if msg != "" { + t.Errorf("ExtractLastAssistantMessage() = %q, want empty", msg) + } +} + +func TestExtractFilePathFromToolState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state *TranscriptToolState + want string + }{ + { + name: "nil state", + state: nil, + want: "", + }, + { + name: "empty input", + state: &TranscriptToolState{}, + want: "", + }, + { + name: "file_path key", + state: &TranscriptToolState{ + Input: mustMarshalJSON(t, map[string]string{"file_path": "foo.go"}), + }, + want: "foo.go", + }, + { + name: "path key", + state: &TranscriptToolState{ + Input: mustMarshalJSON(t, map[string]string{"path": "bar.go"}), + }, + want: "bar.go", + }, + { + name: "filePath key", + state: &TranscriptToolState{ + Input: mustMarshalJSON(t, map[string]string{"filePath": "baz.go"}), + }, + want: "baz.go", + }, + { + name: "filename key", + state: &TranscriptToolState{ + Input: mustMarshalJSON(t, map[string]string{"filename": "qux.go"}), + }, + want: "qux.go", + }, + { + name: "no matching key", + state: &TranscriptToolState{ + Input: mustMarshalJSON(t, map[string]string{"command": "ls"}), + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractFilePathFromToolState(tt.state) + if got != tt.want { + t.Errorf("extractFilePathFromToolState() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetTranscriptPosition_WithFile(t *testing.T) { + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello"}]} +{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"hi"}]} +{"info":{"id":"3","role":"user"},"parts":[{"type":"text","text":"bye"}]} +` + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &OpenCodeAgent{} + pos, err := ag.GetTranscriptPosition(transcriptPath) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + + if pos != 3 { + t.Errorf("GetTranscriptPosition() = %d, want 3", pos) + } +} + +func TestExtractModifiedFilesFromOffset_WithFile(t *testing.T) { + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := `{"info":{"id":"1","role":"assistant","summary":{"title":"first","diffs":[{"file":"old.go"}]}},"parts":[]} +{"info":{"id":"2","role":"assistant","summary":{"title":"second","diffs":[{"file":"new.go"}]}},"parts":[]} +{"info":{"id":"3","role":"assistant","summary":{"title":"third","diffs":[{"file":"newer.go"}]}},"parts":[]} +` + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &OpenCodeAgent{} + + // From offset 0 (all lines) + files, pos, err := ag.ExtractModifiedFilesFromOffset(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset(0) error = %v", err) + } + if len(files) != 3 { + t.Errorf("ExtractModifiedFilesFromOffset(0) files = %v, want 3 files", files) + } + if pos != 3 { + t.Errorf("ExtractModifiedFilesFromOffset(0) pos = %d, want 3", pos) + } + + // From offset 1 (skip first line) + files, pos, err = ag.ExtractModifiedFilesFromOffset(transcriptPath, 1) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset(1) error = %v", err) + } + if len(files) != 2 { + t.Errorf("ExtractModifiedFilesFromOffset(1) files = %v, want 2 files", files) + } + if pos != 3 { + t.Errorf("ExtractModifiedFilesFromOffset(1) pos = %d, want 3", pos) + } + + // From offset 2 (skip first two lines) + files, pos, err = ag.ExtractModifiedFilesFromOffset(transcriptPath, 2) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset(2) error = %v", err) + } + if len(files) != 1 { + t.Errorf("ExtractModifiedFilesFromOffset(2) files = %v, want 1 file", files) + } + if files[0] != "newer.go" { + t.Errorf("ExtractModifiedFilesFromOffset(2) files[0] = %q, want %q", files[0], "newer.go") + } + if pos != 3 { + t.Errorf("ExtractModifiedFilesFromOffset(2) pos = %d, want 3", pos) + } +} + +func TestChunkTranscript(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + + line1 := `{"info":{"id":"1","role":"user"},"parts":[{"type":"text","text":"hello"}]}` + line2 := `{"info":{"id":"2","role":"assistant"},"parts":[{"type":"text","text":"hi there"}]}` + content := []byte(line1 + "\n" + line2 + "\n") + + // Set maxSize large enough for both lines + chunks, err := ag.ChunkTranscript(content, len(content)+100) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 1 { + t.Errorf("ChunkTranscript() got %d chunks, want 1", len(chunks)) + } +} + +func TestReassembleTranscript(t *testing.T) { + t.Parallel() + ag := &OpenCodeAgent{} + + chunk1 := []byte(`{"info":{"id":"1","role":"user"},"parts":[]}` + "\n") + chunk2 := []byte(`{"info":{"id":"2","role":"assistant"},"parts":[]}` + "\n") + + result, err := ag.ReassembleTranscript([][]byte{chunk1, chunk2}) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + // Parse to verify valid JSONL + entries, err := ParseTranscript(result) + if err != nil { + t.Fatalf("ReassembleTranscript result is not valid transcript: %v", err) + } + if len(entries) != 2 { + t.Errorf("ReassembleTranscript() got %d entries, want 2", len(entries)) + } +} + +// mustMarshalJSON is a test helper that marshals a value to JSON. +func mustMarshalJSON(t *testing.T, v interface{}) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return data +} + +func TestCalculateTokenUsage_BasicMessages(t *testing.T) { + t.Parallel() + + // Two assistant messages with token usage in info + data := []byte(`{"info":{"id":"1","sessionID":"s1","role":"user","time":{"created":1000,"completed":1001}},"parts":[{"type":"text","text":"hello"}]} +{"info":{"id":"2","sessionID":"s1","role":"assistant","time":{"created":1002,"completed":1003},"tokens":{"input":100,"output":50,"reasoning":10,"cache":{"read":20,"write":5}},"cost":0.01},"parts":[{"type":"text","text":"hi there"}]} +{"info":{"id":"3","sessionID":"s1","role":"user","time":{"created":1004,"completed":1005}},"parts":[{"type":"text","text":"how are you?"}]} +{"info":{"id":"4","sessionID":"s1","role":"assistant","time":{"created":1006,"completed":1007},"tokens":{"input":200,"output":80,"reasoning":15,"cache":{"read":30,"write":10}},"cost":0.02},"parts":[{"type":"text","text":"doing well"}]} +`) + + entries, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + usage := CalculateTokenUsage(entries, 0) + + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } + // Input: 100 + 200 = 300 + if usage.InputTokens != 300 { + t.Errorf("InputTokens = %d, want 300", usage.InputTokens) + } + // Output: 50 + 80 = 130 + if usage.OutputTokens != 130 { + t.Errorf("OutputTokens = %d, want 130", usage.OutputTokens) + } + // CacheRead: 20 + 30 = 50 + if usage.CacheReadTokens != 50 { + t.Errorf("CacheReadTokens = %d, want 50", usage.CacheReadTokens) + } + // CacheCreation: 5 + 10 = 15 + if usage.CacheCreationTokens != 15 { + t.Errorf("CacheCreationTokens = %d, want 15", usage.CacheCreationTokens) + } +} + +func TestCalculateTokenUsage_StartIndex(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","sessionID":"s1","role":"user","time":{"created":1000,"completed":1001}},"parts":[{"type":"text","text":"hello"}]} +{"info":{"id":"2","sessionID":"s1","role":"assistant","time":{"created":1002,"completed":1003},"tokens":{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0.01},"parts":[{"type":"text","text":"hi"}]} +{"info":{"id":"3","sessionID":"s1","role":"user","time":{"created":1004,"completed":1005}},"parts":[{"type":"text","text":"more"}]} +{"info":{"id":"4","sessionID":"s1","role":"assistant","time":{"created":1006,"completed":1007},"tokens":{"input":200,"output":80,"reasoning":0,"cache":{"read":10,"write":5}},"cost":0.02},"parts":[{"type":"text","text":"ok"}]} +`) + + entries, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + // Start from index 2 — should only count the second assistant message + usage := CalculateTokenUsage(entries, 2) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } + if usage.InputTokens != 200 { + t.Errorf("InputTokens = %d, want 200", usage.InputTokens) + } + if usage.OutputTokens != 80 { + t.Errorf("OutputTokens = %d, want 80", usage.OutputTokens) + } + if usage.CacheReadTokens != 10 { + t.Errorf("CacheReadTokens = %d, want 10", usage.CacheReadTokens) + } +} + +func TestCalculateTokenUsage_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + // Even if a user message somehow had tokens, they should be ignored + entries := []TranscriptEntry{ + { + Info: TranscriptEntryInfo{ + ID: "1", + Role: MessageRoleUser, + Tokens: &TranscriptTokens{ + Input: 999, Output: 999, + Cache: TranscriptTokensCache{Read: 999, Write: 999}, + }, + }, + }, + { + Info: TranscriptEntryInfo{ + ID: "2", + Role: MessageRoleAssistant, + Tokens: &TranscriptTokens{ + Input: 10, Output: 20, + Cache: TranscriptTokensCache{Read: 5, Write: 3}, + }, + }, + }, + } + + usage := CalculateTokenUsage(entries, 0) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } + if usage.InputTokens != 10 { + t.Errorf("InputTokens = %d, want 10", usage.InputTokens) + } + if usage.OutputTokens != 20 { + t.Errorf("OutputTokens = %d, want 20", usage.OutputTokens) + } +} + +func TestCalculateTokenUsage_EmptyTranscript(t *testing.T) { + t.Parallel() + + usage := CalculateTokenUsage(nil, 0) + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } +} + +func TestCalculateTokenUsage_DeduplicatesByMessageID(t *testing.T) { + t.Parallel() + + // Same message ID appearing twice (e.g., streaming duplicates) + entries := []TranscriptEntry{ + { + Info: TranscriptEntryInfo{ + ID: "msg-1", + Role: MessageRoleAssistant, + Tokens: &TranscriptTokens{ + Input: 100, Output: 50, + Cache: TranscriptTokensCache{Read: 10, Write: 5}, + }, + }, + }, + { + Info: TranscriptEntryInfo{ + ID: "msg-1", // duplicate + Role: MessageRoleAssistant, + Tokens: &TranscriptTokens{ + Input: 100, Output: 50, + Cache: TranscriptTokensCache{Read: 10, Write: 5}, + }, + }, + }, + } + + usage := CalculateTokenUsage(entries, 0) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1 (deduplicated)", usage.APICallCount) + } + if usage.InputTokens != 100 { + t.Errorf("InputTokens = %d, want 100 (not double-counted)", usage.InputTokens) + } +} + +func TestCalculateTokenUsage_NoTokensField(t *testing.T) { + t.Parallel() + + // Assistant message without tokens field + entries := []TranscriptEntry{ + { + Info: TranscriptEntryInfo{ + ID: "1", + Role: MessageRoleAssistant, + // No Tokens field + }, + }, + } + + usage := CalculateTokenUsage(entries, 0) + + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0 (no tokens)", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromData(t *testing.T) { + t.Parallel() + + data := []byte(`{"info":{"id":"1","sessionID":"s1","role":"assistant","time":{"created":1000,"completed":1001},"tokens":{"input":50,"output":25,"reasoning":0,"cache":{"read":10,"write":5}},"cost":0.005},"parts":[{"type":"text","text":"done"}]} +`) + + usage := CalculateTokenUsageFromData(data, 0) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } + if usage.InputTokens != 50 { + t.Errorf("InputTokens = %d, want 50", usage.InputTokens) + } + if usage.OutputTokens != 25 { + t.Errorf("OutputTokens = %d, want 25", usage.OutputTokens) + } +} + +func TestCalculateTokenUsageFromData_Empty(t *testing.T) { + t.Parallel() + + usage := CalculateTokenUsageFromData([]byte(""), 0) + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile(t *testing.T) { + tmpDir := t.TempDir() + path := tmpDir + "/transcript.jsonl" + + content := `{"info":{"id":"1","sessionID":"s1","role":"assistant","time":{"created":1000,"completed":1001},"tokens":{"input":75,"output":30,"reasoning":5,"cache":{"read":15,"write":8}},"cost":0.01},"parts":[{"type":"text","text":"result"}]} +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + usage, err := CalculateTokenUsageFromFile(path, 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile() error = %v", err) + } + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } + if usage.InputTokens != 75 { + t.Errorf("InputTokens = %d, want 75", usage.InputTokens) + } + if usage.CacheReadTokens != 15 { + t.Errorf("CacheReadTokens = %d, want 15", usage.CacheReadTokens) + } + if usage.CacheCreationTokens != 8 { + t.Errorf("CacheCreationTokens = %d, want 8", usage.CacheCreationTokens) + } +} + +func TestCalculateTokenUsageFromFile_EmptyPath(t *testing.T) { + t.Parallel() + + usage, err := CalculateTokenUsageFromFile("", 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile('') error = %v", err) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile_NonExistent(t *testing.T) { + t.Parallel() + + _, err := CalculateTokenUsageFromFile("/nonexistent/path.jsonl", 0) + if err == nil { + t.Error("CalculateTokenUsageFromFile() expected error for nonexistent file") + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index d26575e81..0e42dc495 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" @@ -399,6 +400,21 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { return nil } + // OpenCode uses JSONL but with its own schema + 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)) @@ -432,6 +448,11 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } + // OpenCode uses JSONL with its own token schema + if agentType == agent.AgentTypeOpenCode { + return opencode.CalculateTokenUsageFromData(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