diff --git a/CLAUDE.md b/CLAUDE.md index 7ffdba5c0..a5ff58d8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ E2E tests: - Located in `cmd/entire/cli/e2e_test/` - Test real agent interactions (Claude Code creating files, committing, etc.) - Validate checkpoint scenarios documented in `docs/architecture/checkpoint-scenarios.md` -- Support multiple agents via `E2E_AGENT` env var (currently `claude-code`, `gemini-cli` stub) +- Support multiple agents via `E2E_AGENT` env var (currently `claude-code`, `gemini-cli` stub, `factoryai-droid` stub) **Environment variables:** - `E2E_AGENT` - Agent to test with (default: `claude-code`) diff --git a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go new file mode 100644 index 000000000..4162a2046 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go @@ -0,0 +1,144 @@ +// Package factoryaidroid implements the Agent interface for Factory AI Droid. +package factoryaidroid + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// nonAlphanumericRegex matches any non-alphanumeric character for path sanitization. +// Same pattern as claudecode.SanitizePathForClaude — duplicated to avoid cross-package dependency. +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +func sanitizeRepoPath(path string) string { + return nonAlphanumericRegex.ReplaceAllString(path, "-") +} + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameFactoryAIDroid, NewFactoryAIDroidAgent) +} + +// FactoryAIDroidAgent implements the agent.Agent interface for Factory AI Droid. +// +//nolint:revive // FactoryAIDroidAgent is clearer than Agent in this context +type FactoryAIDroidAgent struct{} + +// NewFactoryAIDroidAgent creates a new Factory AI Droid agent instance. +func NewFactoryAIDroidAgent() agent.Agent { + return &FactoryAIDroidAgent{} +} + +// Name returns the agent registry key. +func (f *FactoryAIDroidAgent) Name() agent.AgentName { return agent.AgentNameFactoryAIDroid } + +// Type returns the agent type identifier. +func (f *FactoryAIDroidAgent) Type() agent.AgentType { return agent.AgentTypeFactoryAIDroid } + +// Description returns a human-readable description. +func (f *FactoryAIDroidAgent) Description() string { + return "Factory AI Droid - agent-native development platform" +} + +// IsPreview returns true as Factory AI Droid integration is in preview. +func (f *FactoryAIDroidAgent) IsPreview() bool { return true } + +// ProtectedDirs returns directories that Factory AI Droid uses for config/state. +func (f *FactoryAIDroidAgent) ProtectedDirs() []string { return []string{".factory"} } + +// DetectPresence checks if Factory AI Droid is configured in the repository. +func (f *FactoryAIDroidAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + if _, err := os.Stat(filepath.Join(repoRoot, ".factory")); err == nil { + return true, nil + } + return false, nil +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (f *FactoryAIDroidAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + return data, nil +} + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (f *FactoryAIDroidAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +func (f *FactoryAIDroidAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// GetHookConfigPath returns the path to Factory AI Droid's hook config file. +func (f *FactoryAIDroidAgent) GetHookConfigPath() string { return ".factory/settings.json" } + +// SupportsHooks returns true as Factory AI Droid supports lifecycle hooks. +func (f *FactoryAIDroidAgent) SupportsHooks() bool { return true } + +// ParseHookInput parses Factory AI Droid hook input from stdin. +func (f *FactoryAIDroidAgent) ParseHookInput(_ agent.HookType, r io.Reader) (*agent.HookInput, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](r) + if err != nil { + return nil, err + } + return &agent.HookInput{ + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + }, nil +} + +// GetSessionID extracts the session ID from hook input. +func (f *FactoryAIDroidAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } + +// GetSessionDir returns the directory where Factory AI Droid stores session transcripts. +// Path: ~/.factory/sessions// +func (f *FactoryAIDroidAgent) GetSessionDir(repoPath string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_DROID_PROJECT_DIR"); override != "" { + return override, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + projectDir := sanitizeRepoPath(repoPath) + return filepath.Join(homeDir, ".factory", "sessions", projectDir), nil +} + +// ResolveSessionFile returns the path to a Factory AI Droid session file. +func (f *FactoryAIDroidAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ReadSession is not implemented for Factory AI Droid. +func (f *FactoryAIDroidAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) { + return nil, errors.New("not implemented") +} + +// WriteSession is not implemented for Factory AI Droid. +func (f *FactoryAIDroidAgent) WriteSession(_ *agent.AgentSession) error { + return errors.New("not implemented") +} + +// FormatResumeCommand returns the command to resume a Factory AI Droid session. +func (f *FactoryAIDroidAgent) FormatResumeCommand(sessionID string) string { + return "droid --session-id " + sessionID +} diff --git a/cmd/entire/cli/agent/factoryaidroid/factoryaidroid_test.go b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid_test.go new file mode 100644 index 000000000..2f15b9bb6 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid_test.go @@ -0,0 +1,368 @@ +package factoryaidroid + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestNewFactoryAIDroidAgent(t *testing.T) { + t.Parallel() + ag := NewFactoryAIDroidAgent() + if ag == nil { + t.Fatal("NewFactoryAIDroidAgent() returned nil") + } + if _, ok := ag.(*FactoryAIDroidAgent); !ok { + t.Fatal("NewFactoryAIDroidAgent() didn't return *FactoryAIDroidAgent") + } +} + +func TestName(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + if name := ag.Name(); name != agent.AgentNameFactoryAIDroid { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameFactoryAIDroid) + } +} + +func TestType(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + if tp := ag.Type(); tp != agent.AgentTypeFactoryAIDroid { + t.Errorf("Type() = %q, want %q", tp, agent.AgentTypeFactoryAIDroid) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".factory" { + t.Errorf("ProtectedDirs() = %v, want [.factory]", dirs) + } +} + +func TestGetHookConfigPath(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + path := ag.GetHookConfigPath() + if path != ".factory/settings.json" { + t.Errorf("GetHookConfigPath() = %q, want .factory/settings.json", path) + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } +} + +// TestDetectPresence uses t.Chdir so it cannot be parallel. +func TestDetectPresence(t *testing.T) { + t.Run("factory directory exists", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.Mkdir(".factory", 0o755); err != nil { + t.Fatalf("failed to create .factory: %v", err) + } + + ag := &FactoryAIDroidAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("no factory directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &FactoryAIDroidAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) +} + +// --- Transcript tests --- + +func TestReadTranscript(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "transcript.jsonl") + content := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"}` + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + ag := &FactoryAIDroidAgent{} + data, err := ag.ReadTranscript(file) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + if string(data) != content { + t.Errorf("ReadTranscript() = %q, want %q", string(data), content) + } +} + +func TestReadTranscript_MissingFile(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") + if err == nil { + t.Error("ReadTranscript() should error on missing file") + } +} + +func TestChunkTranscript_SmallContent(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + content := []byte(`{"role":"user","content":"hello"}`) + + chunks, err := ag.ChunkTranscript(content, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 1 { + t.Errorf("Expected 1 chunk, got %d", len(chunks)) + } +} + +func TestChunkTranscript_LargeContent(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + + // Build multi-line JSONL that exceeds a small maxSize + var lines []string + for i := range 50 { + lines = append(lines, fmt.Sprintf(`{"role":"user","content":"message %d %s"}`, i, strings.Repeat("x", 200))) + } + content := []byte(strings.Join(lines, "\n")) + + maxSize := 2000 + chunks, err := ag.ChunkTranscript(content, maxSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) < 2 { + t.Errorf("Expected at least 2 chunks for large content, got %d", len(chunks)) + } + + // Verify each chunk is valid JSONL (each line is valid JSON) + for i, chunk := range chunks { + chunkLines := strings.Split(string(chunk), "\n") + for j, line := range chunkLines { + if line == "" { + continue + } + if line[0] != '{' { + t.Errorf("Chunk %d, line %d doesn't look like JSON: %q", i, j, line[:min(len(line), 40)]) + } + } + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + + original := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi there"} +{"role":"user","content":"thanks"}` + + chunks, err := ag.ChunkTranscript([]byte(original), 60) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if string(reassembled) != original { + t.Errorf("Round-trip mismatch:\n got: %q\nwant: %q", string(reassembled), original) + } +} + +func TestReassembleTranscript_SingleChunk(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + + chunk := []byte(`{"role":"user","content":"hello"}`) + result, err := ag.ReassembleTranscript([][]byte{chunk}) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + if string(result) != string(chunk) { + t.Errorf("ReassembleTranscript() = %q, want %q", string(result), string(chunk)) + } +} + +func TestReassembleTranscript_MultipleChunks(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + + chunk1 := []byte(`{"role":"user","content":"hello"}`) + chunk2 := []byte(`{"role":"assistant","content":"hi"}`) + + result, err := ag.ReassembleTranscript([][]byte{chunk1, chunk2}) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + expected := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"}` + if string(result) != expected { + t.Errorf("ReassembleTranscript() = %q, want %q", string(result), expected) + } +} + +// --- ParseHookInput tests --- + +func TestParseHookInput_Valid(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + input := `{"session_id":"sess-abc","transcript_path":"/tmp/transcript.jsonl"}` + + result, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + if result.SessionID != "sess-abc" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "sess-abc") + } + if result.SessionRef != "/tmp/transcript.jsonl" { + t.Errorf("SessionRef = %q, want %q", result.SessionRef, "/tmp/transcript.jsonl") + } +} + +func TestParseHookInput_Empty(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("")) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader("not json")) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +// --- Session stub tests --- + +func TestGetSessionID(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + input := &agent.HookInput{SessionID: "test-session-123"} + + id := ag.GetSessionID(input) + if id != "test-session-123" { + t.Errorf("GetSessionID() = %q, want %q", id, "test-session-123") + } +} + +func TestGetSessionDir(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + + dir, err := ag.GetSessionDir("/Users/alisha/Projects/test-repos/factoryai-droid") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + + expected := filepath.Join(homeDir, ".factory", "sessions", "-Users-alisha-Projects-test-repos-factoryai-droid") + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q", dir, expected) + } +} + +// TestGetSessionDir_EnvOverride cannot use t.Parallel() due to t.Setenv. +func TestGetSessionDir_EnvOverride(t *testing.T) { + ag := &FactoryAIDroidAgent{} + override := "/tmp/test-droid-sessions" + t.Setenv("ENTIRE_TEST_DROID_PROJECT_DIR", override) + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != override { + t.Errorf("GetSessionDir() = %q, want %q (env override)", dir, override) + } +} + +func TestReadSession(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ReadSession(&agent.HookInput{SessionID: "test"}) + if err == nil { + t.Error("ReadSession() should return error (not implemented)") + } +} + +func TestWriteSession(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + err := ag.WriteSession(&agent.AgentSession{}) + if err == nil { + t.Error("WriteSession() should return error (not implemented)") + } +} + +// --- Other methods --- + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + result := ag.ResolveSessionFile("/sessions", "abc-123") + expected := filepath.Join("/sessions", "abc-123.jsonl") + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + cmd := ag.FormatResumeCommand("sess-456") + expected := "droid --session-id sess-456" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} diff --git a/cmd/entire/cli/agent/factoryaidroid/hooks.go b/cmd/entire/cli/agent/factoryaidroid/hooks.go new file mode 100644 index 000000000..5fa754cda --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/hooks.go @@ -0,0 +1,525 @@ +package factoryaidroid + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure FactoryAIDroidAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*FactoryAIDroidAgent)(nil) + _ agent.HookHandler = (*FactoryAIDroidAgent)(nil) +) + +// Factory AI Droid hook names - these become subcommands under `entire hooks factoryai-droid` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameStop = "stop" + HookNameUserPromptSubmit = "user-prompt-submit" + HookNamePreToolUse = "pre-tool-use" + HookNamePostToolUse = "post-tool-use" + HookNameSubagentStop = "subagent-stop" + HookNamePreCompact = "pre-compact" + HookNameNotification = "notification" +) + +// FactorySettingsFileName is the settings file used by Factory AI Droid. +// This is Factory-specific and not shared with other agents. +const FactorySettingsFileName = "settings.json" + +// metadataDenyRule blocks Factory Droid from reading Entire session metadata +const metadataDenyRule = "Read(./.entire/metadata/**)" + +// GetHookNames returns the hook verbs Factory AI Droid supports. +// These become subcommands: entire hooks factoryai-droid +func (f *FactoryAIDroidAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNameUserPromptSubmit, + HookNamePreToolUse, + HookNamePostToolUse, + HookNameSubagentStop, + HookNamePreCompact, + HookNameNotification, + } +} + +// entireHookPrefixes are command prefixes that identify Entire hooks (both old and new formats) +var entireHookPrefixes = []string{ + "entire ", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go ", +} + +// InstallHooks installs Factory AI Droid hooks in .factory/settings.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (f *FactoryAIDroidAgent) InstallHooks(localDev bool, force bool) (int, error) { + // Use repo root instead of CWD to find .factory directory + // This ensures hooks are installed correctly when run from a subdirectory + repoRoot, err := paths.RepoRoot() + if err != nil { + // Fallback to CWD if not in a git repo (e.g., during tests) + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + settingsPath := filepath.Join(repoRoot, ".factory", FactorySettingsFileName) + + // Read existing settings if they exist + var rawSettings map[string]json.RawMessage + + // rawHooks preserves unknown hook types + var rawHooks map[string]json.RawMessage + + // rawPermissions preserves unknown permission fields (e.g., "ask") + var rawPermissions map[string]json.RawMessage + + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from cwd + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawSettings); err != nil { + return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) + } + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return 0, fmt.Errorf("failed to parse hooks in settings.json: %w", err) + } + } + if permRaw, ok := rawSettings["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + return 0, fmt.Errorf("failed to parse permissions in settings.json: %w", err) + } + } + } else { + rawSettings = make(map[string]json.RawMessage) + } + + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + if rawPermissions == nil { + rawPermissions = make(map[string]json.RawMessage) + } + + // Parse only the hook types we need to modify + var sessionStart, sessionEnd, stop, userPromptSubmit, preToolUse, postToolUse, preCompact []FactoryHookMatcher + parseHookType(rawHooks, "SessionStart", &sessionStart) + parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parseHookType(rawHooks, "Stop", &stop) + parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parseHookType(rawHooks, "PreToolUse", &preToolUse) + parseHookType(rawHooks, "PostToolUse", &postToolUse) + parseHookType(rawHooks, "PreCompact", &preCompact) + + // If force is true, remove all existing Entire hooks first + if force { + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + stop = removeEntireHooks(stop) + userPromptSubmit = removeEntireHooks(userPromptSubmit) + preToolUse = removeEntireHooksFromMatchers(preToolUse) + postToolUse = removeEntireHooksFromMatchers(postToolUse) + preCompact = removeEntireHooks(preCompact) + } + + // Define hook commands + var sessionStartCmd, sessionEndCmd, stopCmd, userPromptSubmitCmd, preTaskCmd, postTaskCmd, preCompactCmd string + if localDev { + sessionStartCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid session-start" + sessionEndCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid session-end" + stopCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid stop" + userPromptSubmitCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid user-prompt-submit" + preTaskCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid pre-tool-use" + postTaskCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid post-tool-use" + preCompactCmd = "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid pre-compact" + } else { + sessionStartCmd = "entire hooks factoryai-droid session-start" + sessionEndCmd = "entire hooks factoryai-droid session-end" + stopCmd = "entire hooks factoryai-droid stop" + userPromptSubmitCmd = "entire hooks factoryai-droid user-prompt-submit" + preTaskCmd = "entire hooks factoryai-droid pre-tool-use" + postTaskCmd = "entire hooks factoryai-droid post-tool-use" + preCompactCmd = "entire hooks factoryai-droid pre-compact" + } + + count := 0 + + // Add hooks if they don't exist + if !hookCommandExists(sessionStart, sessionStartCmd) { + sessionStart = addHookToMatcher(sessionStart, "", sessionStartCmd) + count++ + } + if !hookCommandExists(sessionEnd, sessionEndCmd) { + sessionEnd = addHookToMatcher(sessionEnd, "", sessionEndCmd) + count++ + } + if !hookCommandExists(stop, stopCmd) { + stop = addHookToMatcher(stop, "", stopCmd) + count++ + } + if !hookCommandExists(userPromptSubmit, userPromptSubmitCmd) { + userPromptSubmit = addHookToMatcher(userPromptSubmit, "", userPromptSubmitCmd) + count++ + } + if !hookCommandExistsWithMatcher(preToolUse, "Task", preTaskCmd) { + preToolUse = addHookToMatcher(preToolUse, "Task", preTaskCmd) + count++ + } + if !hookCommandExistsWithMatcher(postToolUse, "Task", postTaskCmd) { + postToolUse = addHookToMatcher(postToolUse, "Task", postTaskCmd) + count++ + } + if !hookCommandExists(preCompact, preCompactCmd) { + preCompact = addHookToMatcher(preCompact, "", preCompactCmd) + count++ + } + + // Add permissions.deny rule if not present + permissionsChanged := false + var denyRules []string + if denyRaw, ok := rawPermissions["deny"]; ok { + if err := json.Unmarshal(denyRaw, &denyRules); err != nil { + return 0, fmt.Errorf("failed to parse permissions.deny in settings.json: %w", err) + } + } + if !slices.Contains(denyRules, metadataDenyRule) { + denyRules = append(denyRules, metadataDenyRule) + denyJSON, err := json.Marshal(denyRules) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions.deny: %w", err) + } + rawPermissions["deny"] = denyJSON + permissionsChanged = true + } + + if count == 0 && !permissionsChanged { + return 0, nil // All hooks and permissions already installed + } + + // Marshal modified hook types back to rawHooks + marshalHookType(rawHooks, "SessionStart", sessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd) + marshalHookType(rawHooks, "Stop", stop) + marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit) + marshalHookType(rawHooks, "PreToolUse", preToolUse) + marshalHookType(rawHooks, "PostToolUse", postToolUse) + marshalHookType(rawHooks, "PreCompact", preCompact) + + // Marshal hooks and update raw settings + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + + // Marshal permissions and update raw settings + permJSON, err := json.Marshal(rawPermissions) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions: %w", err) + } + rawSettings["permissions"] = permJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .factory directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write settings.json: %w", err) + } + + return count, nil +} + +// parseHookType parses a specific hook type from rawHooks into the target slice. +// Silently ignores parse errors (leaves target unchanged). +func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]FactoryHookMatcher) { + if data, ok := rawHooks[hookType]; ok { + //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty + json.Unmarshal(data, target) + } +} + +// marshalHookType marshals a hook type back to rawHooks. +// If the slice is empty, removes the key from rawHooks. +func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, matchers []FactoryHookMatcher) { + if len(matchers) == 0 { + delete(rawHooks, hookType) + return + } + data, err := json.Marshal(matchers) + if err != nil { + return // Silently ignore marshal errors (shouldn't happen) + } + rawHooks[hookType] = data +} + +// UninstallHooks removes Entire hooks from Factory AI Droid settings. +func (f *FactoryAIDroidAgent) UninstallHooks() error { + // Use repo root to find .factory directory when run from a subdirectory + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + settingsPath := filepath.Join(repoRoot, ".factory", FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No settings file means nothing to uninstall + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + // rawHooks preserves unknown hook types + var rawHooks map[string]json.RawMessage + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse only the hook types we need to modify + var sessionStart, sessionEnd, stop, userPromptSubmit, preToolUse, postToolUse, preCompact []FactoryHookMatcher + parseHookType(rawHooks, "SessionStart", &sessionStart) + parseHookType(rawHooks, "SessionEnd", &sessionEnd) + parseHookType(rawHooks, "Stop", &stop) + parseHookType(rawHooks, "UserPromptSubmit", &userPromptSubmit) + parseHookType(rawHooks, "PreToolUse", &preToolUse) + parseHookType(rawHooks, "PostToolUse", &postToolUse) + parseHookType(rawHooks, "PreCompact", &preCompact) + + // Remove Entire hooks from all hook types + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + stop = removeEntireHooks(stop) + userPromptSubmit = removeEntireHooks(userPromptSubmit) + preToolUse = removeEntireHooksFromMatchers(preToolUse) + postToolUse = removeEntireHooksFromMatchers(postToolUse) + preCompact = removeEntireHooks(preCompact) + + // Marshal modified hook types back to rawHooks + marshalHookType(rawHooks, "SessionStart", sessionStart) + marshalHookType(rawHooks, "SessionEnd", sessionEnd) + marshalHookType(rawHooks, "Stop", stop) + marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit) + marshalHookType(rawHooks, "PreToolUse", preToolUse) + marshalHookType(rawHooks, "PostToolUse", postToolUse) + marshalHookType(rawHooks, "PreCompact", preCompact) + + // Also remove the metadata deny rule from permissions + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawSettings["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + // If parsing fails, just skip permissions cleanup + rawPermissions = nil + } + } + + if rawPermissions != nil { + if denyRaw, ok := rawPermissions["deny"]; ok { + var denyRules []string + if err := json.Unmarshal(denyRaw, &denyRules); err == nil { + // Filter out the metadata deny rule + filteredRules := make([]string, 0, len(denyRules)) + for _, rule := range denyRules { + if rule != metadataDenyRule { + filteredRules = append(filteredRules, rule) + } + } + if len(filteredRules) > 0 { + denyJSON, err := json.Marshal(filteredRules) + if err == nil { + rawPermissions["deny"] = denyJSON + } + } else { + // Remove empty deny array + delete(rawPermissions, "deny") + } + } + } + + // If permissions is empty, remove it entirely + if len(rawPermissions) > 0 { + permJSON, err := json.Marshal(rawPermissions) + if err == nil { + rawSettings["permissions"] = permJSON + } + } else { + delete(rawSettings, "permissions") + } + } + + // Marshal hooks back (preserving unknown hook types) + if len(rawHooks) > 0 { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + } else { + delete(rawSettings, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (f *FactoryAIDroidAgent) AreHooksInstalled() bool { + // Use repo root to find .factory directory when run from a subdirectory + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + settingsPath := filepath.Join(repoRoot, ".factory", FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var settings FactorySettings + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + + // Check for at least one of our hooks (new or old format) + return hookCommandExists(settings.Hooks.Stop, "entire hooks factoryai-droid stop") || + hookCommandExists(settings.Hooks.Stop, "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid stop") +} + +// GetSupportedHooks returns the hook types Factory AI Droid supports. +func (f *FactoryAIDroidAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookUserPromptSubmit, + agent.HookStop, + agent.HookPreToolUse, + agent.HookPostToolUse, + } +} + +// Helper functions for hook management + +func hookCommandExists(matchers []FactoryHookMatcher, command string) bool { + for _, matcher := range matchers { + for _, hook := range matcher.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func hookCommandExistsWithMatcher(matchers []FactoryHookMatcher, matcherName, command string) bool { + for _, matcher := range matchers { + if matcher.Matcher == matcherName { + for _, hook := range matcher.Hooks { + if hook.Command == command { + return true + } + } + } + } + return false +} + +func addHookToMatcher(matchers []FactoryHookMatcher, matcherName, command string) []FactoryHookMatcher { + entry := FactoryHookEntry{ + Type: "command", + Command: command, + } + + // If no matcher name, add to a matcher with empty string + if matcherName == "" { + for i, matcher := range matchers { + if matcher.Matcher == "" { + matchers[i].Hooks = append(matchers[i].Hooks, entry) + return matchers + } + } + return append(matchers, FactoryHookMatcher{ + Matcher: "", + Hooks: []FactoryHookEntry{entry}, + }) + } + + // Find or create matcher with the given name + for i, matcher := range matchers { + if matcher.Matcher == matcherName { + matchers[i].Hooks = append(matchers[i].Hooks, entry) + return matchers + } + } + + return append(matchers, FactoryHookMatcher{ + Matcher: matcherName, + Hooks: []FactoryHookEntry{entry}, + }) +} + +// isEntireHook checks if a command is an Entire hook (old or new format) +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +// removeEntireHooks removes all Entire hooks from a list of matchers (for simple hooks like Stop) +func removeEntireHooks(matchers []FactoryHookMatcher) []FactoryHookMatcher { + result := make([]FactoryHookMatcher, 0, len(matchers)) + for _, matcher := range matchers { + filteredHooks := make([]FactoryHookEntry, 0, len(matcher.Hooks)) + for _, hook := range matcher.Hooks { + if !isEntireHook(hook.Command) { + filteredHooks = append(filteredHooks, hook) + } + } + // Only keep the matcher if it has hooks remaining + if len(filteredHooks) > 0 { + matcher.Hooks = filteredHooks + result = append(result, matcher) + } + } + return result +} + +// removeEntireHooksFromMatchers removes Entire hooks from tool-use matchers (PreToolUse, PostToolUse) +// This handles the nested structure where hooks are grouped by tool matcher (e.g., "Task") +func removeEntireHooksFromMatchers(matchers []FactoryHookMatcher) []FactoryHookMatcher { + // Same logic as removeEntireHooks - both work on the same structure + return removeEntireHooks(matchers) +} diff --git a/cmd/entire/cli/agent/factoryaidroid/hooks_test.go b/cmd/entire/cli/agent/factoryaidroid/hooks_test.go new file mode 100644 index 000000000..966c1f709 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/hooks_test.go @@ -0,0 +1,731 @@ +package factoryaidroid + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent/testutil" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + count, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // 7 hooks: SessionStart, SessionEnd, Stop, UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) + } + + // Verify settings.json was created with hooks + settings := readFactorySettings(t, tempDir) + + if len(settings.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d, want 1", len(settings.Hooks.SessionStart)) + } + if len(settings.Hooks.SessionEnd) != 1 { + t.Errorf("SessionEnd hooks = %d, want 1", len(settings.Hooks.SessionEnd)) + } + if len(settings.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d, want 1", len(settings.Hooks.Stop)) + } + if len(settings.Hooks.UserPromptSubmit) != 1 { + t.Errorf("UserPromptSubmit hooks = %d, want 1", len(settings.Hooks.UserPromptSubmit)) + } + if len(settings.Hooks.PreToolUse) != 1 { + t.Errorf("PreToolUse hooks = %d, want 1", len(settings.Hooks.PreToolUse)) + } + if len(settings.Hooks.PostToolUse) != 1 { + t.Errorf("PostToolUse hooks = %d, want 1", len(settings.Hooks.PostToolUse)) + } + if len(settings.Hooks.PreCompact) != 1 { + t.Errorf("PreCompact hooks = %d, want 1", len(settings.Hooks.PreCompact)) + } + + // Verify hook commands + assertFactoryHookExists(t, settings.Hooks.SessionStart, "", "entire hooks factoryai-droid session-start", "SessionStart") + assertFactoryHookExists(t, settings.Hooks.SessionEnd, "", "entire hooks factoryai-droid session-end", "SessionEnd") + assertFactoryHookExists(t, settings.Hooks.Stop, "", "entire hooks factoryai-droid stop", "Stop") + assertFactoryHookExists(t, settings.Hooks.UserPromptSubmit, "", "entire hooks factoryai-droid user-prompt-submit", "UserPromptSubmit") + assertFactoryHookExists(t, settings.Hooks.PreToolUse, "Task", "entire hooks factoryai-droid pre-tool-use", "PreToolUse[Task]") + assertFactoryHookExists(t, settings.Hooks.PostToolUse, "Task", "entire hooks factoryai-droid post-tool-use", "PostToolUse[Task]") + assertFactoryHookExists(t, settings.Hooks.PreCompact, "", "entire hooks factoryai-droid pre-compact", "PreCompact") + + // Verify AreHooksInstalled returns true + if !agent.AreHooksInstalled() { + t.Error("AreHooksInstalled() should return true after install") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + + // First install + count1, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 7 { + t.Errorf("first InstallHooks() count = %d, want 7", count1) + } + + // Second install should add 0 hooks + count2, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) + } + + // Verify still only 1 matcher per hook type + settings := readFactorySettings(t, tempDir) + if len(settings.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d after double install, want 1", len(settings.Hooks.SessionStart)) + } + if len(settings.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d after double install, want 1", len(settings.Hooks.Stop)) + } +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + settings := readFactorySettings(t, tempDir) + + // Verify local dev commands use FACTORY_PROJECT_DIR format + assertFactoryHookExists(t, settings.Hooks.SessionStart, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid session-start", "SessionStart localDev") + assertFactoryHookExists(t, settings.Hooks.SessionEnd, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid session-end", "SessionEnd localDev") + assertFactoryHookExists(t, settings.Hooks.Stop, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid stop", "Stop localDev") + assertFactoryHookExists(t, settings.Hooks.UserPromptSubmit, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid user-prompt-submit", "UserPromptSubmit localDev") + assertFactoryHookExists(t, settings.Hooks.PreToolUse, "Task", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid pre-tool-use", "PreToolUse localDev") + assertFactoryHookExists(t, settings.Hooks.PostToolUse, "Task", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid post-tool-use", "PostToolUse localDev") + assertFactoryHookExists(t, settings.Hooks.PreCompact, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid pre-compact", "PreCompact localDev") +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + + // First install + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should replace hooks + count, err := agent.InstallHooks(false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 7 { + t.Errorf("force InstallHooks() count = %d, want 7", count) + } +} + +func TestInstallHooks_PermissionsDeny_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + perms := readFactoryPermissions(t, tempDir) + + // Verify permissions.deny contains our rule + if !slices.Contains(perms.Deny, metadataDenyRule) { + t.Errorf("permissions.deny = %v, want to contain %q", perms.Deny, metadataDenyRule) + } +} + +func TestInstallHooks_PermissionsDeny_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + // First install + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install + _, err = agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + + perms := readFactoryPermissions(t, tempDir) + + // Count occurrences of our rule + count := 0 + for _, rule := range perms.Deny { + if rule == metadataDenyRule { + count++ + } + } + if count != 1 { + t.Errorf("permissions.deny contains %d copies of rule, want 1", count) + } +} + +func TestInstallHooks_PermissionsDeny_PreservesUserRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings.json with existing user deny rule + writeFactorySettingsFile(t, tempDir, `{ + "permissions": { + "deny": ["Bash(rm -rf *)"] + } +}`) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + perms := readFactoryPermissions(t, tempDir) + + // Verify both rules exist + if !slices.Contains(perms.Deny, "Bash(rm -rf *)") { + t.Errorf("permissions.deny = %v, want to contain user rule", perms.Deny) + } + if !slices.Contains(perms.Deny, metadataDenyRule) { + t.Errorf("permissions.deny = %v, want to contain Entire rule", perms.Deny) + } +} + +func TestInstallHooks_PermissionsDeny_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings.json with unknown permission fields like "ask" + writeFactorySettingsFile(t, tempDir, `{ + "permissions": { + "allow": ["Read(**)"], + "ask": ["Write(**)", "Bash(*)"], + "customField": {"nested": "value"} + } +}`) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read raw settings to check for unknown fields + settingsPath := filepath.Join(tempDir, ".factory", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + + var rawPermissions map[string]json.RawMessage + if err := json.Unmarshal(rawSettings["permissions"], &rawPermissions); err != nil { + t.Fatalf("failed to parse permissions: %v", err) + } + + // Verify "ask" field is preserved + if _, ok := rawPermissions["ask"]; !ok { + t.Errorf("permissions.ask was not preserved, got keys: %v", testutil.GetKeys(rawPermissions)) + } + + // Verify "customField" is preserved + if _, ok := rawPermissions["customField"]; !ok { + t.Errorf("permissions.customField was not preserved, got keys: %v", testutil.GetKeys(rawPermissions)) + } + + // Verify the "ask" field content + var askRules []string + if err := json.Unmarshal(rawPermissions["ask"], &askRules); err != nil { + t.Fatalf("failed to parse permissions.ask: %v", err) + } + if len(askRules) != 2 || askRules[0] != "Write(**)" || askRules[1] != "Bash(*)" { + t.Errorf("permissions.ask = %v, want [Write(**), Bash(*)]", askRules) + } + + // Verify the deny rule was added + var denyRules []string + if err := json.Unmarshal(rawPermissions["deny"], &denyRules); err != nil { + t.Fatalf("failed to parse permissions.deny: %v", err) + } + if !slices.Contains(denyRules, metadataDenyRule) { + t.Errorf("permissions.deny = %v, want to contain %q", denyRules, metadataDenyRule) + } + + // Verify "allow" is preserved + var allowRules []string + if err := json.Unmarshal(rawPermissions["allow"], &allowRules); err != nil { + t.Fatalf("failed to parse permissions.allow: %v", err) + } + if len(allowRules) != 1 || allowRules[0] != "Read(**)" { + t.Errorf("permissions.allow = %v, want [Read(**)]", allowRules) + } +} + +//nolint:tparallel // Parent uses t.Chdir() which prevents t.Parallel(); subtests only read from pre-loaded data +func TestInstallHooks_PreservesUserHooksOnSameType(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with user hooks on the same hook types we use + writeFactorySettingsFile(t, tempDir, `{ + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo user stop hook"}] + } + ], + "SessionStart": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo user session start"}] + } + ], + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [{"type": "command", "command": "echo user wrote file"}] + } + ] + } +}`) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + rawHooks := testutil.ReadRawHooks(t, tempDir, ".factory") + + t.Run("Stop", func(t *testing.T) { + t.Parallel() + var matchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["Stop"], &matchers); err != nil { + t.Fatalf("failed to parse Stop hooks: %v", err) + } + assertFactoryHookExists(t, matchers, "", "echo user stop hook", "user Stop hook") + assertFactoryHookExists(t, matchers, "", "entire hooks factoryai-droid stop", "Entire Stop hook") + }) + + t.Run("SessionStart", func(t *testing.T) { + t.Parallel() + var matchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["SessionStart"], &matchers); err != nil { + t.Fatalf("failed to parse SessionStart hooks: %v", err) + } + assertFactoryHookExists(t, matchers, "", "echo user session start", "user SessionStart hook") + assertFactoryHookExists(t, matchers, "", "entire hooks factoryai-droid session-start", "Entire SessionStart hook") + }) + + t.Run("PostToolUse", func(t *testing.T) { + t.Parallel() + var matchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["PostToolUse"], &matchers); err != nil { + t.Fatalf("failed to parse PostToolUse hooks: %v", err) + } + assertFactoryHookExists(t, matchers, "Write", "echo user wrote file", "user Write hook") + assertFactoryHookExists(t, matchers, "Task", "entire hooks factoryai-droid post-tool-use", "Entire Task hook") + }) +} + +func TestInstallHooks_PreservesUnknownHookTypes(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with a hook type we don't handle (Notification is a hypothetical future hook type) + writeFactorySettingsFile(t, tempDir, `{ + "hooks": { + "Notification": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo notification received"}] + } + ], + "SubagentStop": [ + { + "matcher": ".*", + "hooks": [{"type": "command", "command": "echo subagent stopped"}] + } + ] + } +}`) + + agent := &FactoryAIDroidAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read raw settings to check for unknown hook types + rawHooks := testutil.ReadRawHooks(t, tempDir, ".factory") + + // Verify Notification hook is preserved + if _, ok := rawHooks["Notification"]; !ok { + t.Errorf("Notification hook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) + } + + // Verify SubagentStop hook is preserved + if _, ok := rawHooks["SubagentStop"]; !ok { + t.Errorf("SubagentStop hook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) + } + + // Verify the Notification hook content is intact + var notificationMatchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["Notification"], ¬ificationMatchers); err != nil { + t.Fatalf("failed to parse Notification hooks: %v", err) + } + if len(notificationMatchers) != 1 { + t.Errorf("Notification matchers = %d, want 1", len(notificationMatchers)) + } + if len(notificationMatchers) > 0 && len(notificationMatchers[0].Hooks) > 0 { + if notificationMatchers[0].Hooks[0].Command != "echo notification received" { + t.Errorf("Notification hook command = %q, want %q", + notificationMatchers[0].Hooks[0].Command, "echo notification received") + } + } + + // Verify the SubagentStop hook content is intact + var subagentStopMatchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["SubagentStop"], &subagentStopMatchers); err != nil { + t.Fatalf("failed to parse SubagentStop hooks: %v", err) + } + if len(subagentStopMatchers) != 1 { + t.Errorf("SubagentStop matchers = %d, want 1", len(subagentStopMatchers)) + } + if len(subagentStopMatchers) > 0 { + if subagentStopMatchers[0].Matcher != ".*" { + t.Errorf("SubagentStop matcher = %q, want %q", subagentStopMatchers[0].Matcher, ".*") + } + if len(subagentStopMatchers[0].Hooks) > 0 { + if subagentStopMatchers[0].Hooks[0].Command != "echo subagent stopped" { + t.Errorf("SubagentStop hook command = %q, want %q", + subagentStopMatchers[0].Hooks[0].Command, "echo subagent stopped") + } + } + } + + // Verify our hooks were also installed + if _, ok := rawHooks["Stop"]; !ok { + t.Errorf("Stop hook should have been installed") + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + + // First install + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify hooks are installed + if !agent.AreHooksInstalled() { + t.Error("hooks should be installed before uninstall") + } + + // Uninstall + err = agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify hooks are removed + if agent.AreHooksInstalled() { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoSettingsFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + + // Should not error when no settings file exists + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() should not error when no settings file: %v", err) + } +} + +func TestUninstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with both user and entire hooks + writeFactorySettingsFile(t, tempDir, `{ + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo user hook"}] + }, + { + "matcher": "", + "hooks": [{"type": "command", "command": "entire hooks factoryai-droid stop"}] + } + ] + } +}`) + + agent := &FactoryAIDroidAgent{} + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + settings := readFactorySettings(t, tempDir) + + // Verify only user hooks remain + if len(settings.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d after uninstall, want 1 (user only)", len(settings.Hooks.Stop)) + } + + // Verify it's the user hook + if len(settings.Hooks.Stop) > 0 && len(settings.Hooks.Stop[0].Hooks) > 0 { + if settings.Hooks.Stop[0].Hooks[0].Command != "echo user hook" { + t.Error("user hook was removed during uninstall") + } + } +} + +func TestUninstallHooks_RemovesDenyRule(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &FactoryAIDroidAgent{} + + // First install (which adds the deny rule) + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify deny rule was added + perms := readFactoryPermissions(t, tempDir) + if !slices.Contains(perms.Deny, metadataDenyRule) { + t.Fatal("deny rule should be present after install") + } + + // Uninstall + err = agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify deny rule was removed + perms = readFactoryPermissions(t, tempDir) + if slices.Contains(perms.Deny, metadataDenyRule) { + t.Error("deny rule should be removed after uninstall") + } +} + +func TestUninstallHooks_PreservesUserDenyRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with user deny rule and entire deny rule + writeFactorySettingsFile(t, tempDir, `{ + "permissions": { + "deny": ["Bash(rm -rf *)", "Read(./.entire/metadata/**)"] + }, + "hooks": { + "Stop": [ + { + "hooks": [{"type": "command", "command": "entire hooks factoryai-droid stop"}] + } + ] + } +}`) + + agent := &FactoryAIDroidAgent{} + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + perms := readFactoryPermissions(t, tempDir) + + // Verify user deny rule is preserved + if !slices.Contains(perms.Deny, "Bash(rm -rf *)") { + t.Errorf("user deny rule was removed, got: %v", perms.Deny) + } + + // Verify entire deny rule is removed + if slices.Contains(perms.Deny, metadataDenyRule) { + t.Errorf("entire deny rule should be removed, got: %v", perms.Deny) + } +} + +func TestUninstallHooks_PreservesUnknownHookTypes(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with Entire hooks AND unknown hook types + writeFactorySettingsFile(t, tempDir, `{ + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "entire hooks factoryai-droid stop"}] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo notification received"}] + } + ], + "SubagentStop": [ + { + "matcher": ".*", + "hooks": [{"type": "command", "command": "echo subagent stopped"}] + } + ] + } +}`) + + agent := &FactoryAIDroidAgent{} + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Read raw settings to check for unknown hook types + rawHooks := testutil.ReadRawHooks(t, tempDir, ".factory") + + // Verify Notification hook is preserved + if _, ok := rawHooks["Notification"]; !ok { + t.Errorf("Notification hook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) + } + + // Verify SubagentStop hook is preserved + if _, ok := rawHooks["SubagentStop"]; !ok { + t.Errorf("SubagentStop hook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) + } + + // Verify our hooks were removed + if _, ok := rawHooks["Stop"]; ok { + // Check if there are any hooks left (should be empty after uninstall) + var stopMatchers []FactoryHookMatcher + if err := json.Unmarshal(rawHooks["Stop"], &stopMatchers); err == nil && len(stopMatchers) > 0 { + t.Errorf("Stop hook should have been removed") + } + } +} + +// Helper functions + +// testPermissions is used only for test assertions +type testPermissions struct { + Allow []string `json:"allow,omitempty"` + Deny []string `json:"deny,omitempty"` +} + +func writeFactorySettingsFile(t *testing.T, tempDir, content string) { + t.Helper() + factoryDir := filepath.Join(tempDir, ".factory") + if err := os.MkdirAll(factoryDir, 0o755); err != nil { + t.Fatalf("failed to create .factory dir: %v", err) + } + settingsPath := filepath.Join(factoryDir, "settings.json") + if err := os.WriteFile(settingsPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write settings.json: %v", err) + } +} + +func readFactoryPermissions(t *testing.T, tempDir string) testPermissions { + t.Helper() + settingsPath := filepath.Join(tempDir, ".factory", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + + var perms testPermissions + if permRaw, ok := rawSettings["permissions"]; ok { + if err := json.Unmarshal(permRaw, &perms); err != nil { + t.Fatalf("failed to parse permissions: %v", err) + } + } + return perms +} + +func readFactorySettings(t *testing.T, tempDir string) FactorySettings { + t.Helper() + settingsPath := filepath.Join(tempDir, ".factory", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var settings FactorySettings + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + return settings +} + +func assertFactoryHookExists(t *testing.T, matchers []FactoryHookMatcher, matcher, command, description string) { + t.Helper() + for _, m := range matchers { + if m.Matcher == matcher { + for _, h := range m.Hooks { + if h.Command == command { + return + } + } + } + } + t.Errorf("%s was not found (matcher=%q, command=%q)", description, matcher, command) +} diff --git a/cmd/entire/cli/agent/factoryaidroid/lifecycle.go b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go new file mode 100644 index 000000000..eacd7eea8 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go @@ -0,0 +1,241 @@ +package factoryaidroid + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" + + "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 assertions. +var ( + _ agent.TranscriptAnalyzer = (*FactoryAIDroidAgent)(nil) + _ agent.TokenCalculator = (*FactoryAIDroidAgent)(nil) + _ agent.SubagentAwareExtractor = (*FactoryAIDroidAgent)(nil) +) + +// HookNames returns the hook verbs Factory AI Droid supports. +func (f *FactoryAIDroidAgent) HookNames() []string { + return f.GetHookNames() +} + +// ParseHookEvent translates a Factory AI Droid hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (f *FactoryAIDroidAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return f.parseSessionStart(stdin) + case HookNameUserPromptSubmit: + return f.parseTurnStart(stdin) + case HookNameStop: + return f.parseTurnEnd(stdin) + case HookNameSessionEnd: + return f.parseSessionEnd(stdin) + case HookNamePreToolUse: + return f.parseSubagentStart(stdin) + case HookNamePostToolUse: + return f.parseSubagentEnd(stdin) + case HookNamePreCompact: + return f.parseCompaction(stdin) + case HookNameSubagentStop, HookNameNotification: + // Acknowledged hooks with no lifecycle action + return nil, nil //nolint:nilnil // nil event = no lifecycle action + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// --- TranscriptAnalyzer --- + +// GetTranscriptPosition returns the current line count of the JSONL transcript. +func (f *FactoryAIDroidAgent) GetTranscriptPosition(path string) (int, error) { + _, pos, err := ParseDroidTranscript(path, 0) + if err != nil { + return 0, err + } + return pos, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line offset. +func (f *FactoryAIDroidAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { + lines, currentPos, err := ParseDroidTranscript(path, startOffset) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse transcript: %w", err) + } + files := ExtractModifiedFiles(lines) + return files, currentPos, nil +} + +// ExtractPrompts extracts user prompts from the transcript starting at the given line offset. +func (f *FactoryAIDroidAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + lines, _, err := ParseDroidTranscript(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 (f *FactoryAIDroidAgent) 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, err := ParseDroidTranscriptFromBytes(data) + if err != nil { + return "", fmt.Errorf("failed to parse transcript: %w", err) + } + + 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 +} + +// --- TokenCalculator --- + +// CalculateTokenUsage computes token usage from the transcript starting at the given line offset. +func (f *FactoryAIDroidAgent) CalculateTokenUsage(sessionRef string, fromOffset int) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromTranscript(sessionRef, fromOffset, "") +} + +// --- SubagentAwareExtractor --- + +// ExtractAllModifiedFiles extracts files modified by both the main agent and any spawned subagents. +func (f *FactoryAIDroidAgent) ExtractAllModifiedFiles(sessionRef string, fromOffset int, subagentsDir string) ([]string, error) { + return ExtractAllModifiedFilesFromTranscript(sessionRef, fromOffset, subagentsDir) +} + +// CalculateTotalTokenUsage computes token usage including all spawned subagents. +func (f *FactoryAIDroidAgent) CalculateTotalTokenUsage(sessionRef string, fromOffset int, subagentsDir string) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromTranscript(sessionRef, fromOffset, subagentsDir) +} + +// --- Internal hook parsing functions --- + +func (f *FactoryAIDroidAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (f *FactoryAIDroidAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[userPromptSubmitRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Prompt: raw.Prompt, + Timestamp: time.Now(), + }, nil +} + +func (f *FactoryAIDroidAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (f *FactoryAIDroidAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (f *FactoryAIDroidAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[taskHookInputRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SubagentStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + }, nil +} + +func (f *FactoryAIDroidAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[postToolHookInputRaw](stdin) + if err != nil { + return nil, err + } + event := &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + } + if raw.ToolResponse.AgentID != "" { + event.SubagentID = raw.ToolResponse.AgentID + } + return event, nil +} + +func (f *FactoryAIDroidAgent) parseCompaction(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.Compaction, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} diff --git a/cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go b/cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go new file mode 100644 index 000000000..c97b991ed --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go @@ -0,0 +1,190 @@ +package factoryaidroid + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "test-session", "transcript_path": "/tmp/transcript.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionStart { + t.Errorf("expected SessionStart, got %v", event.Type) + } + if event.SessionID != "test-session" { + t.Errorf("expected session_id 'test-session', got %q", event.SessionID) + } + if event.SessionRef != "/tmp/transcript.jsonl" { + t.Errorf("expected transcript_path '/tmp/transcript.jsonl', got %q", event.SessionRef) + } +} + +func TestParseHookEvent_TurnStart(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-1", "transcript_path": "/tmp/t.jsonl", "prompt": "Fix the bug"}` + + event, err := ag.ParseHookEvent(HookNameUserPromptSubmit, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.TurnStart { + t.Errorf("expected TurnStart, got %v", event.Type) + } + if event.Prompt != "Fix the bug" { + t.Errorf("expected prompt 'Fix the bug', got %q", event.Prompt) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-2", "transcript_path": "/tmp/t.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.TurnEnd { + t.Errorf("expected TurnEnd, got %v", event.Type) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-3", "transcript_path": "/tmp/t.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.SessionEnd { + t.Errorf("expected SessionEnd, got %v", event.Type) + } +} + +func TestParseHookEvent_SubagentStart(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-4", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "tu-123", "tool_input": {"prompt": "do something"}}` + + event, err := ag.ParseHookEvent(HookNamePreToolUse, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.SubagentStart { + t.Errorf("expected SubagentStart, got %v", event.Type) + } + if event.ToolUseID != "tu-123" { + t.Errorf("expected tool_use_id 'tu-123', got %q", event.ToolUseID) + } +} + +func TestParseHookEvent_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-5", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "tu-456", "tool_input": {}, "tool_response": {"agentId": "agent-789"}}` + + event, err := ag.ParseHookEvent(HookNamePostToolUse, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.SubagentEnd { + t.Errorf("expected SubagentEnd, got %v", event.Type) + } + if event.SubagentID != "agent-789" { + t.Errorf("expected SubagentID 'agent-789', got %q", event.SubagentID) + } +} + +func TestParseHookEvent_Compaction(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + input := `{"session_id": "sess-6", "transcript_path": "/tmp/t.jsonl"}` + + event, err := ag.ParseHookEvent(HookNamePreCompact, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.Compaction { + t.Errorf("expected Compaction, got %v", event.Type) + } +} + +func TestParseHookEvent_SubagentStop_PassThrough(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + event, err := ag.ParseHookEvent(HookNameSubagentStop, strings.NewReader(`{"session_id":"s"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for SubagentStop, got %+v", event) + } +} + +func TestParseHookEvent_Notification_PassThrough(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + event, err := ag.ParseHookEvent(HookNameNotification, strings.NewReader(`{"session_id":"s"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for Notification, got %+v", event) + } +} + +func TestParseHookEvent_UnknownHook(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + event, err := ag.ParseHookEvent("unknown-hook", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for unknown hook, got %+v", event) + } +} + +func TestParseHookEvent_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("")) + if err == nil { + t.Fatal("expected error for empty input") + } +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("not json")) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} diff --git a/cmd/entire/cli/agent/factoryaidroid/transcript.go b/cmd/entire/cli/agent/factoryaidroid/transcript.go new file mode 100644 index 000000000..4fc02a52b --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/transcript.go @@ -0,0 +1,401 @@ +package factoryaidroid + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// TranscriptLine is an alias to the shared transcript.Line type. +type TranscriptLine = transcript.Line + +// droidEnvelope is the top-level structure of a Factory AI Droid JSONL line. +// Droid wraps messages as {"type":"message","id":"...","message":{"role":"assistant","content":[...]}}, +// unlike Claude Code which uses {"type":"assistant","uuid":"...","message":{"content":[...]}}. +type droidEnvelope struct { + Type string `json:"type"` + ID string `json:"id"` + Message json.RawMessage `json:"message"` +} + +// droidMessageRole extracts just the role from the inner message. +type droidMessageRole struct { + Role string `json:"role"` +} + +// ParseDroidTranscript parses a Droid JSONL file into normalized transcript.Line entries. +// It transforms the Droid envelope format (type="message", role inside message) into the +// shared transcript.Line format (type="assistant"/"user", message=inner content). +// Non-message entries (session_start, etc.) are skipped. +func ParseDroidTranscript(path string, startLine int) ([]transcript.Line, int, error) { + file, err := os.Open(path) //nolint:gosec // path is a controlled transcript file path + if err != nil { + return nil, 0, fmt.Errorf("failed to open transcript: %w", err) + } + defer func() { _ = file.Close() }() + + return parseDroidTranscriptFromReader(file, startLine) +} + +// ParseDroidTranscriptFromBytes parses Droid JSONL content from a byte slice. +func ParseDroidTranscriptFromBytes(content []byte) ([]transcript.Line, error) { + lines, _, err := parseDroidTranscriptFromReader(bytes.NewReader(content), 0) + return lines, err +} + +func parseDroidTranscriptFromReader(r io.Reader, startLine int) ([]transcript.Line, int, error) { + reader := bufio.NewReader(r) + var lines []transcript.Line + totalLines := 0 + + for { + lineBytes, err := reader.ReadBytes('\n') + if err != nil && err != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", err) + } + + if len(lineBytes) == 0 { + if err == io.EOF { + break + } + continue + } + + if totalLines >= startLine { + if line, ok := parseDroidLine(lineBytes); ok { + lines = append(lines, line) + } + } + totalLines++ + + if err == io.EOF { + break + } + } + + return lines, totalLines, nil +} + +// parseDroidLine converts a single Droid JSONL line into a normalized transcript.Line. +// Returns false if the line is not a message entry (e.g., session_start). +func parseDroidLine(lineBytes []byte) (transcript.Line, bool) { + var env droidEnvelope + if err := json.Unmarshal(lineBytes, &env); err != nil { + return transcript.Line{}, false + } + + // Only process "message" type entries — skip session_start, etc. + if env.Type != "message" { + return transcript.Line{}, false + } + + // Extract role from the inner message + var role droidMessageRole + if err := json.Unmarshal(env.Message, &role); err != nil { + return transcript.Line{}, false + } + + return transcript.Line{ + Type: role.Role, // "assistant" or "user" + UUID: env.ID, + Message: env.Message, + }, true +} + +// SerializeTranscript converts transcript lines back to JSONL bytes. +func SerializeTranscript(lines []TranscriptLine) ([]byte, error) { + var buf bytes.Buffer + for _, line := range lines { + data, err := json.Marshal(line) + if err != nil { + return nil, fmt.Errorf("failed to marshal line: %w", err) + } + buf.Write(data) + buf.WriteByte('\n') + } + return buf.Bytes(), nil +} + +// ExtractModifiedFiles extracts files modified by tool calls from transcript. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]bool) + var files []string + + for _, line := range lines { + if line.Type != "assistant" { + continue + } + + var msg transcript.AssistantMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + for _, block := range msg.Content { + if block.Type != "tool_use" || !slices.Contains(FileModificationTools, block.Name) { + continue + } + + var input transcript.ToolInput + if err := json.Unmarshal(block.Input, &input); err != nil { + continue + } + + file := input.FilePath + if file == "" { + file = input.NotebookPath + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + + return files +} + +// CalculateTokenUsage calculates token usage from a Factory AI Droid transcript. +// Due to streaming, multiple transcript rows may share the same message.id. +// We deduplicate by taking the row with the highest output_tokens for each message.id. +func CalculateTokenUsage(transcriptLines []TranscriptLine) *agent.TokenUsage { + // Map from message.id to the usage with highest output_tokens + usageByMessageID := make(map[string]messageUsage) + + for _, line := range transcriptLines { + if line.Type != "assistant" { + continue + } + + var msg messageWithUsage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + if msg.ID == "" { + continue + } + + // Keep the entry with highest output_tokens (final streaming state) + existing, exists := usageByMessageID[msg.ID] + if !exists || msg.Usage.OutputTokens > existing.OutputTokens { + usageByMessageID[msg.ID] = msg.Usage + } + } + + // Sum up all unique messages + usage := &agent.TokenUsage{ + APICallCount: len(usageByMessageID), + } + for _, u := range usageByMessageID { + usage.InputTokens += u.InputTokens + usage.CacheCreationTokens += u.CacheCreationInputTokens + usage.CacheReadTokens += u.CacheReadInputTokens + usage.OutputTokens += u.OutputTokens + } + + return usage +} + +// CalculateTokenUsageFromFile calculates token usage from a transcript file. +// If startLine > 0, only considers lines from startLine onwards. +func CalculateTokenUsageFromFile(path string, startLine int) (*agent.TokenUsage, error) { + if path == "" { + return &agent.TokenUsage{}, nil + } + + lines, _, err := ParseDroidTranscript(path, startLine) + if err != nil { + return nil, err + } + + return CalculateTokenUsage(lines), nil +} + +// ExtractSpawnedAgentIDs extracts agent IDs from Task tool results in a transcript. +// When a Task tool completes, the tool_result contains "agentId: " in its content. +// Returns a map of agentID -> toolUseID for all spawned agents. +func ExtractSpawnedAgentIDs(transcriptLines []TranscriptLine) map[string]string { + agentIDs := make(map[string]string) + + for _, line := range transcriptLines { + if line.Type != "user" { + continue + } + + // Parse as array of content blocks (tool results) + var contentBlocks []struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + } + + var msg struct { + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + if err := json.Unmarshal(msg.Content, &contentBlocks); err != nil { + continue + } + + for _, block := range contentBlocks { + if block.Type != "tool_result" { + continue + } + + // Content can be a string or array of text blocks + var textContent string + + // Try as array of text blocks first + var textBlocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(block.Content, &textBlocks); err == nil { + var sb strings.Builder + for _, tb := range textBlocks { + if tb.Type == "text" { + sb.WriteString(tb.Text + "\n") + } + } + textContent = sb.String() + } else { + // Try as plain string + var str string + if err := json.Unmarshal(block.Content, &str); err == nil { + textContent = str + } + } + + // Look for agentId in the text + if agentID := extractAgentIDFromText(textContent); agentID != "" { + agentIDs[agentID] = block.ToolUseID + } + } + } + + return agentIDs +} + +// extractAgentIDFromText extracts an agent ID from text containing "agentId: ". +func extractAgentIDFromText(text string) string { + const prefix = "agentId: " + idx := strings.Index(text, prefix) + if idx == -1 { + return "" + } + + // Extract the ID (alphanumeric characters after the prefix) + start := idx + len(prefix) + end := start + for end < len(text) && (text[end] >= 'a' && text[end] <= 'z' || + text[end] >= 'A' && text[end] <= 'Z' || + text[end] >= '0' && text[end] <= '9') { + end++ + } + + if end > start { + return text[start:end] + } + return "" +} + +// CalculateTotalTokenUsageFromTranscript calculates token usage for a turn, including subagents. +// It parses the main transcript from startLine, extracts spawned agent IDs, +// and calculates their token usage from transcripts in subagentsDir. +func CalculateTotalTokenUsageFromTranscript(transcriptPath string, startLine int, subagentsDir string) (*agent.TokenUsage, error) { + if transcriptPath == "" { + return &agent.TokenUsage{}, nil + } + + // Parse transcript once using Droid-specific parser + parsed, _, err := ParseDroidTranscript(transcriptPath, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + // Calculate token usage from parsed transcript + mainUsage := CalculateTokenUsage(parsed) + + // Extract spawned agent IDs from the same parsed transcript + agentIDs := ExtractSpawnedAgentIDs(parsed) + + // Calculate subagent token usage + if len(agentIDs) > 0 { + subagentUsage := &agent.TokenUsage{} + for agentID := range agentIDs { + agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)) + agentUsage, err := CalculateTokenUsageFromFile(agentPath, 0) + if err != nil { + // Agent transcript may not exist yet or may have been cleaned up + continue + } + subagentUsage.InputTokens += agentUsage.InputTokens + subagentUsage.CacheCreationTokens += agentUsage.CacheCreationTokens + subagentUsage.CacheReadTokens += agentUsage.CacheReadTokens + subagentUsage.OutputTokens += agentUsage.OutputTokens + subagentUsage.APICallCount += agentUsage.APICallCount + } + if subagentUsage.APICallCount > 0 { + mainUsage.SubagentTokens = subagentUsage + } + } + + return mainUsage, nil +} + +// ExtractAllModifiedFilesFromTranscript 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 ExtractAllModifiedFilesFromTranscript(transcriptPath string, startLine int, subagentsDir string) ([]string, error) { + if transcriptPath == "" { + return nil, nil + } + + // Parse main transcript once using Droid-specific parser + parsed, _, err := ParseDroidTranscript(transcriptPath, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + // Collect modified files from main agent (already deduplicated) + files := ExtractModifiedFiles(parsed) + fileSet := make(map[string]bool, len(files)) + for _, f := range files { + fileSet[f] = true + } + + // Find spawned subagents and collect their modified files + agentIDs := ExtractSpawnedAgentIDs(parsed) + for agentID := range agentIDs { + agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)) + agentLines, _, agentErr := ParseDroidTranscript(agentPath, 0) + if agentErr != nil { + // Subagent transcript may not exist yet or may have been cleaned up + continue + } + for _, f := range ExtractModifiedFiles(agentLines) { + if !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + + return files, nil +} diff --git a/cmd/entire/cli/agent/factoryaidroid/transcript_test.go b/cmd/entire/cli/agent/factoryaidroid/transcript_test.go new file mode 100644 index 000000000..f05b11503 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/transcript_test.go @@ -0,0 +1,850 @@ +package factoryaidroid + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +func TestSerializeTranscript(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + {Type: "user", UUID: "u1"}, + {Type: "assistant", UUID: "a1"}, + } + + data, err := SerializeTranscript(lines) + if err != nil { + t.Fatalf("SerializeTranscript() error = %v", err) + } + + // Parse back to verify round-trip + parsed, err := transcript.ParseFromBytes(data) + if err != nil { + t.Fatalf("ParseFromBytes(serialized) error = %v", err) + } + + if len(parsed) != 2 { + t.Errorf("Round-trip got %d lines, want 2", len(parsed)) + } +} + +func TestParseDroidTranscript_NormalizesEnvelope(t *testing.T) { + t.Parallel() + + // Real Droid format: type is always "message", role is inside the inner message + data := []byte( + `{"type":"session_start","id":"sess-1","title":"test"}` + "\n" + + `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" + + `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}` + "\n", + ) + + lines, err := ParseDroidTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err) + } + + // session_start should be skipped + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 (session_start should be skipped)", len(lines)) + } + + // First line should be normalized to type="user" + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[0].UUID != "m1" { + t.Errorf("lines[0].UUID = %q, want \"m1\"", lines[0].UUID) + } + + // Second line should be normalized to type="assistant" + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } + if lines[1].UUID != "m2" { + t.Errorf("lines[1].UUID = %q, want \"m2\"", lines[1].UUID) + } +} + +func TestParseDroidTranscript_StartLineOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := tmpDir + "/transcript.jsonl" + + data := []byte( + `{"type":"session_start","id":"s1"}` + "\n" + + `{"type":"message","id":"m1","message":{"role":"user","content":"hello"}}` + "\n" + + `{"type":"message","id":"m2","message":{"role":"assistant","content":"hi"}}` + "\n" + + `{"type":"message","id":"m3","message":{"role":"user","content":"bye"}}` + "\n", + ) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("failed to write: %v", err) + } + + // Read from line 2 onward (skip session_start + first message) + lines, totalLines, err := ParseDroidTranscript(path, 2) + if err != nil { + t.Fatalf("ParseDroidTranscript() error = %v", err) + } + + if totalLines != 4 { + t.Errorf("totalLines = %d, want 4", totalLines) + } + + // Lines 2 and 3 are messages, both should be parsed + if len(lines) != 2 { + t.Fatalf("got %d lines from offset 2, want 2", len(lines)) + } + if lines[0].Type != transcript.TypeAssistant { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeAssistant) + } + if lines[1].Type != transcript.TypeUser { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeUser) + } +} + +func TestParseDroidTranscript_RealDroidFormat(t *testing.T) { + t.Parallel() + + // Test with a realistic Droid transcript snippet including tool use + data := []byte( + `{"type":"session_start","id":"5734e7ee","title":"test session"}` + "\n" + + `{"type":"message","id":"msg-1","message":{"role":"user","content":[{"type":"text","text":"update main.go"}]}}` + "\n" + + `{"type":"message","id":"msg-2","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Edit","input":{"file_path":"/repo/main.go","old_str":"old","new_str":"new"}}]}}` + "\n" + + `{"type":"message","id":"msg-3","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"success"}]}}` + "\n" + + `{"type":"message","id":"msg-4","message":{"role":"assistant","content":[{"type":"text","text":"Done!"}]}}` + "\n", + ) + + lines, err := ParseDroidTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err) + } + + if len(lines) != 4 { + t.Fatalf("got %d lines, want 4", len(lines)) + } + + // Verify ExtractModifiedFiles works with the parsed Droid lines + files := ExtractModifiedFiles(lines) + if len(files) != 1 { + t.Fatalf("ExtractModifiedFiles() got %d files, want 1", len(files)) + } + if files[0] != "/repo/main.go" { + t.Errorf("ExtractModifiedFiles() got %q, want /repo/main.go", files[0]) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + // Droid format: {"type":"message","id":"...","message":{"role":"assistant","content":[...]}} + data := []byte(`{"type":"message","id":"a1","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"foo.go"}}]}} +{"type":"message","id":"a2","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"bar.go"}}]}} +{"type":"message","id":"a3","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}} +{"type":"message","id":"a4","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"foo.go"}}]}} +`) + + lines, err := ParseDroidTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err) + } + files := ExtractModifiedFiles(lines) + + // Should have foo.go and bar.go (deduplicated, Bash not included) + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("ExtractModifiedFiles() missing foo.go") + } + if !hasFile("bar.go") { + t.Error("ExtractModifiedFiles() missing bar.go") + } +} + +func TestExtractModifiedFiles_NotebookEdit(t *testing.T) { + t.Parallel() + + data := []byte(`{"type":"message","id":"a1","message":{"role":"assistant","content":[{"type":"tool_use","name":"NotebookEdit","input":{"notebook_path":"/repo/analysis.ipynb"}}]}} +`) + + lines, err := ParseDroidTranscriptFromBytes(data) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 { + t.Fatalf("ExtractModifiedFiles() got %d files, want 1", len(files)) + } + if files[0] != "/repo/analysis.ipynb" { + t.Errorf("ExtractModifiedFiles() got %q, want /repo/analysis.ipynb", files[0]) + } +} + +func TestExtractModifiedFiles_Empty(t *testing.T) { + t.Parallel() + + files := ExtractModifiedFiles(nil) + if files != nil { + t.Errorf("ExtractModifiedFiles(nil) = %v, want nil", files) + } +} + +func TestCalculateTokenUsage_BasicMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: "assistant", + UUID: "asst-1", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_001", + "usage": map[string]int{ + "input_tokens": 10, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 50, + "output_tokens": 20, + }, + }), + }, + { + Type: "assistant", + UUID: "asst-2", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_002", + "usage": map[string]int{ + "input_tokens": 5, + "cache_creation_input_tokens": 200, + "cache_read_input_tokens": 0, + "output_tokens": 30, + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } + if usage.InputTokens != 15 { + t.Errorf("InputTokens = %d, want 15", usage.InputTokens) + } + if usage.CacheCreationTokens != 300 { + t.Errorf("CacheCreationTokens = %d, want 300", usage.CacheCreationTokens) + } + if usage.CacheReadTokens != 50 { + t.Errorf("CacheReadTokens = %d, want 50", usage.CacheReadTokens) + } + if usage.OutputTokens != 50 { + t.Errorf("OutputTokens = %d, want 50", usage.OutputTokens) + } +} + +func TestCalculateTokenUsage_StreamingDeduplication(t *testing.T) { + t.Parallel() + + // Simulate streaming: multiple rows with same message ID, increasing output_tokens + lines := []TranscriptLine{ + { + Type: "assistant", + UUID: "asst-1", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_001", + "usage": map[string]int{ + "input_tokens": 10, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 50, + "output_tokens": 1, // First streaming chunk + }, + }), + }, + { + Type: "assistant", + UUID: "asst-2", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_001", // Same message ID + "usage": map[string]int{ + "input_tokens": 10, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 50, + "output_tokens": 5, // More output + }, + }), + }, + { + Type: "assistant", + UUID: "asst-3", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_001", // Same message ID + "usage": map[string]int{ + "input_tokens": 10, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 50, + "output_tokens": 20, // Final output + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + // Should deduplicate to 1 API call with the highest output_tokens + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1 (should deduplicate by message ID)", usage.APICallCount) + } + if usage.OutputTokens != 20 { + t.Errorf("OutputTokens = %d, want 20 (should take highest)", usage.OutputTokens) + } + // Input/cache tokens should not be duplicated + if usage.InputTokens != 10 { + t.Errorf("InputTokens = %d, want 10", usage.InputTokens) + } +} + +func TestCalculateTokenUsage_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: "user", + UUID: "user-1", + Message: mustMarshal(t, map[string]interface{}{"content": "hello"}), + }, + { + Type: "assistant", + UUID: "asst-1", + Message: mustMarshal(t, map[string]interface{}{ + "id": "msg_001", + "usage": map[string]int{ + "input_tokens": 10, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 0, + "output_tokens": 20, + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } +} + +func TestCalculateTokenUsage_EmptyTranscript(t *testing.T) { + t.Parallel() + + usage := CalculateTokenUsage(nil) + + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } +} + +func TestExtractSpawnedAgentIDs_FromToolResult(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: "user", + UUID: "user-1", + Message: mustMarshal(t, map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": "toolu_abc123", + "content": []map[string]string{ + {"type": "text", "text": "Result from agent\n\nagentId: ac66d4b (for resuming)"}, + }, + }, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 1 { + t.Fatalf("Expected 1 agent ID, got %d", len(agentIDs)) + } + if _, ok := agentIDs["ac66d4b"]; !ok { + t.Errorf("Expected agent ID 'ac66d4b', got %v", agentIDs) + } + if agentIDs["ac66d4b"] != "toolu_abc123" { + t.Errorf("Expected tool_use_id 'toolu_abc123', got %s", agentIDs["ac66d4b"]) + } +} + +func TestExtractSpawnedAgentIDs_MultipleAgents(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: "user", + UUID: "user-1", + Message: mustMarshal(t, map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": []map[string]string{ + {"type": "text", "text": "agentId: aaa1111"}, + }, + }, + }, + }), + }, + { + Type: "user", + UUID: "user-2", + Message: mustMarshal(t, map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": "toolu_002", + "content": []map[string]string{ + {"type": "text", "text": "agentId: bbb2222"}, + }, + }, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 2 { + t.Fatalf("Expected 2 agent IDs, got %d", len(agentIDs)) + } + if _, ok := agentIDs["aaa1111"]; !ok { + t.Errorf("Expected agent ID 'aaa1111'") + } + if _, ok := agentIDs["bbb2222"]; !ok { + t.Errorf("Expected agent ID 'bbb2222'") + } +} + +func TestExtractSpawnedAgentIDs_NoAgentID(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: "user", + UUID: "user-1", + Message: mustMarshal(t, map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": []map[string]string{ + {"type": "text", "text": "Some result without agent ID"}, + }, + }, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 0 { + t.Errorf("Expected 0 agent IDs, got %d: %v", len(agentIDs), agentIDs) + } +} + +func TestExtractAgentIDFromText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + expected string + }{ + { + name: "standard format", + text: "agentId: ac66d4b (for resuming)", + expected: "ac66d4b", + }, + { + name: "at end of text", + text: "Result text\n\nagentId: abc1234", + expected: "abc1234", + }, + { + name: "no agent ID", + text: "Some text without agent ID", + expected: "", + }, + { + name: "empty text", + text: "", + expected: "", + }, + { + name: "agent ID with newline after", + text: "agentId: xyz9999\nMore text", + expected: "xyz9999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractAgentIDFromText(tt.text) + if got != tt.expected { + t.Errorf("extractAgentIDFromText(%q) = %q, want %q", tt.text, got, tt.expected) + } + }) + } +} + +func TestCalculateTotalTokenUsageFromTranscript_PerCheckpoint(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + // Build transcript with 3 turns: + // Turn 1: user + assistant (100 input, 50 output) + // Turn 2: user + assistant (200 input, 100 output) + // Turn 3: user + assistant (300 input, 150 output) + // + // Lines: + // 0: user message 1 + // 1: assistant response 1 (100/50 tokens) + // 2: user message 2 + // 3: assistant response 2 (200/100 tokens) + // 4: user message 3 + // 5: assistant response 3 (300/150 tokens) + + // Droid format: outer type is always "message", role is inside the inner message + transcriptContent := []byte( + `{"type":"message","id":"u1","message":{"role":"user","content":"first prompt"}}` + "\n" + + `{"type":"message","id":"a1","message":{"role":"assistant","id":"m1","usage":{"input_tokens":100,"output_tokens":50}}}` + "\n" + + `{"type":"message","id":"u2","message":{"role":"user","content":"second prompt"}}` + "\n" + + `{"type":"message","id":"a2","message":{"role":"assistant","id":"m2","usage":{"input_tokens":200,"output_tokens":100}}}` + "\n" + + `{"type":"message","id":"u3","message":{"role":"user","content":"third prompt"}}` + "\n" + + `{"type":"message","id":"a3","message":{"role":"assistant","id":"m3","usage":{"input_tokens":300,"output_tokens":150}}}` + "\n", + ) + if err := os.WriteFile(transcriptPath, transcriptContent, 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Test 1: From line 0 - all 3 turns = 600 input, 300 output + usage1, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 0, "") + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromTranscript(0) error: %v", err) + } + if usage1.InputTokens != 600 || usage1.OutputTokens != 300 { + t.Errorf("From line 0: got input=%d output=%d, want input=600 output=300", + usage1.InputTokens, usage1.OutputTokens) + } + if usage1.APICallCount != 3 { + t.Errorf("From line 0: got APICallCount=%d, want 3", usage1.APICallCount) + } + + // Test 2: From line 2 (after turn 1) - turns 2+3 only = 500 input, 250 output + usage2, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 2, "") + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromTranscript(2) error: %v", err) + } + if usage2.InputTokens != 500 || usage2.OutputTokens != 250 { + t.Errorf("From line 2: got input=%d output=%d, want input=500 output=250", + usage2.InputTokens, usage2.OutputTokens) + } + if usage2.APICallCount != 2 { + t.Errorf("From line 2: got APICallCount=%d, want 2", usage2.APICallCount) + } + + // Test 3: From line 4 (after turns 1+2) - turn 3 only = 300 input, 150 output + usage3, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 4, "") + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromTranscript(4) error: %v", err) + } + if usage3.InputTokens != 300 || usage3.OutputTokens != 150 { + t.Errorf("From line 4: got input=%d output=%d, want input=300 output=150", + usage3.InputTokens, usage3.OutputTokens) + } + if usage3.APICallCount != 1 { + t.Errorf("From line 4: got APICallCount=%d, want 1", usage3.APICallCount) + } +} + +func TestExtractAllModifiedFilesFromTranscript_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 := ExtractAllModifiedFilesFromTranscript(transcriptPath, 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromTranscript() 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 TestExtractAllModifiedFilesFromTranscript_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 := ExtractAllModifiedFilesFromTranscript(transcriptPath, 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromTranscript() 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 TestExtractAllModifiedFilesFromTranscript_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 := ExtractAllModifiedFilesFromTranscript(transcriptPath, 0, tmpDir+"/nonexistent") + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromTranscript() 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 TestExtractAllModifiedFilesFromTranscript_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 := ExtractAllModifiedFilesFromTranscript(transcriptPath, 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromTranscript() 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) + } +} + +// mustMarshal is a test helper that marshals a value to JSON or fails the test. +func mustMarshal(t *testing.T, v interface{}) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return data +} + +// 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) + } +} + +// makeFileToolLine returns a Droid-format JSONL line with a file-modifying tool_use. +func makeFileToolLine(t *testing.T, toolName, id, filePath string) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "assistant", + "content": []map[string]interface{}{ + { + "type": "tool_use", + "id": "toolu_" + id, + "name": toolName, + "input": map[string]string{"file_path": filePath}, + }, + }, + }) + line := mustMarshal(t, map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} + +// makeWriteToolLine returns a Droid-format JSONL line with a Write tool_use for the given file. +func makeWriteToolLine(t *testing.T, id, filePath string) string { + t.Helper() + return makeFileToolLine(t, "Write", id, filePath) +} + +// makeEditToolLine returns a Droid-format JSONL line with an Edit tool_use for the given file. +func makeEditToolLine(t *testing.T, id, filePath string) string { + t.Helper() + return makeFileToolLine(t, "Edit", id, filePath) +} + +// makeTaskToolUseLine returns a Droid-format JSONL line with a Task tool_use (spawning a subagent). +func makeTaskToolUseLine(t *testing.T, id, toolUseID string) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "assistant", + "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": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} + +// makeTaskResultLine returns a Droid-format JSONL user line with a tool_result containing agentId. +func makeTaskResultLine(t *testing.T, id, toolUseID, agentID string) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "user", + "content": []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": toolUseID, + "content": "agentId: " + agentID, + }, + }, + }) + line := mustMarshal(t, map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} diff --git a/cmd/entire/cli/agent/factoryaidroid/types.go b/cmd/entire/cli/agent/factoryaidroid/types.go new file mode 100644 index 000000000..e392ec521 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/types.go @@ -0,0 +1,91 @@ +package factoryaidroid + +import "encoding/json" + +// FactorySettings represents the .factory/settings.json structure. +type FactorySettings struct { + Hooks FactoryHooks `json:"hooks"` +} + +// FactoryHooks contains the hook configurations. +type FactoryHooks struct { + SessionStart []FactoryHookMatcher `json:"SessionStart,omitempty"` + SessionEnd []FactoryHookMatcher `json:"SessionEnd,omitempty"` + UserPromptSubmit []FactoryHookMatcher `json:"UserPromptSubmit,omitempty"` + Stop []FactoryHookMatcher `json:"Stop,omitempty"` + PreToolUse []FactoryHookMatcher `json:"PreToolUse,omitempty"` + PostToolUse []FactoryHookMatcher `json:"PostToolUse,omitempty"` + PreCompact []FactoryHookMatcher `json:"PreCompact,omitempty"` +} + +// FactoryHookMatcher matches hooks to specific patterns. +type FactoryHookMatcher struct { + Matcher string `json:"matcher"` + Hooks []FactoryHookEntry `json:"hooks"` +} + +// FactoryHookEntry represents a single hook command. +type FactoryHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop/SubagentStop/PreCompact hooks. +type sessionInfoRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` +} + +// userPromptSubmitRaw is the JSON structure from UserPromptSubmit hooks. +type userPromptSubmitRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt"` +} + +// taskHookInputRaw is the JSON structure from PreToolUse[Task] hook. +type taskHookInputRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` +} + +// postToolHookInputRaw is the JSON structure from PostToolUse[Task] hook. +type postToolHookInputRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse struct { + AgentID string `json:"agentId"` + } `json:"tool_response"` +} + +// Tool names used in Factory Droid transcripts. +const ( + ToolWrite = "Write" + ToolEdit = "Edit" + ToolNotebookEdit = "NotebookEdit" +) + +// FileModificationTools lists tools that create or modify files. +var FileModificationTools = []string{ + ToolWrite, + ToolEdit, + ToolNotebookEdit, +} + +// messageUsage represents token usage from an API response. +type messageUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// messageWithUsage represents an assistant message with usage data. +type messageWithUsage struct { + ID string `json:"id"` + Usage messageUsage `json:"usage"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 0be89d8b6..05533be37 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -91,15 +91,17 @@ type AgentType string // Agent name constants (registry keys) const ( - AgentNameClaudeCode AgentName = "claude-code" - AgentNameGemini AgentName = "gemini" + AgentNameClaudeCode AgentName = "claude-code" + AgentNameFactoryAIDroid AgentName = "factoryai-droid" + AgentNameGemini AgentName = "gemini" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( - AgentTypeClaudeCode AgentType = "Claude Code" - AgentTypeGemini AgentType = "Gemini CLI" - AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility + AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeFactoryAIDroid AgentType = "Factory AI Droid" + AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) // DefaultAgentName is the registry key for the default agent. diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 6eb704cbb..5e7fb4135 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -11,8 +11,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" - // Import claudecode to register the agent + // Import agents to register them _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" ) // Package-level aliases to avoid shadowing the settings package with local variables named "settings". diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index 89fd8c191..93ae0794f 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -19,6 +19,9 @@ const AgentNameClaudeCode = "claude-code" // AgentNameGemini is the name for Gemini CLI agent. const AgentNameGemini = "gemini" +// AgentNameFactoryAIDroid is the name for Factory AI Droid agent. +const AgentNameFactoryAIDroid = "factoryai-droid" + // AgentRunner abstracts invoking a coding agent for e2e tests. // This follows the multi-agent pattern from cmd/entire/cli/agent/agent.go. type AgentRunner interface { @@ -58,6 +61,8 @@ func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { return NewClaudeCodeRunner(config) case AgentNameGemini: return NewGeminiCLIRunner(config) + case AgentNameFactoryAIDroid: + return NewFactoryAIDroidRunner(config) default: // Return a runner that reports as unavailable return &unavailableRunner{name: name} @@ -324,3 +329,113 @@ func (r *GeminiCLIRunner) RunPromptWithTools(ctx context.Context, workDir string result.ExitCode = 0 return result, nil } + +// FactoryAIDroidRunner implements AgentRunner for Factory AI Droid CLI. +type FactoryAIDroidRunner struct { + Model string + Timeout time.Duration + AutoLevel string +} + +// NewFactoryAIDroidRunner creates a new Factory AI Droid runner with the given config. +func NewFactoryAIDroidRunner(config AgentRunnerConfig) *FactoryAIDroidRunner { + model := config.Model + if model == "" { + model = os.Getenv("E2E_DROID_MODEL") + // No default model — use droid's built-in default if not specified + } + + timeout := config.Timeout + if timeout == 0 { + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if parsed, err := time.ParseDuration(envTimeout); err == nil { + timeout = parsed + } + } + if timeout == 0 { + timeout = 2 * time.Minute + } + } + + return &FactoryAIDroidRunner{ + Model: model, + Timeout: timeout, + AutoLevel: "medium", + } +} + +func (r *FactoryAIDroidRunner) Name() string { + return AgentNameFactoryAIDroid +} + +// IsAvailable checks if droid CLI is installed and FACTORY_API_KEY is set. +// Droid uses API key authentication, not OAuth. +func (r *FactoryAIDroidRunner) IsAvailable() (bool, error) { + if _, err := exec.LookPath("droid"); err != nil { + return false, fmt.Errorf("droid CLI not found in PATH: %w", err) + } + + if os.Getenv("FACTORY_API_KEY") == "" { + return false, fmt.Errorf("FACTORY_API_KEY environment variable not set") + } + + return true, nil +} + +func (r *FactoryAIDroidRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, nil) +} + +func (r *FactoryAIDroidRunner) RunPromptWithTools(ctx context.Context, workDir string, prompt string, tools []string) (*AgentResult, error) { + args := []string{ + "exec", + "--cwd", workDir, + "--auto", r.AutoLevel, + "-o", "text", + } + + if len(tools) > 0 { + args = append(args, "--enabled-tools", strings.Join(tools, ",")) + } + + if r.Model != "" { + args = append(args, "-m", r.Model) + } + + args = append(args, prompt) + + ctx, cancel := context.WithTimeout(ctx, r.Timeout) + defer cancel() + + //nolint:gosec // args are constructed from trusted config, not user input + cmd := exec.CommandContext(ctx, "droid", args...) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + result := &AgentResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: duration, + } + + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + //nolint:wrapcheck // error is from exec.Run, caller can check ExitCode in result + return result, err + } + + result.ExitCode = 0 + return result, nil +} diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..748d964f9 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,6 +4,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" diff --git a/cmd/entire/cli/integration_test/agent_strategy_test.go b/cmd/entire/cli/integration_test/agent_strategy_test.go index 6dd9f7bd6..16b6ea748 100644 --- a/cmd/entire/cli/integration_test/agent_strategy_test.go +++ b/cmd/entire/cli/integration_test/agent_strategy_test.go @@ -361,3 +361,109 @@ func TestSetupAgentFlag(t *testing.T) { // Agent field may be omitted if default } } + +// TestFactoryAIDroidAgentStrategyComposition verifies that the Factory AI Droid agent +// works correctly with each strategy. This tests the full hook-based flow: +// agent hooks dispatch → lifecycle dispatcher → strategy saves checkpoint. +// +// Note: We use InitEntire (not InitEntireWithAgent) because the agent is determined +// by the hook command routing (entire hooks factoryai-droid ...), not by settings.json. +// EntireSettings doesn't have an "agent" field — the CLI subprocess determines the agent +// from the hook subcommand path. +func TestFactoryAIDroidAgentStrategyComposition(t *testing.T) { + t.Parallel() + + for _, strat := range AllStrategies() { + strat := strat // capture for parallel + t.Run(strat, func(t *testing.T) { + t.Parallel() + + // Set up repo with the specific strategy + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire(strat) + + // Create initial commit + env.WriteFile(".gitignore", ".entire/\n") + env.WriteFile("README.md", "# Test Repository") + env.GitAdd(".gitignore") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Create feature branch + env.GitCheckoutNewBranch("feature/droid-test") + + // Create a Droid session with Droid-envelope transcript + session := env.NewFactoryDroidSession() + env.WriteFile("feature.go", "package main\n// new feature") + session.CreateDroidTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, + }) + + // Simulate session flow: UserPromptSubmit → Stop + if err := env.SimulateFactoryDroidUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateFactoryDroidUserPromptSubmit error = %v", err) + } + + if err := env.SimulateFactoryDroidStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateFactoryDroidStop error = %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after Stop hook") + } + }) + } +} + +// TestFactoryAIDroidSessionIDTransformation verifies session ID transformation and rewind +// across the agent/strategy boundary for Factory AI Droid. +func TestFactoryAIDroidSessionIDTransformation(t *testing.T) { + t.Parallel() + + for _, strat := range AllStrategies() { + strat := strat + t.Run(strat, func(t *testing.T) { + t.Parallel() + + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire(strat) + + env.WriteFile(".gitignore", ".entire/\n") + env.WriteFile("README.md", "# Test") + env.GitAdd(".gitignore") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/droid-rewind") + + // Create session + session := env.NewFactoryDroidSession() + env.WriteFile("test.go", "package main") + session.CreateDroidTranscript("Test", []FileChange{ + {Path: "test.go", Content: "package main"}, + }) + + // Simulate hooks + if err := env.SimulateFactoryDroidUserPromptSubmit(session.ID); err != nil { + t.Fatalf("UserPromptSubmit error = %v", err) + } + if err := env.SimulateFactoryDroidStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("Stop error = %v", err) + } + + // Get rewind points and verify we can rewind + points := env.GetRewindPoints() + if len(points) == 0 { + t.Skip("no rewind points created") + } + + // Rewind should work + if err := env.Rewind(points[0].ID); err != nil { + t.Errorf("Rewind() error = %v", err) + } + }) + } +} diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index 92fe48865..0f14d814b 100644 --- a/cmd/entire/cli/integration_test/agent_test.go +++ b/cmd/entire/cli/integration_test/agent_test.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/transcript" ) @@ -818,3 +819,354 @@ func TestGeminiCLIHelperMethods(t *testing.T) { }) } + +// TestFactoryAIDroidAgentDetection verifies Factory AI Droid agent detection. +// Not parallel - contains subtests that use os.Chdir which is process-global. +func TestFactoryAIDroidAgentDetection(t *testing.T) { + + t.Run("agent is registered", func(t *testing.T) { + t.Parallel() + + agents := agent.List() + found := false + for _, name := range agents { + if name == "factoryai-droid" { + found = true + break + } + } + if !found { + t.Errorf("agent.List() = %v, want to contain 'factoryai-droid'", agents) + } + }) + + t.Run("detects presence when .factory exists", func(t *testing.T) { + // Not parallel - uses os.Chdir which is process-global + env := NewTestEnv(t) + env.InitRepo() + + // Create .factory directory + factoryDir := filepath.Join(env.RepoDir, ".factory") + if err := os.MkdirAll(factoryDir, 0o755); err != nil { + t.Fatalf("failed to create .factory dir: %v", err) + } + + // Change to repo dir for detection + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("factoryai-droid") + if err != nil { + t.Fatalf("Get(factoryai-droid) error = %v", err) + } + + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true when .factory exists") + } + }) +} + +// TestFactoryAIDroidHookInstallation verifies hook installation via Factory AI Droid agent interface. +// Note: These tests cannot run in parallel because they use os.Chdir which affects the entire process. +func TestFactoryAIDroidHookInstallation(t *testing.T) { + // Not parallel - tests use os.Chdir which is process-global + + t.Run("installs all required hooks", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + // Change to repo dir + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("factoryai-droid") + if err != nil { + t.Fatalf("Get(factoryai-droid) error = %v", err) + } + + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + t.Fatal("factoryai-droid agent does not implement HookSupport") + } + + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Should install 7 hooks: SessionStart, SessionEnd, Stop, UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) + } + + // Verify hooks are installed + if !hookAgent.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false after InstallHooks()") + } + + // Verify settings.json was created + settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName) + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("settings.json was not created") + } + + // Verify hooks structure in settings.json + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + content := string(data) + + // Verify all hook types are present + if !strings.Contains(content, "SessionStart") { + t.Error("settings.json should contain SessionStart hook") + } + if !strings.Contains(content, "SessionEnd") { + t.Error("settings.json should contain SessionEnd hook") + } + if !strings.Contains(content, "Stop") { + t.Error("settings.json should contain Stop hook") + } + if !strings.Contains(content, "UserPromptSubmit") { + t.Error("settings.json should contain UserPromptSubmit hook") + } + if !strings.Contains(content, "PreToolUse") { + t.Error("settings.json should contain PreToolUse hook") + } + if !strings.Contains(content, "PostToolUse") { + t.Error("settings.json should contain PostToolUse hook") + } + if !strings.Contains(content, "PreCompact") { + t.Error("settings.json should contain PreCompact hook") + } + + // Verify permissions.deny contains metadata deny rule + if !strings.Contains(content, "Read(./.entire/metadata/**)") { + t.Error("settings.json should contain permissions.deny rule for .entire/metadata/**") + } + }) + + t.Run("idempotent - second install returns 0", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("factoryai-droid") + hookAgent := ag.(agent.HookSupport) + + // First install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install should be idempotent + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count) + } + }) + + t.Run("localDev mode uses go run", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("factoryai-droid") + hookAgent := ag.(agent.HookSupport) + + _, err := hookAgent.InstallHooks(true, false) // localDev = true + if err != nil { + t.Fatalf("InstallHooks(localDev=true) error = %v", err) + } + + // Read settings and verify commands use "go run" + settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + content := string(data) + if !strings.Contains(content, "go run") { + t.Error("localDev hooks should use 'go run', but settings.json doesn't contain it") + } + if !strings.Contains(content, "${FACTORY_PROJECT_DIR}") { + t.Error("localDev hooks should use '${FACTORY_PROJECT_DIR}', but settings.json doesn't contain it") + } + }) + + t.Run("production mode uses entire binary", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("factoryai-droid") + hookAgent := ag.(agent.HookSupport) + + _, err := hookAgent.InstallHooks(false, false) // localDev = false + if err != nil { + t.Fatalf("InstallHooks(localDev=false) error = %v", err) + } + + // Read settings and verify commands use "entire" binary + settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + content := string(data) + if !strings.Contains(content, "entire hooks factoryai-droid") { + t.Error("production hooks should use 'entire hooks factoryai-droid', but settings.json doesn't contain it") + } + }) + + t.Run("force flag reinstalls hooks", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("factoryai-droid") + hookAgent := ag.(agent.HookSupport) + + // First install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should return count > 0 + count, err := hookAgent.InstallHooks(false, true) // force = true + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 7 { + t.Errorf("force InstallHooks() count = %d, want 7", count) + } + }) +} + +// TestFactoryAIDroidHelperMethods verifies Factory Droid-specific helper methods. +func TestFactoryAIDroidHelperMethods(t *testing.T) { + t.Parallel() + + t.Run("FormatResumeCommand returns droid --session-id", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + cmd := ag.FormatResumeCommand("abc123") + + if cmd != "droid --session-id abc123" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "droid --session-id abc123") + } + }) + + t.Run("GetHookConfigPath returns .factory/settings.json", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + droid, ok := ag.(*factoryaidroid.FactoryAIDroidAgent) + if !ok { + t.Fatal("agent is not *factoryaidroid.FactoryAIDroidAgent") + } + path := droid.GetHookConfigPath() + + if path != ".factory/settings.json" { + t.Errorf("GetHookConfigPath() = %q, want %q", path, ".factory/settings.json") + } + }) +} + +// TestFactoryAIDroidSessionStubs verifies that stub methods return not-implemented errors. +func TestFactoryAIDroidSessionStubs(t *testing.T) { + t.Parallel() + + t.Run("ReadSession returns not-implemented error", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + _, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: "/tmp/test.jsonl", + }) + if err == nil { + t.Error("ReadSession() should return an error for Factory AI Droid") + } + if !strings.Contains(err.Error(), "not implemented") { + t.Errorf("ReadSession() error = %q, want to contain 'not implemented'", err.Error()) + } + }) + + t.Run("WriteSession returns not-implemented error", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + err := ag.WriteSession(&agent.AgentSession{ + SessionID: "test", + AgentName: "factoryai-droid", + SessionRef: "/tmp/test.jsonl", + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should return an error for Factory AI Droid") + } + if !strings.Contains(err.Error(), "not implemented") { + t.Errorf("WriteSession() error = %q, want to contain 'not implemented'", err.Error()) + } + }) + + t.Run("GetSessionDir returns factory sessions path", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + dir, err := ag.GetSessionDir("/Users/test/my-project") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if !strings.Contains(dir, filepath.Join(".factory", "sessions")) { + t.Errorf("GetSessionDir() = %q, want to contain .factory/sessions", dir) + } + if !strings.HasSuffix(dir, "-Users-test-my-project") { + t.Errorf("GetSessionDir() = %q, want to end with sanitized path", dir) + } + }) +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index cdac30c4b..ce0ed02f2 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -751,3 +751,359 @@ func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) e runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath) } + +// FactoryDroidHookRunner executes Factory AI Droid hooks in the test environment. +type FactoryDroidHookRunner struct { + RepoDir string + T interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) + } +} + +// NewFactoryDroidHookRunner creates a new Factory Droid hook runner. +func NewFactoryDroidHookRunner(repoDir string, t interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) +}) *FactoryDroidHookRunner { + return &FactoryDroidHookRunner{ + RepoDir: repoDir, + T: t, + } +} + +// runDroidHookWithInput runs a Factory Droid hook with the given input. +func (r *FactoryDroidHookRunner) runDroidHookWithInput(hookName string, input interface{}) error { + r.T.Helper() + + inputJSON, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal hook input: %w", err) + } + + return r.runDroidHookInRepoDir(hookName, inputJSON) +} + +func (r *FactoryDroidHookRunner) runDroidHookInRepoDir(hookName string, inputJSON []byte) error { + cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = os.Environ() + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s", + hookName, err, inputJSON, output) + } + + r.T.Logf("Droid hook %s output: %s", hookName, output) + return nil +} + +// runDroidHookWithOutput runs a Factory Droid hook and returns both stdout and stderr separately. +func (r *FactoryDroidHookRunner) runDroidHookWithOutput(hookName string, inputJSON []byte) HookOutput { + cmd := exec.Command(getTestBinary(), "hooks", "factoryai-droid", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = os.Environ() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return HookOutput{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + Err: err, + } +} + +// SimulateUserPromptSubmit simulates the UserPromptSubmit hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulateUserPromptSubmit(sessionID string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + "prompt": "test prompt", + } + + return r.runDroidHookWithInput("user-prompt-submit", input) +} + +// SimulateUserPromptSubmitWithOutput simulates the UserPromptSubmit hook and returns the output. +func (r *FactoryDroidHookRunner) SimulateUserPromptSubmitWithOutput(sessionID string) HookOutput { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + "prompt": "test prompt", + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)} + } + + return r.runDroidHookWithOutput("user-prompt-submit", inputJSON) +} + +// SimulateStop simulates the Stop hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulateStop(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runDroidHookWithInput("stop", input) +} + +// SimulateSessionStart simulates the SessionStart hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulateSessionStart(sessionID string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + } + + return r.runDroidHookWithInput("session-start", input) +} + +// SimulateSessionStartWithOutput simulates the SessionStart hook and returns the output. +func (r *FactoryDroidHookRunner) SimulateSessionStartWithOutput(sessionID string) HookOutput { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)} + } + + return r.runDroidHookWithOutput("session-start", inputJSON) +} + +// SimulateSessionEnd simulates the SessionEnd hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulateSessionEnd(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + } + + return r.runDroidHookWithInput("session-end", input) +} + +// SimulatePreTask simulates the PreToolUse[Task] hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulatePreTask(sessionID, transcriptPath, toolUseID string) error { + r.T.Helper() + + input := map[string]interface{}{ + "session_id": sessionID, + "transcript_path": transcriptPath, + "tool_use_id": toolUseID, + "tool_input": map[string]string{ + "subagent_type": "general-purpose", + "description": "test task", + }, + } + + return r.runDroidHookWithInput("pre-tool-use", input) +} + +// SimulatePostTask simulates the PostToolUse[Task] hook for Factory Droid. +func (r *FactoryDroidHookRunner) SimulatePostTask(input PostTaskInput) error { + r.T.Helper() + + hookInput := map[string]interface{}{ + "session_id": input.SessionID, + "transcript_path": input.TranscriptPath, + "tool_use_id": input.ToolUseID, + "tool_input": map[string]string{}, + "tool_response": map[string]string{ + "agentId": input.AgentID, + }, + } + + return r.runDroidHookWithInput("post-tool-use", hookInput) +} + +// FactoryDroidSession represents a simulated Factory AI Droid session. +type FactoryDroidSession struct { + ID string + TranscriptPath string + env *TestEnv +} + +// NewFactoryDroidSession creates a new simulated Factory Droid session. +func (env *TestEnv) NewFactoryDroidSession() *FactoryDroidSession { + env.T.Helper() + + env.SessionCounter++ + sessionID := fmt.Sprintf("droid-session-%d", env.SessionCounter) + transcriptPath := filepath.Join(env.RepoDir, ".entire", "tmp", sessionID+".jsonl") + + return &FactoryDroidSession{ + ID: sessionID, + TranscriptPath: transcriptPath, + env: env, + } +} + +// CreateDroidTranscript creates a Droid-envelope JSONL transcript file. +// Droid wraps messages as {"type":"message","id":"...","message":{"role":"...","content":[...]}}, +// unlike Claude Code which uses {"type":"assistant","uuid":"...","message":{"content":[...]}}. +func (s *FactoryDroidSession) CreateDroidTranscript(prompt string, changes []FileChange) string { + var lines []map[string]interface{} + + // User message with prompt + lines = append(lines, map[string]interface{}{ + "type": "message", + "id": "m1", + "message": map[string]interface{}{ + "role": "user", + "content": []map[string]interface{}{ + {"type": "text", "text": prompt}, + }, + }, + }) + + // Assistant message with tool uses + assistantContent := []interface{}{ + map[string]interface{}{"type": "text", "text": "I'll help you with that."}, + } + for i, change := range changes { + assistantContent = append(assistantContent, map[string]interface{}{ + "type": "tool_use", + "id": fmt.Sprintf("toolu_%d", i+1), + "name": "Write", + "input": map[string]string{"file_path": change.Path, "content": change.Content}, + }) + } + lines = append(lines, map[string]interface{}{ + "type": "message", + "id": "m2", + "message": map[string]interface{}{ + "role": "assistant", + "content": assistantContent, + }, + }) + + // Tool results + toolResultContent := make([]map[string]interface{}, 0, len(changes)) + for i := range changes { + toolResultContent = append(toolResultContent, map[string]interface{}{ + "type": "tool_result", + "tool_use_id": fmt.Sprintf("toolu_%d", i+1), + "content": "Success", + }) + } + lines = append(lines, map[string]interface{}{ + "type": "message", + "id": "m3", + "message": map[string]interface{}{ + "role": "user", + "content": toolResultContent, + }, + }) + + // Final assistant message + lines = append(lines, map[string]interface{}{ + "type": "message", + "id": "m4", + "message": map[string]interface{}{ + "role": "assistant", + "content": []map[string]interface{}{ + {"type": "text", "text": "Done!"}, + }, + }, + }) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil { + s.env.T.Fatalf("failed to create transcript dir: %v", err) + } + + // Write as JSONL + file, err := os.Create(s.TranscriptPath) + if err != nil { + s.env.T.Fatalf("failed to create transcript file: %v", err) + } + defer func() { _ = file.Close() }() + + encoder := json.NewEncoder(file) + for _, line := range lines { + if err := encoder.Encode(line); err != nil { + s.env.T.Fatalf("failed to encode transcript line: %v", err) + } + } + + return s.TranscriptPath +} + +// SimulateFactoryDroidUserPromptSubmit is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidUserPromptSubmit(sessionID string) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateUserPromptSubmit(sessionID) +} + +// SimulateFactoryDroidUserPromptSubmitWithOutput is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidUserPromptSubmitWithOutput(sessionID string) HookOutput { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateUserPromptSubmitWithOutput(sessionID) +} + +// SimulateFactoryDroidStop is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidStop(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateStop(sessionID, transcriptPath) +} + +// SimulateFactoryDroidSessionStart is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidSessionStart(sessionID string) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateSessionStart(sessionID) +} + +// SimulateFactoryDroidSessionStartWithOutput is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidSessionStartWithOutput(sessionID string) HookOutput { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateSessionStartWithOutput(sessionID) +} + +// SimulateFactoryDroidSessionEnd is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidSessionEnd(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulateSessionEnd(sessionID, transcriptPath) +} + +// SimulateFactoryDroidPreTask is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidPreTask(sessionID, transcriptPath, toolUseID string) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulatePreTask(sessionID, transcriptPath, toolUseID) +} + +// SimulateFactoryDroidPostTask is a convenience method on TestEnv. +func (env *TestEnv) SimulateFactoryDroidPostTask(input PostTaskInput) error { + env.T.Helper() + runner := NewFactoryDroidHookRunner(env.RepoDir, env.T) + return runner.SimulatePostTask(input) +} diff --git a/cmd/entire/cli/integration_test/setup_factoryai_hooks_test.go b/cmd/entire/cli/integration_test/setup_factoryai_hooks_test.go new file mode 100644 index 000000000..43e2fbebc --- /dev/null +++ b/cmd/entire/cli/integration_test/setup_factoryai_hooks_test.go @@ -0,0 +1,170 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" +) + +// Use the real Factory types from the factoryaidroid package to avoid schema drift. +type FactorySettings = factoryaidroid.FactorySettings + +// TestSetupFactoryAIHooks_AddsAllRequiredHooks is a smoke test verifying that +// `entire enable --agent factoryai-droid` adds all required hooks to the correct file. +func TestSetupFactoryAIHooks_AddsAllRequiredHooks(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") // Sets up .entire/settings.json + + // Create initial commit (required for setup) + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Run entire enable --agent factoryai-droid (non-interactive) + output, err := env.RunCLIWithError("enable", "--agent", "factoryai-droid") + if err != nil { + t.Fatalf("enable factoryai-droid command failed: %v\nOutput: %s", err, output) + } + + // Read the generated settings.json + settings := readFactorySettingsFile(t, env) + + // Verify all hooks exist (7 total) + if len(settings.Hooks.SessionStart) == 0 { + t.Error("SessionStart hook should exist") + } + if len(settings.Hooks.SessionEnd) == 0 { + t.Error("SessionEnd hook should exist") + } + if len(settings.Hooks.Stop) == 0 { + t.Error("Stop hook should exist") + } + if len(settings.Hooks.UserPromptSubmit) == 0 { + t.Error("UserPromptSubmit hook should exist") + } + if len(settings.Hooks.PreToolUse) == 0 { + t.Error("PreToolUse hook should exist") + } + if len(settings.Hooks.PostToolUse) == 0 { + t.Error("PostToolUse hook should exist") + } + if len(settings.Hooks.PreCompact) == 0 { + t.Error("PreCompact hook should exist") + } + + // Verify permissions.deny contains metadata deny rule + settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + content := string(data) + if !strings.Contains(content, "Read(./.entire/metadata/**)") { + t.Error("settings.json should contain permissions.deny rule for .entire/metadata/**") + } +} + +// TestSetupFactoryAIHooks_PreservesExistingSettings is a smoke test verifying that +// enable factoryai-droid doesn't nuke existing settings or user-configured hooks. +func TestSetupFactoryAIHooks_PreservesExistingSettings(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire("manual-commit") + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Create existing settings with custom fields and user hooks + factoryDir := filepath.Join(env.RepoDir, ".factory") + if err := os.MkdirAll(factoryDir, 0o755); err != nil { + t.Fatalf("failed to create .factory dir: %v", err) + } + + existingSettings := `{ + "customSetting": "should-be-preserved", + "hooks": { + "Stop": [ + { + "matcher": "", + "hooks": [{"type": "command", "command": "echo user-stop-hook"}] + } + ] + } +}` + settingsPath := filepath.Join(factoryDir, factoryaidroid.FactorySettingsFileName) + if err := os.WriteFile(settingsPath, []byte(existingSettings), 0o644); err != nil { + t.Fatalf("failed to write existing settings: %v", err) + } + + // Run enable factoryai-droid + output, err := env.RunCLIWithError("enable", "--agent", "factoryai-droid") + if err != nil { + t.Fatalf("enable factoryai-droid failed: %v\nOutput: %s", err, output) + } + + // Verify custom setting is preserved + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var rawSettings map[string]interface{} + if err := json.Unmarshal(data, &rawSettings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + + if rawSettings["customSetting"] != "should-be-preserved" { + t.Error("customSetting should be preserved after enable factoryai-droid") + } + + // Verify user hooks are preserved + settings := readFactorySettingsFile(t, env) + + // User's Stop hook should still exist alongside our hook + foundUserHook := false + for _, matcher := range settings.Hooks.Stop { + for _, hook := range matcher.Hooks { + if hook.Command == "echo user-stop-hook" { + foundUserHook = true + } + } + } + if !foundUserHook { + t.Error("existing user hook 'echo user-stop-hook' should be preserved") + } + + // Our hooks should also be added + if len(settings.Hooks.SessionStart) == 0 { + t.Error("SessionStart hook should be added") + } + if len(settings.Hooks.UserPromptSubmit) == 0 { + t.Error("UserPromptSubmit hook should be added") + } +} + +// Helper functions + +func readFactorySettingsFile(t *testing.T, env *TestEnv) FactorySettings { + t.Helper() + settingsPath := filepath.Join(env.RepoDir, ".factory", factoryaidroid.FactorySettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read %s at %s: %v", factoryaidroid.FactorySettingsFileName, settingsPath, err) + } + + var settings FactorySettings + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + return settings +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9c34d9041..751929489 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -446,6 +446,8 @@ func handleLifecycleCompaction(ag agent.Agent, event *agent.Event) error { } // handleLifecycleSessionEnd handles session end: marks the session as ended. +// Idempotent: if the session is already ended (e.g., some agents fire this hook +// twice), the second call is a no-op. func handleLifecycleSessionEnd(ag agent.Agent, event *agent.Event) error { logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "lifecycle"), ag.Name()) logging.Info(logCtx, "session-end", @@ -457,6 +459,14 @@ func handleLifecycleSessionEnd(ag agent.Agent, event *agent.Event) error { return nil // No session to update } + // Skip if session is already ended (some agents fire SessionEnd twice) + if state, err := strategy.LoadSessionState(event.SessionID); err == nil && state != nil && state.Phase == session.PhaseEnded { + logging.Debug(logCtx, "session already ended, skipping duplicate session-end", + slog.String("session_id", event.SessionID), + ) + return nil + } + if err := markSessionEnded(event.SessionID); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to mark session ended: %v\n", err) } diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 5ee5ced3e..07233d451 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -18,6 +18,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/session" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/go-git/go-git/v5" @@ -972,6 +973,7 @@ func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent SessionID: sessionID, CLIVersion: buildinfo.Version, BaseCommit: baseCommit, + Phase: session.PhaseIdle, StartedAt: now, LastInteractionTime: &now, TurnID: turnID.String(), diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index fc24205d7..274014951 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -502,6 +503,26 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { return nil } + // Droid has its own envelope format — use its parser to normalize first + if agentType == agent.AgentTypeFactoryAIDroid { + lines, err := factoryaidroid.ParseDroidTranscriptFromBytes([]byte(content)) + if err != nil { + return nil + } + var prompts []string + for _, line := range lines { + if line.Type != transcript.TypeUser { + continue + } + if text := transcript.ExtractUserContent(line.Message); text != "" { + if stripped := textutil.StripIDEContextTags(text); stripped != "" { + prompts = append(prompts, stripped) + } + } + } + return prompts + } + // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { prompts, err := geminicli.ExtractAllUserPrompts([]byte(content)) @@ -535,6 +556,18 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } + // Droid has its own envelope format — use its parser to normalize first + if agentType == agent.AgentTypeFactoryAIDroid { + lines, err := factoryaidroid.ParseDroidTranscriptFromBytes(data) + if err != nil || len(lines) == 0 { + return &agent.TokenUsage{} + } + if startOffset > 0 && startOffset < len(lines) { + lines = lines[startOffset:] + } + return factoryaidroid.CalculateTokenUsage(lines) + } + // Try Gemini format first if agentType is Gemini, or as fallback if Unknown if agentType == agent.AgentTypeGemini || agentType == agent.AgentTypeUnknown { // Attempt to parse as Gemini JSON diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index e9e9a6d3a..8aa1832b2 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -10,6 +10,7 @@ import ( "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" + "github.com/entireio/cli/cmd/entire/cli/session" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -231,6 +232,7 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID AttributionBaseCommit: headHash, WorktreePath: worktreePath, WorktreeID: worktreeID, + Phase: session.PhaseIdle, StartedAt: now, LastInteractionTime: &now, TurnID: turnID.String(), diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..c03ef2a51 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -116,6 +117,13 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) + case agent.AgentTypeFactoryAIDroid: + // Droid has its own envelope format — normalize before condensing + droidLines, err := factoryaidroid.ParseDroidTranscriptFromBytes(content) + if err != nil { + return nil, fmt.Errorf("failed to parse Droid transcript: %w", err) + } + return BuildCondensedTranscript(droidLines), nil case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: // Claude format - fall through to shared logic below }