diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index e22acc217..ce625c56f 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -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 @@ -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) @@ -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) } @@ -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 diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index dd3975f67..6003e4aa6 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "errors" "os" "os/exec" "path/filepath" @@ -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" @@ -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) diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..67b3141cc 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -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) { diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 7d5ae525d..2e88d5753 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -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{} }