diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 73f548e444..1f2b7709d5 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -188,6 +188,19 @@ type TranscriptPreparer interface { PrepareTranscript(ctx context.Context, sessionRef string) error } +// TranscriptFetcher is implemented by agents that can materialize a session +// transcript on demand (e.g. OpenCode via `opencode export`), including for +// sessions Entire never tracked — where no hook-cached transcript file exists +// (e.g. sessions spawned by an external host rather than a hooked terminal). +// TranscriptPreparer, by contrast, only refreshes an already-existing file. +type TranscriptFetcher interface { + Agent + + // FetchTranscript writes the session's transcript to the agent's cache + // location and returns its path. + FetchTranscript(ctx context.Context, sessionID string) (string, error) +} + // SidecarImageProvider is implemented by agents that keep images OUTSIDE the // transcript Entire condenses — e.g. Cursor stores pasted images in a per-session // SQLite blob store, not the JSONL transcript. The strategy layer calls this diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index 9c4ed549c0..cb8727f2c4 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -89,6 +89,18 @@ func AsSidecarImageProvider(ag Agent) (SidecarImageProvider, bool) { return p, ok } +// AsTranscriptFetcher returns the agent as TranscriptFetcher if it implements +// the interface. This is an optional capability (materializing a transcript on +// demand for sessions with no hook-cached file), so it resolves by type +// assertion alone with no DeclaredCaps gate. +func AsTranscriptFetcher(ag Agent) (TranscriptFetcher, bool) { + if ag == nil { + return nil, false + } + f, ok := ag.(TranscriptFetcher) + return f, ok +} + // AsTokenCalculator returns the agent as TokenCalculator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { diff --git a/cmd/entire/cli/agent/opencode/lifecycle.go b/cmd/entire/cli/agent/opencode/lifecycle.go index 0f413192a2..dce618c772 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle.go +++ b/cmd/entire/cli/agent/opencode/lifecycle.go @@ -168,6 +168,14 @@ func (a *OpenCodeAgent) PrepareTranscript(ctx context.Context, sessionRef string return err } +// FetchTranscript materializes the session's transcript via `opencode export` +// and returns the cached path. Unlike PrepareTranscript (which only refreshes +// an existing file), this works for sessions Entire never tracked — e.g. +// sessions spawned by an external host, where no hook ever cached an export. +func (a *OpenCodeAgent) FetchTranscript(ctx context.Context, sessionID string) (string, error) { + return a.fetchAndCacheExport(ctx, sessionID) +} + // sessionTranscriptPath validates the session ID and returns the expected transcript path. func sessionTranscriptPath(ctx context.Context, sessionID string) (string, error) { if err := validation.ValidateSessionID(sessionID); err != nil { diff --git a/cmd/entire/cli/agent/opencode/lifecycle_test.go b/cmd/entire/cli/agent/opencode/lifecycle_test.go index 27dbfe0c83..e3fff4d03e 100644 --- a/cmd/entire/cli/agent/opencode/lifecycle_test.go +++ b/cmd/entire/cli/agent/opencode/lifecycle_test.go @@ -389,3 +389,29 @@ func TestFetchAndCacheExport_WritesAndValidatesExportFile(t *testing.T) { require.True(t, json.Valid(content), "expected cached transcript to be valid JSON") require.Contains(t, string(content), "\"ses_abc123\"") } + +func TestFetchTranscript_ValidatesSessionID(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + if _, err := ag.FetchTranscript(context.Background(), "bad/session-id"); err == nil { + t.Fatal("expected error for session ID with path separator, got nil") + } +} + +func TestFetchTranscript_AttemptsExport(t *testing.T) { + t.Parallel() + + // Without mock-export mode, FetchTranscript must attempt `opencode export` + // — proving it tries to materialize the transcript for sessions with no + // hook-cached file rather than stat-checking an existing one. Without a + // usable session the export fails, wrapped as "opencode export failed". + ag := &OpenCodeAgent{} + _, err := ag.FetchTranscript(context.Background(), "test-fetch-transcript-no-such-session") + if err == nil { + t.Fatal("expected error for nonexistent session, got nil") + } + if !strings.Contains(err.Error(), "opencode export failed") { + t.Errorf("expected 'opencode export failed' error, got: %v", err) + } +} diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 037082f3e2..735ca186c4 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -810,6 +810,16 @@ func resolveAndValidateTranscript(ctx context.Context, sessionID string, ag agen } return transcriptPath, nil } + // Agents that can materialize a transcript on demand (e.g. OpenCode via + // `opencode export`) can conjure one even when no hook-cached file exists, + // e.g. sessions spawned by an external host rather than a hooked terminal. + if fetcher, ok := agent.AsTranscriptFetcher(ag); ok { + fetched, fetchErr := fetcher.FetchTranscript(ctx, sessionID) + if fetchErr == nil { + return fetched, nil + } + logging.Debug(ctx, "FetchTranscript failed, falling back to project-dir search", "error", fetchErr) + } found, searchErr := searchTranscriptInProjectDirs(sessionID, ag) if searchErr == nil { logging.Info(ctx, "found transcript in alternative project directory", "path", found) diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index e4c6f5b940..02a4ec26f7 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -1903,3 +1903,78 @@ func runGitInDir(t *testing.T, dir string, args ...string) { t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) } } + +// TestAttach_OpenCodeFetchesTranscriptForUntrackedSession is a regression test +// for sessions spawned outside a hooked terminal (e.g. by an external session +// host): no hook ever cached an export under .entire/tmp, and +// resolveAndValidateTranscript used to give up because PrepareTranscript was +// only called when the file already existed. OpenCode can materialize the +// transcript via `opencode export`, so attach now consults the +// TranscriptFetcher capability before failing. +func TestAttach_OpenCodeFetchesTranscriptForUntrackedSession(t *testing.T) { + setupAttachTestRepo(t) + // Mock-export mode: fetchAndCacheExport returns the pre-written file + // instead of invoking the opencode CLI. + t.Setenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT", "1") + + sessionID := "test-attach-opencode-untracked" + repoRoot := mustGetwd(t) + tmpDir := filepath.Join(repoRoot, ".entire", "tmp") + if err := os.MkdirAll(tmpDir, 0o750); err != nil { + t.Fatal(err) + } + export := `{ + "info": {"id": "test-attach-opencode-untracked", "title": "docs: example"}, + "messages": [ + { + "info": {"id": "msg_u1", "role": "user", "time": {"created": 1767225600000}}, + "parts": [{"type": "text", "text": "Update the example doc"}] + }, + { + "info": { + "id": "msg_a1", "role": "assistant", + "providerID": "fireworks-ai", "modelID": "accounts/fireworks/routers/kimi-k3-fast", + "time": {"created": 1767225660000, "completed": 1767225700000}, + "tokens": {"total": 100, "input": 90, "output": 10, "reasoning": 0, "cache": {"write": 0, "read": 0}} + }, + "parts": [ + {"type": "tool", "tool": "bash", "callID": "bash_0", + "state": {"status": "completed", "input": {"command": "git commit -m \"docs: example\""}, "output": "ok"}} + ] + } + ] +}` + if err := os.WriteFile(filepath.Join(tmpDir, sessionID+".json"), []byte(export), 0o600); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, &out, sessionID, agent.AgentNameOpenCode, attachOptions{Force: true}) + if err != nil { + t.Fatalf("runAttach failed: %v", err) + } + + store, err := session.NewStateStore(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := store.Load(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("expected session state to be created") + return + } + if state.LastCheckpointID.IsEmpty() { + t.Error("expected LastCheckpointID to be set after attach") + } + + output := out.String() + if !strings.Contains(output, "Attached session") { + t.Errorf("expected 'Attached session' in output, got: %s", output) + } + if !strings.Contains(output, "Created checkpoint") { + t.Errorf("expected 'Created checkpoint' in output, got: %s", output) + } +}