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
46 changes: 46 additions & 0 deletions cmd/entire/cli/agent/claudecode/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,49 @@ func CalculateTotalTokenUsage(transcriptPath string, startLine int, subagentsDir

return mainUsage, nil
}

// ExtractAllModifiedFiles extracts files modified by both the main agent and
// any subagents spawned via the Task tool. It parses the main transcript from
// startLine, collects modified files from the main agent, then reads each
// subagent's transcript from subagentsDir to collect their modified files too.
// The result is a deduplicated list of all modified file paths.
func ExtractAllModifiedFiles(transcriptPath string, startLine int, subagentsDir string) ([]string, error) {
if transcriptPath == "" {
return nil, nil
}

// Parse main transcript once
transcript, err := parseTranscriptFromLine(transcriptPath, startLine)
if err != nil {
return nil, fmt.Errorf("failed to parse transcript: %w", err)
}

// Collect modified files from main agent
fileSet := make(map[string]bool)
var files []string
for _, f := range ExtractModifiedFiles(transcript) {
if !fileSet[f] {
fileSet[f] = true
files = append(files, f)
}
}

// Find spawned subagents and collect their modified files
agentIDs := ExtractSpawnedAgentIDs(transcript)
for agentID := range agentIDs {
agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID))
agentTranscript, err := parseTranscriptFromLine(agentPath, 0)
if err != nil {
// Subagent transcript may not exist yet or may have been cleaned up
continue
}
for _, f := range ExtractModifiedFiles(agentTranscript) {
if !fileSet[f] {
fileSet[f] = true
files = append(files, f)
}
}
}

return files, nil
}
255 changes: 255 additions & 0 deletions cmd/entire/cli/agent/claudecode/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package claudecode
import (
"encoding/json"
"os"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/transcript"
Expand Down Expand Up @@ -618,3 +619,257 @@ func TestCalculateTotalTokenUsage_PerCheckpoint(t *testing.T) {
t.Errorf("From line 4: got APICallCount=%d, want 1", usage3.APICallCount)
}
}

// writeJSONLFile is a test helper that writes JSONL transcript lines to a file.
func writeJSONLFile(t *testing.T, path string, lines ...string) {
t.Helper()
var buf strings.Builder
for _, line := range lines {
buf.WriteString(line)
buf.WriteByte('\n')
}
if err := os.WriteFile(path, []byte(buf.String()), 0o600); err != nil {
t.Fatalf("failed to write JSONL file %s: %v", path, err)
}
}

// makeWriteToolLine returns a JSONL assistant line with a Write tool_use for the given file.
func makeWriteToolLine(t *testing.T, uuid, filePath string) string {
t.Helper()
data := mustMarshal(t, map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "tool_use",
"id": "toolu_" + uuid,
"name": "Write",
"input": map[string]string{"file_path": filePath},
},
},
})
line := mustMarshal(t, map[string]interface{}{
"type": "assistant",
"uuid": uuid,
"message": json.RawMessage(data),
})
return string(line)
}

// makeEditToolLine returns a JSONL assistant line with an Edit tool_use for the given file.
func makeEditToolLine(t *testing.T, uuid, filePath string) string {
t.Helper()
data := mustMarshal(t, map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "tool_use",
"id": "toolu_" + uuid,
"name": "Edit",
"input": map[string]string{"file_path": filePath},
},
},
})
line := mustMarshal(t, map[string]interface{}{
"type": "assistant",
"uuid": uuid,
"message": json.RawMessage(data),
})
return string(line)
}

// makeTaskToolUseLine returns a JSONL assistant line with a Task tool_use (spawning a subagent).
func makeTaskToolUseLine(t *testing.T, uuid, toolUseID string) string {
t.Helper()
data := mustMarshal(t, map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "tool_use",
"id": toolUseID,
"name": "Task",
"input": map[string]string{"prompt": "do something"},
},
},
})
line := mustMarshal(t, map[string]interface{}{
"type": "assistant",
"uuid": uuid,
"message": json.RawMessage(data),
})
return string(line)
}

// makeTaskResultLine returns a JSONL user line with a tool_result containing agentId.
func makeTaskResultLine(t *testing.T, uuid, toolUseID, agentID string) string {
t.Helper()
data := mustMarshal(t, map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "tool_result",
"tool_use_id": toolUseID,
"content": "agentId: " + agentID,
},
},
})
line := mustMarshal(t, map[string]interface{}{
"type": "user",
"uuid": uuid,
"message": json.RawMessage(data),
})
return string(line)
}

func TestExtractAllModifiedFiles_IncludesSubagentFiles(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
transcriptPath := tmpDir + "/transcript.jsonl"
subagentsDir := tmpDir + "/tasks/toolu_task1"

if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
t.Fatalf("failed to create subagents dir: %v", err)
}

// Main transcript: Write to main.go + Task call spawning subagent "sub1"
writeJSONLFile(t, transcriptPath,
makeWriteToolLine(t, "a1", "/repo/main.go"),
makeTaskToolUseLine(t, "a2", "toolu_task1"),
makeTaskResultLine(t, "u1", "toolu_task1", "sub1"),
)

// Subagent transcript: Write to helper.go + Edit to utils.go
writeJSONLFile(t, subagentsDir+"/agent-sub1.jsonl",
makeWriteToolLine(t, "sa1", "/repo/helper.go"),
makeEditToolLine(t, "sa2", "/repo/utils.go"),
)

files, err := ExtractAllModifiedFiles(transcriptPath, 0, subagentsDir)
if err != nil {
t.Fatalf("ExtractAllModifiedFiles() error: %v", err)
}

if len(files) != 3 {
t.Errorf("expected 3 files, got %d: %v", len(files), files)
}

wantFiles := map[string]bool{
"/repo/main.go": true,
"/repo/helper.go": true,
"/repo/utils.go": true,
}
for _, f := range files {
if !wantFiles[f] {
t.Errorf("unexpected file %q in result", f)
}
delete(wantFiles, f)
}
for f := range wantFiles {
t.Errorf("missing expected file %q", f)
}
}

func TestExtractAllModifiedFiles_DeduplicatesAcrossAgents(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
transcriptPath := tmpDir + "/transcript.jsonl"
subagentsDir := tmpDir + "/tasks/toolu_task1"

if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
t.Fatalf("failed to create subagents dir: %v", err)
}

// Main transcript: Write to shared.go + Task call
writeJSONLFile(t, transcriptPath,
makeWriteToolLine(t, "a1", "/repo/shared.go"),
makeTaskToolUseLine(t, "a2", "toolu_task1"),
makeTaskResultLine(t, "u1", "toolu_task1", "sub1"),
)

// Subagent transcript: Also modifies shared.go (same file as main)
writeJSONLFile(t, subagentsDir+"/agent-sub1.jsonl",
makeEditToolLine(t, "sa1", "/repo/shared.go"),
)

files, err := ExtractAllModifiedFiles(transcriptPath, 0, subagentsDir)
if err != nil {
t.Fatalf("ExtractAllModifiedFiles() error: %v", err)
}

if len(files) != 1 {
t.Errorf("expected 1 file (deduplicated), got %d: %v", len(files), files)
}
if len(files) > 0 && files[0] != "/repo/shared.go" {
t.Errorf("expected /repo/shared.go, got %q", files[0])
}
}

func TestExtractAllModifiedFiles_NoSubagents(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
transcriptPath := tmpDir + "/transcript.jsonl"

// Main transcript: Write to a file, no Task calls
writeJSONLFile(t, transcriptPath,
makeWriteToolLine(t, "a1", "/repo/solo.go"),
)

files, err := ExtractAllModifiedFiles(transcriptPath, 0, tmpDir+"/nonexistent")
if err != nil {
t.Fatalf("ExtractAllModifiedFiles() error: %v", err)
}

if len(files) != 1 {
t.Errorf("expected 1 file, got %d: %v", len(files), files)
}
if len(files) > 0 && files[0] != "/repo/solo.go" {
t.Errorf("expected /repo/solo.go, got %q", files[0])
}
}

func TestExtractAllModifiedFiles_SubagentOnlyChanges(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
transcriptPath := tmpDir + "/transcript.jsonl"
subagentsDir := tmpDir + "/tasks/toolu_task1"

if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
t.Fatalf("failed to create subagents dir: %v", err)
}

// Main transcript: ONLY a Task call, no direct file modifications
// This is the key bug scenario - if we only look at the main transcript,
// we miss all the subagent's file changes entirely.
writeJSONLFile(t, transcriptPath,
makeTaskToolUseLine(t, "a1", "toolu_task1"),
makeTaskResultLine(t, "u1", "toolu_task1", "sub1"),
)

// Subagent transcript: Write to two files
writeJSONLFile(t, subagentsDir+"/agent-sub1.jsonl",
makeWriteToolLine(t, "sa1", "/repo/subagent_file1.go"),
makeWriteToolLine(t, "sa2", "/repo/subagent_file2.go"),
)

files, err := ExtractAllModifiedFiles(transcriptPath, 0, subagentsDir)
if err != nil {
t.Fatalf("ExtractAllModifiedFiles() error: %v", err)
}

if len(files) != 2 {
t.Errorf("expected 2 files from subagent, got %d: %v", len(files), files)
}

wantFiles := map[string]bool{
"/repo/subagent_file1.go": true,
"/repo/subagent_file2.go": true,
}
for _, f := range files {
if !wantFiles[f] {
t.Errorf("unexpected file %q in result", f)
}
delete(wantFiles, f)
}
for f := range wantFiles {
t.Errorf("missing expected file %q", f)
}
}
19 changes: 12 additions & 7 deletions cmd/entire/cli/hooks_claudecode_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,15 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba
}
fmt.Fprintf(os.Stderr, "Extracted summary to: %s\n", sessionDir+"/"+paths.SummaryFileName)

// Get modified files from transcript
modifiedFiles := extractModifiedFiles(transcript)
// Get modified files from transcript (main agent + subagents).
// Subagent transcripts live in <transcriptDir>/<modelSessionID>/subagents/
subagentsDir := filepath.Join(filepath.Dir(transcriptPath), input.SessionID, "subagents")
modifiedFiles, subagentErr := claudecode.ExtractAllModifiedFiles(transcriptPath, transcriptOffset, subagentsDir)
if subagentErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to extract modified files with subagents: %v\n", subagentErr)
// Fall back to main transcript only
modifiedFiles = extractModifiedFiles(transcript)
}

// Generate commit message from last user prompt
lastPrompt := ""
Expand Down Expand Up @@ -321,11 +328,9 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba
// Calculate token usage for this checkpoint (Claude Code specific)
var tokenUsage *agent.TokenUsage
if transcriptPath != "" {
// Subagents are stored in a subagents/ directory next to the main transcript
subagentsDir := filepath.Join(filepath.Dir(transcriptPath), sessionID, "subagents")
usage, err := claudecode.CalculateTotalTokenUsage(transcriptPath, transcriptLinesAtStart, subagentsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to calculate token usage: %v\n", err)
usage, tokenErr := claudecode.CalculateTotalTokenUsage(transcriptPath, transcriptLinesAtStart, subagentsDir)
if tokenErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to calculate token usage: %v\n", tokenErr)
} else {
tokenUsage = usage
}
Expand Down
Loading