Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions cmd/entire/cli/agent/opencode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions cmd/entire/cli/agent/opencode/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment on lines +402 to +417
10 changes: 10 additions & 0 deletions cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +813 to +817
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)
Expand Down
75 changes: 75 additions & 0 deletions cmd/entire/cli/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +1907 to +1913
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)
}
}