diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 1bc6385e2..34b5077fd 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -17,6 +17,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/summarize" "github.com/entireio/cli/cmd/entire/cli/trailers" @@ -320,7 +321,12 @@ func generateCheckpointSummary(w, _ io.Writer, store *checkpoint.GitStore, check ctx := context.Background() logging.Info(ctx, "generating checkpoint summary") - summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil) + s, sErr := settings.Load() + if sErr != nil { + s = nil + } + gen := summarize.ResolveGenerator(s) + summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, gen) if err != nil { return fmt.Errorf("failed to generate summary: %w", err) } diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 381c9993a..fb52a765b 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -213,6 +213,60 @@ func applyDefaults(settings *EntireSettings) { } } +// summarizeOpts extracts the summarize sub-map from strategy_options, or returns nil. +func (s *EntireSettings) summarizeOpts() map[string]any { + if s.StrategyOptions == nil { + return nil + } + opts, ok := s.StrategyOptions["summarize"].(map[string]any) + if !ok { + return nil + } + return opts +} + +// SummarizeProvider returns the configured summarization provider name. +// Returns empty string if not set (caller should default to Claude). +func (s *EntireSettings) SummarizeProvider() string { + opts := s.summarizeOpts() + if opts == nil { + return "" + } + provider, ok := opts["provider"].(string) + if !ok { + return "" + } + return provider +} + +// SummarizeModel returns the configured model for the summarization provider. +// Returns empty string if not set (caller should use the provider's default). +func (s *EntireSettings) SummarizeModel() string { + opts := s.summarizeOpts() + if opts == nil { + return "" + } + model, ok := opts["model"].(string) + if !ok { + return "" + } + return model +} + +// SummarizeAPIKey returns the configured API key for the summarization provider. +// Returns empty string if not set. +func (s *EntireSettings) SummarizeAPIKey() string { + opts := s.summarizeOpts() + if opts == nil { + return "" + } + apiKey, ok := opts["api_key"].(string) + if !ok { + return "" + } + return apiKey +} + // IsSummarizeEnabled checks if auto-summarize is enabled in settings. // Returns false by default if settings cannot be loaded or the key is missing. func IsSummarizeEnabled() bool { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index ad09bc57a..3aa217001 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -135,6 +135,66 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { } } +func TestSummarizeProvider_Empty(t *testing.T) { + t.Parallel() + s := &EntireSettings{} + if got := s.SummarizeProvider(); got != "" { + t.Errorf("expected empty provider, got %q", got) + } +} + +func TestSummarizeProvider_Set(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + StrategyOptions: map[string]any{ + "summarize": map[string]any{"provider": "openai"}, + }, + } + if got := s.SummarizeProvider(); got != "openai" { + t.Errorf("expected provider openai, got %q", got) + } +} + +func TestSummarizeModel_Empty(t *testing.T) { + t.Parallel() + s := &EntireSettings{} + if got := s.SummarizeModel(); got != "" { + t.Errorf("expected empty model, got %q", got) + } +} + +func TestSummarizeModel_Set(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + StrategyOptions: map[string]any{ + "summarize": map[string]any{"model": "gpt-5-mini"}, + }, + } + if got := s.SummarizeModel(); got != "gpt-5-mini" { + t.Errorf("expected model gpt-5-mini, got %q", got) + } +} + +func TestSummarizeAPIKey_Empty(t *testing.T) { + t.Parallel() + s := &EntireSettings{} + if got := s.SummarizeAPIKey(); got != "" { + t.Errorf("expected empty api_key, got %q", got) + } +} + +func TestSummarizeAPIKey_Set(t *testing.T) { + t.Parallel() + s := &EntireSettings{ + StrategyOptions: map[string]any{ + "summarize": map[string]any{"api_key": "sk-test"}, + }, + } + if got := s.SummarizeAPIKey(); got != "sk-test" { + t.Errorf("expected api_key sk-test, got %q", got) + } +} + // containsUnknownField checks if the error message indicates an unknown field func containsUnknownField(msg string) bool { // Go's json package reports unknown fields with this message format diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 5ee5ced3e..ebb278008 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -18,6 +18,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/summarize" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/go-git/go-git/v5" @@ -258,6 +260,29 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository // Write committed checkpoint using the checkpoint store // Pass TranscriptPath so writeTranscript generates content_hash.txt transcriptPath := filepath.Join(ctx.MetadataDirAbs, paths.TranscriptFileName) + + // Generate summary if summarization is enabled (non-blocking) + var cpSummary *checkpoint.Summary + if settings.IsSummarizeEnabled() { + if transcriptBytes, readErr := os.ReadFile(transcriptPath); readErr == nil && len(transcriptBytes) > 0 { //nolint:gosec // path computed from session metadata + scoped := summarize.ScopeTranscript(transcriptBytes, ctx.StepTranscriptStart, ctx.AgentType) + if len(scoped) > 0 { + s, sErr := settings.Load() + if sErr != nil { + s = nil + } + gen := summarize.ResolveGenerator(s) + sumCtx := logging.WithComponent(context.Background(), "summarize") + var genErr error + cpSummary, genErr = summarize.GenerateFromTranscript(sumCtx, scoped, filesTouched, ctx.AgentType, gen) + if genErr != nil { + logging.Warn(sumCtx, "auto-commit summary generation failed", + slog.String("session_id", sessionID), + slog.String("error", genErr.Error())) + } + } + } + } err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: checkpointID, SessionID: sessionID, @@ -274,6 +299,7 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository TokenUsage: ctx.TokenUsage, CheckpointsCount: 1, // Each auto-commit checkpoint = 1 FilesTouched: filesTouched, // Track modified files (same as manual-commit) + Summary: cpSummary, }) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to write committed checkpoint: %w", err) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index e34076c86..b9a155563 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -199,8 +199,13 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart) } if len(scopedTranscript) > 0 { + s, sErr := settings.Load() + if sErr != nil { + s = nil + } + gen := summarize.ResolveGenerator(s) var err error - summary, err = summarize.GenerateFromTranscript(summarizeCtx, scopedTranscript, sessionData.FilesTouched, state.AgentType, nil) + summary, err = summarize.GenerateFromTranscript(summarizeCtx, scopedTranscript, sessionData.FilesTouched, state.AgentType, gen) if err != nil { logging.Warn(summarizeCtx, "summary generation failed", slog.String("session_id", state.SessionID), diff --git a/cmd/entire/cli/summarize/openai.go b/cmd/entire/cli/summarize/openai.go new file mode 100644 index 000000000..e461871d2 --- /dev/null +++ b/cmd/entire/cli/summarize/openai.go @@ -0,0 +1,132 @@ +package summarize + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" +) + +const openAIDefaultModel = "gpt-5-mini-mini" +const openAIAPIEndpoint = "https://api.openai.com/v1/chat/completions" + +// OpenAIGenerator generates summaries using the OpenAI API. +type OpenAIGenerator struct { + // APIKey is the OpenAI API key. + // Falls back to OPENAI_API_KEY environment variable if empty. + APIKey string + + // Model is the OpenAI model to use. + // Defaults to openAIDefaultModel if empty. + Model string + + // HTTPClient is used for API requests. Defaults to http.DefaultClient if nil. + HTTPClient *http.Client + + // endpoint overrides the API URL (used in tests). + endpoint string +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openAIResponse struct { + Choices []openAIChoice `json:"choices"` + Error *openAIError `json:"error,omitempty"` +} + +type openAIChoice struct { + Message openAIMessage `json:"message"` +} + +type openAIError struct { + Message string `json:"message"` + Type string `json:"type"` +} + +// Generate creates a summary by calling the OpenAI chat completions API. +func (g *OpenAIGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { + apiKey := g.APIKey + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + if apiKey == "" { + return nil, errors.New("OpenAI API key not configured: set summarize.api_key in settings or OPENAI_API_KEY environment variable") + } + + model := g.Model + if model == "" { + model = openAIDefaultModel + } + + transcriptText := FormatCondensedTranscript(input) + prompt := buildSummarizationPrompt(transcriptText) + + reqBody := openAIRequest{ + Model: model, + Messages: []openAIMessage{{Role: "user", Content: prompt}}, + } + reqBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal OpenAI request: %w", err) + } + + endpoint := g.endpoint + if endpoint == "" { + endpoint = openAIAPIEndpoint + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := g.HTTPClient + if client == nil { + client = http.DefaultClient + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call OpenAI API: %w", err) + } + defer resp.Body.Close() + + var apiResp openAIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to parse OpenAI response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + errMsg := "unknown error" + if apiResp.Error != nil { + errMsg = apiResp.Error.Message + } + return nil, fmt.Errorf("OpenAI API error (status %d): %s", resp.StatusCode, errMsg) + } + + if len(apiResp.Choices) == 0 { + return nil, errors.New("OpenAI API returned no choices") + } + + resultJSON := extractJSONFromMarkdown(apiResp.Choices[0].Message.Content) + var summary checkpoint.Summary + if err := json.Unmarshal([]byte(resultJSON), &summary); err != nil { + return nil, fmt.Errorf("failed to parse summary JSON from OpenAI: %w (response: %s)", err, resultJSON) + } + + return &summary, nil +} diff --git a/cmd/entire/cli/summarize/openai_test.go b/cmd/entire/cli/summarize/openai_test.go new file mode 100644 index 000000000..5b2ae4dec --- /dev/null +++ b/cmd/entire/cli/summarize/openai_test.go @@ -0,0 +1,208 @@ +package summarize + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// openAITestGenerator returns an OpenAIGenerator wired to the given test server. +func openAITestGenerator(t *testing.T, srv *httptest.Server, apiKey string) *OpenAIGenerator { + t.Helper() + return &OpenAIGenerator{ + APIKey: apiKey, + endpoint: srv.URL, + } +} + +// openAISuccessHandler returns an HTTP handler that responds with a valid summary JSON. +func openAISuccessHandler(t *testing.T, intent, outcome string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, _ *http.Request) { + body, err := json.Marshal(map[string]any{ + "intent": intent, + "outcome": outcome, + "learnings": map[string]any{"repo": []any{}, "code": []any{}, "workflow": []any{}}, + "friction": []any{}, + "open_items": []any{}, + }) + if err != nil { + t.Errorf("failed to marshal summary: %v", err) + } + resp := openAIResponse{Choices: []openAIChoice{{Message: openAIMessage{Content: string(body)}}}} + w.Header().Set("Content-Type", "application/json") + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + t.Errorf("failed to encode response: %v", encErr) + } + } +} + +func TestOpenAIGenerator_MissingAPIKey(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + gen := &OpenAIGenerator{} + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + _, err := gen.Generate(context.Background(), input) + if err == nil { + t.Fatal("expected error for missing API key") + } + if !strings.Contains(err.Error(), "API key") { + t.Errorf("expected API key error, got: %v", err) + } +} + +func TestOpenAIGenerator_EnvAPIKey(t *testing.T) { + srv := httptest.NewServer(openAISuccessHandler(t, "from env key", "ok")) + defer srv.Close() + + t.Setenv("OPENAI_API_KEY", "sk-env-test") + gen := &OpenAIGenerator{endpoint: srv.URL} + + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + summary, err := gen.Generate(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Intent != "from env key" { + t.Errorf("unexpected intent: %s", summary.Intent) + } +} + +func TestOpenAIGenerator_SuccessResponse(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + http.Error(w, "missing auth", http.StatusUnauthorized) + return + } + openAISuccessHandler(t, "test intent", "test outcome")(w, r) + })) + defer srv.Close() + + gen := openAITestGenerator(t, srv, "sk-test") + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Help me"}}} + summary, err := gen.Generate(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Intent != "test intent" { + t.Errorf("unexpected intent: %s", summary.Intent) + } + if summary.Outcome != "test outcome" { + t.Errorf("unexpected outcome: %s", summary.Outcome) + } +} + +func TestOpenAIGenerator_APIError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + resp := openAIResponse{Error: &openAIError{Message: "invalid API key", Type: "invalid_request_error"}} + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + t.Errorf("failed to encode response: %v", encErr) + } + })) + defer srv.Close() + + gen := openAITestGenerator(t, srv, "sk-bad") + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + _, err := gen.Generate(context.Background(), input) + if err == nil { + t.Fatal("expected error for API error response") + } + if !strings.Contains(err.Error(), "invalid API key") { + t.Errorf("expected API error message, got: %v", err) + } +} + +func TestOpenAIGenerator_NoChoices(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := openAIResponse{Choices: []openAIChoice{}} + w.Header().Set("Content-Type", "application/json") + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + t.Errorf("failed to encode response: %v", encErr) + } + })) + defer srv.Close() + + gen := openAITestGenerator(t, srv, "sk-test") + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + _, err := gen.Generate(context.Background(), input) + if err == nil { + t.Fatal("expected error for empty choices") + } + if !strings.Contains(err.Error(), "no choices") { + t.Errorf("expected 'no choices' error, got: %v", err) + } +} + +func TestOpenAIGenerator_InvalidSummaryJSON(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := openAIResponse{Choices: []openAIChoice{{Message: openAIMessage{Content: "not valid json"}}}} + w.Header().Set("Content-Type", "application/json") + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + t.Errorf("failed to encode response: %v", encErr) + } + })) + defer srv.Close() + + gen := openAITestGenerator(t, srv, "sk-test") + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + _, err := gen.Generate(context.Background(), input) + if err == nil { + t.Fatal("expected error for invalid summary JSON") + } + if !strings.Contains(err.Error(), "parse summary JSON") { + t.Errorf("expected parse error, got: %v", err) + } +} + +func TestOpenAIGenerator_DefaultModel(t *testing.T) { + t.Parallel() + var capturedModel string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req openAIRequest + if decErr := json.NewDecoder(r.Body).Decode(&req); decErr == nil { + capturedModel = req.Model + } + openAISuccessHandler(t, "ok", "ok")(w, r) + })) + defer srv.Close() + + gen := &OpenAIGenerator{APIKey: "sk-test", endpoint: srv.URL} + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + _, err := gen.Generate(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedModel != openAIDefaultModel { + t.Errorf("expected default model %s, got %s", openAIDefaultModel, capturedModel) + } +} + +func TestOpenAIGenerator_MarkdownCodeBlock(t *testing.T) { + t.Parallel() + summaryContent := "```json\n{\"intent\":\"md intent\",\"outcome\":\"md outcome\",\"learnings\":{\"repo\":[],\"code\":[],\"workflow\":[]},\"friction\":[],\"open_items\":[]}\n```" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := openAIResponse{Choices: []openAIChoice{{Message: openAIMessage{Content: summaryContent}}}} + w.Header().Set("Content-Type", "application/json") + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + t.Errorf("failed to encode response: %v", encErr) + } + })) + defer srv.Close() + + gen := openAITestGenerator(t, srv, "sk-test") + input := Input{Transcript: []Entry{{Type: EntryTypeUser, Content: "Hello"}}} + summary, err := gen.Generate(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Intent != "md intent" { + t.Errorf("unexpected intent: %s", summary.Intent) + } +} diff --git a/cmd/entire/cli/summarize/provider.go b/cmd/entire/cli/summarize/provider.go new file mode 100644 index 000000000..43243aba2 --- /dev/null +++ b/cmd/entire/cli/summarize/provider.go @@ -0,0 +1,32 @@ +package summarize + +import ( + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// ResolveGenerator creates a Generator based on the summarize provider settings. +// Falls back to ClaudeGenerator when no provider is configured or settings is nil. +func ResolveGenerator(s *settings.EntireSettings) Generator { //nolint:ireturn // factory returns interface by design + if s == nil { + return &ClaudeGenerator{} + } + model := s.SummarizeModel() + switch s.SummarizeProvider() { + case "openai": + return &OpenAIGenerator{APIKey: s.SummarizeAPIKey(), Model: model} + default: + return &ClaudeGenerator{Model: model} + } +} + +// ScopeTranscript slices a transcript to start from the given offset. +// For Gemini (JSON), the offset is a message index; for Claude (JSONL), it is a line offset. +func ScopeTranscript(transcriptBytes []byte, startOffset int, agentType agent.AgentType) []byte { + if agentType == agent.AgentTypeGemini { + return geminicli.SliceFromMessage(transcriptBytes, startOffset) + } + return transcript.SliceFromLine(transcriptBytes, startOffset) +} diff --git a/cmd/entire/cli/summarize/provider_test.go b/cmd/entire/cli/summarize/provider_test.go new file mode 100644 index 000000000..552f30fc9 --- /dev/null +++ b/cmd/entire/cli/summarize/provider_test.go @@ -0,0 +1,90 @@ +package summarize + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +func TestResolveGenerator_NilSettings(t *testing.T) { + t.Parallel() + gen := ResolveGenerator(nil) + if gen == nil { + t.Fatal("expected non-nil generator") + } + if _, ok := gen.(*ClaudeGenerator); !ok { + t.Errorf("expected *ClaudeGenerator, got %T", gen) + } +} + +func TestResolveGenerator_DefaultProvider(t *testing.T) { + t.Parallel() + s := &settings.EntireSettings{} + gen := ResolveGenerator(s) + if _, ok := gen.(*ClaudeGenerator); !ok { + t.Errorf("expected *ClaudeGenerator for empty provider, got %T", gen) + } +} + +func TestResolveGenerator_ClaudeProvider(t *testing.T) { + t.Parallel() + s := &settings.EntireSettings{ + StrategyOptions: map[string]any{ + "summarize": map[string]any{ + "provider": "claude", + "model": "opus", + }, + }, + } + gen := ResolveGenerator(s) + cg, ok := gen.(*ClaudeGenerator) + if !ok { + t.Fatalf("expected *ClaudeGenerator, got %T", gen) + } + if cg.Model != "opus" { + t.Errorf("expected model opus, got %s", cg.Model) + } +} + +func TestResolveGenerator_OpenAIProvider(t *testing.T) { + t.Parallel() + s := &settings.EntireSettings{ + StrategyOptions: map[string]any{ + "summarize": map[string]any{ + "provider": "openai", + "model": "gpt-5-mini", + "api_key": "sk-test", + }, + }, + } + gen := ResolveGenerator(s) + og, ok := gen.(*OpenAIGenerator) + if !ok { + t.Fatalf("expected *OpenAIGenerator, got %T", gen) + } + if og.Model != "gpt-5-mini" { + t.Errorf("expected model gpt-5-mini, got %s", og.Model) + } + if og.APIKey != "sk-test" { + t.Errorf("expected api_key sk-test, got %s", og.APIKey) + } +} + +func TestScopeTranscript_ClaudeLineBased(t *testing.T) { + t.Parallel() + transcript := []byte("line1\nline2\nline3\n") + scoped := ScopeTranscript(transcript, 1, agent.AgentTypeClaudeCode) + if string(scoped) != "line2\nline3\n" { + t.Errorf("unexpected scoped transcript: %q", scoped) + } +} + +func TestScopeTranscript_ZeroOffset(t *testing.T) { + t.Parallel() + transcript := []byte("line1\nline2\n") + scoped := ScopeTranscript(transcript, 0, agent.AgentTypeClaudeCode) + if string(scoped) != "line1\nline2\n" { + t.Errorf("expected full transcript, got: %q", scoped) + } +}