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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import (
"golang.org/x/term"
)

const defaultCheckpointSummaryTimeout = 30 * time.Second

var checkpointSummaryTimeout = defaultCheckpointSummaryTimeout

var generateTranscriptSummary = summarize.GenerateFromTranscript

// interaction holds a single prompt and its responses for display.
type interaction struct {
Prompt string
Expand Down Expand Up @@ -451,7 +457,7 @@ func readV2ContentFromMain(ctx context.Context, v2Reader *checkpoint.V2GitStore,
// generateCheckpointSummary generates an AI summary for a checkpoint and persists it.
// The summary is generated from the scoped transcript (only this checkpoint's portion),
// not the entire session transcript.
func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error {
func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error {
// Check if summary already exists
if content.Metadata.Summary != nil && !force {
return fmt.Errorf("checkpoint %s already has a summary (use --force to regenerate)", checkpointID)
Expand All @@ -470,8 +476,11 @@ func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, v1Store *che

// Generate summary using shared helper
logging.Info(ctx, "generating checkpoint summary")
if errW != nil {
fmt.Fprintln(errW, "Generating checkpoint summary...")
}

summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil)
summary, err := generateCheckpointAISummary(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent)
if err != nil {
return fmt.Errorf("failed to generate summary: %w", err)
}
Expand Down Expand Up @@ -506,6 +515,38 @@ func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, v1Store *che
return nil
}

func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, filesTouched []string, agentType types.AgentType) (*checkpoint.Summary, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, checkpointSummaryTimeout)
timeoutDuration := checkpointSummaryTimeout
if deadline, ok := timeoutCtx.Deadline(); ok {
timeoutDuration = time.Until(deadline)
}
defer cancel()

summary, err := generateTranscriptSummary(timeoutCtx, scopedTranscript, filesTouched, agentType, nil)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(timeoutCtx.Err(), context.Canceled) {
return nil, fmt.Errorf("summary generation canceled: %w", context.Canceled)
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("summary generation timed out after %s: %w", formatSummaryTimeout(timeoutDuration), context.DeadlineExceeded)
}
return nil, err
}

return summary, nil
}

func formatSummaryTimeout(d time.Duration) string {
if d < 0 {
d = 0
}
if d < time.Second {
return d.Round(10 * time.Millisecond).String()
}
return d.Round(time.Second).String()
}

// explainTemporaryCheckpoint finds and formats a temporary checkpoint by shadow commit hash prefix.
// Returns the formatted output and whether the checkpoint was found.
// Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints
Expand Down
168 changes: 168 additions & 0 deletions cmd/entire/cli/explain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
Expand All @@ -11,6 +12,7 @@ import (
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
Expand Down Expand Up @@ -100,6 +102,172 @@ func TestExplainCmd_RejectsPositionalArgs(t *testing.T) {
}
}

func TestGenerateCheckpointAISummary_AddsDefaultTimeoutWithoutParentDeadline(t *testing.T) {
tmpTimeout := checkpointSummaryTimeout
tmpGenerator := generateTranscriptSummary
t.Cleanup(func() {
checkpointSummaryTimeout = tmpTimeout
generateTranscriptSummary = tmpGenerator
})

checkpointSummaryTimeout = 50 * time.Millisecond

var gotDeadline time.Time
generateTranscriptSummary = func(
ctx context.Context,
_ []byte,
_ []string,
_ types.AgentType,
_ summarize.Generator,
) (*checkpoint.Summary, error) {
deadline, ok := ctx.Deadline()
if !ok {
return nil, errors.New("expected deadline on summary context")
}
gotDeadline = deadline
return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil
}

start := time.Now()
summary, err := generateCheckpointAISummary(context.Background(), []byte("transcript"), nil, agent.AgentTypeClaudeCode)
if err != nil {
t.Fatalf("generateCheckpointAISummary() error = %v", err)
}
if summary == nil {
t.Fatal("expected summary")
}
if gotDeadline.IsZero() {
t.Fatal("expected deadline to be set")
}
if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond {
t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout)
}
}

func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testing.T) {
tmpTimeout := checkpointSummaryTimeout
tmpGenerator := generateTranscriptSummary
t.Cleanup(func() {
checkpointSummaryTimeout = tmpTimeout
generateTranscriptSummary = tmpGenerator
})

checkpointSummaryTimeout = 30 * time.Second

parentCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
parentDeadline, _ := parentCtx.Deadline()

var gotDeadline time.Time
generateTranscriptSummary = func(
ctx context.Context,
_ []byte,
_ []string,
_ types.AgentType,
_ summarize.Generator,
) (*checkpoint.Summary, error) {
gotDeadline, _ = ctx.Deadline()
<-ctx.Done()
return nil, ctx.Err()
}

_, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode)
if err == nil {
t.Fatal("expected timeout error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
if gotDeadline.IsZero() {
t.Fatal("expected deadline to be captured")
}
if delta := gotDeadline.Sub(parentDeadline); delta < -5*time.Millisecond || delta > 5*time.Millisecond {
t.Fatalf("deadline delta = %s, want near 0", delta)
}
if strings.Contains(err.Error(), "30s") {
t.Fatalf("timeout error should not report default timeout when parent deadline fired: %v", err)
}
}

func TestGenerateCheckpointAISummary_ClampsLongParentDeadlineToDefaultTimeout(t *testing.T) {
tmpTimeout := checkpointSummaryTimeout
tmpGenerator := generateTranscriptSummary
t.Cleanup(func() {
checkpointSummaryTimeout = tmpTimeout
generateTranscriptSummary = tmpGenerator
})

checkpointSummaryTimeout = 50 * time.Millisecond

parentCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

var gotDeadline time.Time
generateTranscriptSummary = func(
ctx context.Context,
_ []byte,
_ []string,
_ types.AgentType,
_ summarize.Generator,
) (*checkpoint.Summary, error) {
deadline, ok := ctx.Deadline()
if !ok {
return nil, errors.New("expected deadline on summary context")
}
gotDeadline = deadline
return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil
}

start := time.Now()
summary, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode)
if err != nil {
t.Fatalf("generateCheckpointAISummary() error = %v", err)
}
if summary == nil {
t.Fatal("expected summary")
}
if gotDeadline.IsZero() {
t.Fatal("expected deadline to be set")
}
if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond {
t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout)
}
}

func TestGenerateCheckpointAISummary_UsesCancellationSentinel(t *testing.T) {
tmpTimeout := checkpointSummaryTimeout
tmpGenerator := generateTranscriptSummary
t.Cleanup(func() {
checkpointSummaryTimeout = tmpTimeout
generateTranscriptSummary = tmpGenerator
})

parentCtx, cancel := context.WithCancel(context.Background())

generateTranscriptSummary = func(
ctx context.Context,
_ []byte,
_ []string,
_ types.AgentType,
_ summarize.Generator,
) (*checkpoint.Summary, error) {
cancel()
<-ctx.Done()
return nil, ctx.Err()
}

_, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode)
if err == nil {
t.Fatal("expected cancellation error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected Canceled, got %v", err)
}
if !strings.Contains(err.Error(), "canceled") {
t.Fatalf("expected cancellation message, got %v", err)
}
}

func TestExplainCommit_NotFound(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/summarize/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin

err := cmd.Run()
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, context.DeadlineExceeded
}
if errors.Is(ctx.Err(), context.Canceled) {
return nil, context.Canceled
}

// Check if the command was not found
var execErr *exec.Error
if errors.As(err, &execErr) {
Expand Down
1 change: 0 additions & 1 deletion cmd/entire/cli/summarize/summarize.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTo
FilesTouched: filesTouched,
}

// Use default generator if none provided
if generator == nil {
generator = &ClaudeGenerator{}
}
Expand Down
Loading