diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 75c31c182..c507bfc38 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -104,8 +104,9 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { } // ReadSession reads a session from Cursor's storage (JSONL transcript file). -// Note: ModifiedFiles is left empty because Cursor's transcript format does not -// contain tool_use blocks. File detection relies on git status instead. +// Note: ModifiedFiles is left empty because Cursor's transcript does not contain +// tool_use blocks for file detection. TranscriptAnalyzer extracts prompts and +// summaries; file detection relies on git status. func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { if input.SessionRef == "" { return nil, errors.New("session reference (transcript path) is required") diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 74169ce88..2e6fe2b5c 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -44,10 +44,6 @@ func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) { return data, nil } -// Note: CursorAgent does NOT implement TranscriptAnalyzer. Cursor's transcript -// format does not contain tool_use blocks that would allow extracting modified -// files. File detection relies on git status instead. - // --- Internal hook parsing functions --- // resolveTranscriptRef returns the transcript path from the hook input, or computes diff --git a/cmd/entire/cli/agent/cursor/transcript.go b/cmd/entire/cli/agent/cursor/transcript.go new file mode 100644 index 000000000..bb328a60f --- /dev/null +++ b/cmd/entire/cli/agent/cursor/transcript.go @@ -0,0 +1,113 @@ +package cursor + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/textutil" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// Compile-time interface assertion. +var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) + +// GetTranscriptPosition returns the current line count of a Cursor transcript. +// Cursor uses the same JSONL format as Claude Code, so position is the number of lines. +// Uses bufio.Reader to handle arbitrarily long lines (no size limit). +// Returns 0 if the file doesn't exist or is empty. +func (c *CursorAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from Cursor transcript location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + if len(line) > 0 { + lineCount++ // Count final line without trailing newline + } + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractPrompts extracts user prompts from the transcript starting at the given line offset. +// Cursor uses the same JSONL format as Claude Code; the shared transcript package normalizes +// "role" → "type" and strips tags. +func (c *CursorAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + lines, err := transcript.ParseFromFileAtLine(sessionRef, fromOffset) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + var prompts []string + for i := range lines { + if lines[i].Type != transcript.TypeUser { + continue + } + content := transcript.ExtractUserContent(lines[i].Message) + if content != "" { + prompts = append(prompts, textutil.StripIDEContextTags(content)) + } + } + return prompts, nil +} + +// ExtractSummary extracts the last assistant message as a session summary. +func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return "", fmt.Errorf("failed to read transcript: %w", err) + } + + lines, parseErr := transcript.ParseFromBytes(data) + if parseErr != nil { + return "", fmt.Errorf("failed to parse transcript: %w", parseErr) + } + + // Walk backward to find last assistant text block + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != transcript.TypeAssistant { + continue + } + var msg transcript.AssistantMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + for _, block := range msg.Content { + if block.Type == transcript.ContentTypeText && block.Text != "" { + return block.Text, nil + } + } + } + return "", nil +} + +// ExtractModifiedFilesFromOffset returns nil, 0, nil because Cursor transcripts +// do not contain tool_use blocks. File detection relies on git status instead. +// All call sites are expected to also use git-status-based detection. +func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]string, int, error) { + return nil, 0, nil +} diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go new file mode 100644 index 000000000..9ecb378ad --- /dev/null +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -0,0 +1,194 @@ +package cursor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface check. +var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil) + +// --- GetTranscriptPosition --- + +func TestCursorAgent_GetTranscriptPosition(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + pos, err := ag.GetTranscriptPosition(path) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 4 { + t.Errorf("GetTranscriptPosition() = %d, want 4", pos) + } +} + +func TestCursorAgent_GetTranscriptPosition_EmptyPath(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + pos, err := ag.GetTranscriptPosition("") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition() = %d, want 0", pos) + } +} + +func TestCursorAgent_GetTranscriptPosition_NonexistentFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + pos, err := ag.GetTranscriptPosition("/nonexistent/path/transcript.jsonl") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition() = %d, want 0", pos) + } +} + +// --- ExtractPrompts --- + +func TestCursorAgent_ExtractPrompts(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + prompts, err := ag.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 2 { + t.Fatalf("ExtractPrompts() returned %d prompts, want 2", len(prompts)) + } + // Verify tags are stripped + if prompts[0] != "hello" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "hello") + } + if prompts[1] != "add 'one' to a file and commit" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "add 'one' to a file and commit") + } +} + +func TestCursorAgent_ExtractPrompts_WithOffset(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + // Offset 2 skips the first user+assistant pair, leaving 1 user prompt + prompts, err := ag.ExtractPrompts(path, 2) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() returned %d prompts, want 1", len(prompts)) + } + if prompts[0] != "add 'one' to a file and commit" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "add 'one' to a file and commit") + } +} + +func TestCursorAgent_ExtractPrompts_EmptyFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.jsonl") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + prompts, err := ag.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + if len(prompts) != 0 { + t.Errorf("ExtractPrompts() returned %d prompts, want 0", len(prompts)) + } +} + +// --- ExtractSummary --- + +func TestCursorAgent_ExtractSummary(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + if summary != "Created one.txt with one and committed." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "Created one.txt with one and committed.") + } +} + +func TestCursorAgent_ExtractSummary_EmptyFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "empty.jsonl") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + summary, err := ag.ExtractSummary(path) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + if summary != "" { + t.Errorf("ExtractSummary() = %q, want empty string", summary) + } +} + +// --- ExtractModifiedFilesFromOffset --- + +func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + tmpDir := t.TempDir() + path := writeSampleTranscript(t, tmpDir) + + files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) + } + if files != nil { + t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) + } + if pos != 0 { + t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) + } +} + +func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) + } + if files != nil { + t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files) + } + if pos != 0 { + t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos) + } +} diff --git a/cmd/entire/cli/commit_message.go b/cmd/entire/cli/commit_message.go index 3d3e0f77d..8419d50ae 100644 --- a/cmd/entire/cli/commit_message.go +++ b/cmd/entire/cli/commit_message.go @@ -1,13 +1,17 @@ package cli import ( + "fmt" "strings" + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/stringutil" ) -// generateCommitMessage creates a commit message from the user's original prompt -func generateCommitMessage(originalPrompt string) string { +// generateCommitMessage creates a commit message from the user's original prompt. +// If the prompt is empty or cleans to empty, falls back to " session updates". +func generateCommitMessage(originalPrompt string, agentType types.AgentType) string { if originalPrompt != "" { cleaned := cleanPromptForCommit(originalPrompt) if cleaned != "" { @@ -15,7 +19,10 @@ func generateCommitMessage(originalPrompt string) string { } } - return "Claude Code session updates" + if agentType == "" { + agentType = agent.AgentTypeUnknown + } + return fmt.Sprintf("%s session updates", agentType) } // cleanPromptForCommit cleans up a user prompt to make it suitable as a commit message diff --git a/cmd/entire/cli/commit_message_test.go b/cmd/entire/cli/commit_message_test.go index 994bb76c9..0a86426c4 100644 --- a/cmd/entire/cli/commit_message_test.go +++ b/cmd/entire/cli/commit_message_test.go @@ -2,6 +2,9 @@ package cli import ( "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" ) func TestCleanPromptForCommit(t *testing.T) { @@ -185,47 +188,78 @@ func TestCleanPromptForCommit(t *testing.T) { func TestGenerateCommitMessage(t *testing.T) { tests := []struct { - name string - prompt string - expected string + name string + prompt string + agentType types.AgentType + expected string }{ { - name: "returns cleaned prompt", - prompt: "Can you fix the login bug?", - expected: "Fix the login bug", + name: "returns cleaned prompt", + prompt: "Can you fix the login bug?", + agentType: agent.AgentTypeClaudeCode, + expected: "Fix the login bug", + }, + { + name: "returns default for empty prompt with Claude Code", + prompt: "", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "returns default when cleaned prompt is empty", + prompt: "Can you ?", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "returns default for whitespace only prompt", + prompt: " ", + agentType: agent.AgentTypeClaudeCode, + expected: "Claude Code session updates", + }, + { + name: "handles direct command prompt", + prompt: "Add unit tests for the auth module", + agentType: agent.AgentTypeClaudeCode, + expected: "Add unit tests for the auth module", }, { - name: "returns default for empty prompt", - prompt: "", - expected: "Claude Code session updates", + name: "handles polite request", + prompt: "Please refactor the database connection handling", + agentType: agent.AgentTypeClaudeCode, + expected: "Refactor the database connection handling", }, { - name: "returns default when cleaned prompt is empty", - prompt: "Can you ?", - expected: "Claude Code session updates", + name: "returns Cursor fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeCursor, + expected: "Cursor session updates", }, { - name: "returns default for whitespace only prompt", - prompt: " ", - expected: "Claude Code session updates", + name: "returns Gemini CLI fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeGemini, + expected: "Gemini CLI session updates", }, { - name: "handles direct command prompt", - prompt: "Add unit tests for the auth module", - expected: "Add unit tests for the auth module", + name: "returns OpenCode fallback for empty prompt", + prompt: "", + agentType: agent.AgentTypeOpenCode, + expected: "OpenCode session updates", }, { - name: "handles polite request", - prompt: "Please refactor the database connection handling", - expected: "Refactor the database connection handling", + name: "returns Agent fallback for empty agent type", + prompt: "", + agentType: "", + expected: "Agent session updates", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := generateCommitMessage(tt.prompt) + result := generateCommitMessage(tt.prompt, tt.agentType) if result != tt.expected { - t.Errorf("generateCommitMessage(%q) = %q, want %q", tt.prompt, result, tt.expected) + t.Errorf("generateCommitMessage(%q, %q) = %q, want %q", tt.prompt, tt.agentType, result, tt.expected) } }) } diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index 6028dbaed..9c834c396 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -203,6 +203,70 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { } } +// TestHookRunner_SimulateStop_GitStatusMergesWithTranscript verifies that +// handleLifecycleTurnEnd always consults git status and merges those files with +// transcript-extracted files. This guards against a regression where someone makes +// git status conditional on transcript parsing failing. +// +// Scenario: +// - Transcript references file A (via tool_use block) +// - File B is a tracked file modified on disk but NOT mentioned in transcript +// - After stop, FilesTouched must contain both A and B +func TestHookRunner_SimulateStop_GitStatusMergesWithTranscript(t *testing.T) { + t.Parallel() + env := NewRepoWithCommit(t) + + // Create a tracked file that we'll modify later (not mentioned in transcript). + // It must be committed first so git status reports it as "modified" (not "untracked"). + env.WriteFile("tracked-only.txt", "original content") + env.GitAdd("tracked-only.txt") + env.GitCommit("add tracked file") + + session := env.NewSession() + + // Capture pre-prompt state + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Modify the tracked file on disk (git status will see this as modified) + env.WriteFile("tracked-only.txt", "modified content") + + // Create a file that IS mentioned in the transcript + env.WriteFile("transcript-file.txt", "created by agent") + + // Build transcript that only mentions transcript-file.txt + session.CreateTranscript("Create a file", []FileChange{ + {Path: "transcript-file.txt", Content: "created by agent"}, + }) + + // Simulate stop + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Verify FilesTouched contains BOTH files + state, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("failed to get session state: %v", err) + } + if state == nil { + t.Fatal("session state should exist after checkpoint") + } + + filesTouched := make(map[string]bool) + for _, f := range state.FilesTouched { + filesTouched[f] = true + } + + if !filesTouched["transcript-file.txt"] { + t.Errorf("FilesTouched should contain transcript-extracted file 'transcript-file.txt', got %v", state.FilesTouched) + } + if !filesTouched["tracked-only.txt"] { + t.Errorf("FilesTouched should contain git-status-detected file 'tracked-only.txt', got %v", state.FilesTouched) + } +} + // TestUserPromptSubmit_ReinstallsOverwrittenHooks verifies that EnsureSetup is called // during user-prompt-submit (start of turn) and reinstalls hooks that were overwritten // by third-party tools like lefthook. This ensures hooks are in place before any diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 398882cef..ec80ac392 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -285,7 +285,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev if len(allPrompts) > 0 { lastPrompt = allPrompts[len(allPrompts)-1] } - commitMessage := generateCommitMessage(lastPrompt) + commitMessage := generateCommitMessage(lastPrompt, ag.Type()) logging.Debug(logCtx, "using commit message", slog.Int("message_length", len(commitMessage)))