diff --git a/.github/workflows/e2e-isolated.yml b/.github/workflows/e2e-isolated.yml index e28c270da..0b17a7a53 100644 --- a/.github/workflows/e2e-isolated.yml +++ b/.github/workflows/e2e-isolated.yml @@ -8,7 +8,7 @@ on: required: true default: "gemini" type: choice - options: [claude, opencode, gemini] + options: [claude, opencode, gemini, factoryai-droid] test: description: "Test name filter (regex)" required: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 188d7ab1f..feb120ce9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - agent: [claude, opencode] + agent: [claude, opencode, factoryai-droid] steps: - name: Checkout repository @@ -33,6 +33,7 @@ jobs: case "${{ matrix.agent }}" in claude) curl -fsSL https://claude.ai/install.sh | bash ;; opencode) curl -fsSL https://opencode.ai/install | bash ;; + factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;; esac echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/CLAUDE.md b/CLAUDE.md index dfdac7b2f..a37a2cf72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ E2E tests: - Located in `cmd/entire/cli/e2e_test/` - Test real agent interactions (Claude Code, Gemini CLI, or OpenCode creating files, committing, etc.) - Validate checkpoint scenarios documented in `docs/architecture/checkpoint-scenarios.md` -- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`) +- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`, `factoryai-droid`) **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..c718e022f --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go @@ -0,0 +1,174 @@ +// Package factoryaidroid implements the Agent interface for Factory AI Droid. +package factoryaidroid + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "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.WorktreeRoot() + 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 +} + +// 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 reads a session from Factory AI Droid's storage (JSONL transcript file). +// The session data is stored in NativeData as raw JSONL bytes. +// ModifiedFiles is computed by parsing the transcript. +func (f *FactoryAIDroidAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + lines, err := ParseDroidTranscriptFromBytes(data, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: f.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to Factory AI Droid's storage (JSONL transcript file). +// Uses the NativeData field which contains raw JSONL bytes. +func (f *FactoryAIDroidAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != f.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, f.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// 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..f26622df3 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/factoryaidroid_test.go @@ -0,0 +1,345 @@ +package factoryaidroid + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// 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_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 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) + } +} + +// --- ReadSession / WriteSession tests --- + +func TestReadSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "transcript.jsonl") + + // Write a Droid-format JSONL transcript with a file-modifying tool call + content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"create a file"}]}} +{"type":"message","id":"msg2","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"hello.txt","content":"hi"}}]}}` + if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &FactoryAIDroidAgent{} + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session-123", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session-123" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session-123") + } + if session.AgentName != agent.AgentNameFactoryAIDroid { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameFactoryAIDroid) + } + if session.SessionRef != transcriptPath { + t.Errorf("SessionRef = %q, want %q", session.SessionRef, transcriptPath) + } + if len(session.NativeData) == 0 { + t.Error("NativeData should not be empty") + } + if len(session.ModifiedFiles) == 0 { + t.Error("ModifiedFiles should contain at least one file") + } +} + +func TestReadSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ReadSession(&agent.HookInput{SessionID: "test"}) + if err == nil { + t.Error("ReadSession() should error on empty SessionRef") + } +} + +func TestReadSession_MissingFile(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + _, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: "/nonexistent/path/transcript.jsonl", + }) + if err == nil { + t.Error("ReadSession() should error on missing file") + } +} + +func TestWriteSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // Write to a nested path to test directory creation + transcriptPath := filepath.Join(tmpDir, "sessions", "project", "transcript.jsonl") + nativeData := []byte(`{"type":"message","id":"msg1","message":{"role":"user","content":"hello"}}`) + + ag := &FactoryAIDroidAgent{} + err := ag.WriteSession(&agent.AgentSession{ + SessionID: "test-session-456", + AgentName: agent.AgentNameFactoryAIDroid, + SessionRef: transcriptPath, + NativeData: nativeData, + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written correctly + written, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(written) != string(nativeData) { + t.Errorf("written data = %q, want %q", string(written), string(nativeData)) + } +} + +func TestWriteSession_NilSession(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + if err := ag.WriteSession(nil); err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + err := ag.WriteSession(&agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/tmp/test.jsonl", + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error for wrong agent name") + } +} + +func TestWriteSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + err := ag.WriteSession(&agent.AgentSession{ + AgentName: agent.AgentNameFactoryAIDroid, + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error on empty SessionRef") + } +} + +func TestWriteSession_EmptyNativeData(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + err := ag.WriteSession(&agent.AgentSession{ + AgentName: agent.AgentNameFactoryAIDroid, + SessionRef: "/tmp/test.jsonl", + }) + if err == nil { + t.Error("WriteSession() should error on empty NativeData") + } +} + +func TestReadWriteSession_RoundTrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + originalPath := filepath.Join(tmpDir, "original.jsonl") + restoredPath := filepath.Join(tmpDir, "restored.jsonl") + + content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}} +{"type":"message","id":"msg2","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}` + if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write original: %v", err) + } + + ag := &FactoryAIDroidAgent{} + + // Read from original location + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "round-trip-test", + SessionRef: originalPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Write to new location + session.SessionRef = restoredPath + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify content matches + restored, err := os.ReadFile(restoredPath) + if err != nil { + t.Fatalf("failed to read restored: %v", err) + } + if string(restored) != content { + t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content) + } +} + +// 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) + } +} diff --git a/cmd/entire/cli/agent/factoryaidroid/hooks.go b/cmd/entire/cli/agent/factoryaidroid/hooks.go new file mode 100644 index 000000000..a54675d3e --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/hooks.go @@ -0,0 +1,472 @@ +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 +var _ agent.HookSupport = (*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/**)" + +// 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.WorktreeRoot() + 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 WorktreeRoot() 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 = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(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++ + } + // Also install user-prompt-submit on SessionStart to ensure TurnStart fires + // even when UserPromptSubmit doesn't (e.g., droid exec mode). + // The user-prompt-submit handler gracefully handles SessionStart's stdin format + // (userPromptSubmitRaw is a superset of sessionInfoRaw; Prompt defaults to ""). + if !hookCommandExists(sessionStart, userPromptSubmitCmd) { + sessionStart = addHookToMatcher(sessionStart, "", userPromptSubmitCmd) + 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.WorktreeRoot() + 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 = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(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.WorktreeRoot() + 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") +} + +// 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} + for i := range matchers { + if matchers[i].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 +} 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..d4f547429 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/hooks_test.go @@ -0,0 +1,736 @@ +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) + } + + // 8 hooks: SessionStart (session-start + user-prompt-submit), SessionEnd, Stop, + // UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact + if count != 8 { + t.Errorf("InstallHooks() count = %d, want 8", 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.SessionStart, "", "entire hooks factoryai-droid user-prompt-submit", "SessionStart user-prompt-submit") + 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 != 8 { + t.Errorf("first InstallHooks() count = %d, want 8", 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.SessionStart, "", + "go run ${FACTORY_PROJECT_DIR}/cmd/entire/main.go hooks factoryai-droid user-prompt-submit", "SessionStart user-prompt-submit 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 != 8 { + t.Errorf("force InstallHooks() count = %d, want 8", 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") + assertFactoryHookExists(t, matchers, "", "entire hooks factoryai-droid user-prompt-submit", "Entire SessionStart user-prompt-submit 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..571c371aa --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go @@ -0,0 +1,252 @@ +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. +// These become subcommands: entire hooks factoryai-droid +func (f *FactoryAIDroidAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNameUserPromptSubmit, + HookNamePreToolUse, + HookNamePostToolUse, + HookNameSubagentStop, + HookNamePreCompact, + HookNameNotification, + } +} + +// 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, 0) + 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..871f7c791 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go @@ -0,0 +1,216 @@ +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) + } +} + +// TestParseHookEvent_TurnStart_SessionStartFormat verifies that parseTurnStart +// handles SessionStart-format stdin (no "prompt" field). This happens when +// user-prompt-submit is installed on the SessionStart event type to ensure +// TurnStart fires in droid exec mode. +func TestParseHookEvent_TurnStart_SessionStartFormat(t *testing.T) { + t.Parallel() + + ag := &FactoryAIDroidAgent{} + // SessionStart-format stdin: only session_id and transcript_path, no prompt + input := `{"session_id": "exec-sess", "transcript_path": "/tmp/exec.jsonl"}` + + 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.SessionID != "exec-sess" { + t.Errorf("expected session_id 'exec-sess', got %q", event.SessionID) + } + if event.SessionRef != "/tmp/exec.jsonl" { + t.Errorf("expected transcript_path '/tmp/exec.jsonl', got %q", event.SessionRef) + } + if event.Prompt != "" { + t.Errorf("expected empty prompt, 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_PassThroughHooks(t *testing.T) { + t.Parallel() + + passThroughHooks := []string{ + HookNameSubagentStop, + HookNameNotification, + } + + for _, hookName := range passThroughHooks { + t.Run(hookName, func(t *testing.T) { + t.Parallel() + ag := &FactoryAIDroidAgent{} + event, err := ag.ParseHookEvent(hookName, strings.NewReader(`{"session_id":"s"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for %s, got %+v", hookName, 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..a3d52b594 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/transcript.go @@ -0,0 +1,390 @@ +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. +// startLine skips the first N raw JSONL lines before parsing (0 = parse all). +// This mirrors ParseDroidTranscript's startLine parameter, applying the offset +// at the raw line level before filtering out non-message entries. +func ParseDroidTranscriptFromBytes(content []byte, startLine int) ([]transcript.Line, error) { + lines, _, err := parseDroidTranscriptFromReader(bytes.NewReader(content), startLine) + 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 +} + +// 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..177932ad4 --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/transcript_test.go @@ -0,0 +1,1143 @@ +package factoryaidroid + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +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, 0) + 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 TestParseDroidTranscriptFromBytes_StartLineSkipsNonMessageEntries(t *testing.T) { + t.Parallel() + + // Transcript: session_start(0), message(1), session_event(2), message(3), message(4) + // Raw line indices: 0 1 2 3 4 + data := []byte( + `{"type":"session_start","id":"s1"}` + "\n" + + `{"type":"message","id":"m1","message":{"role":"user","content":"hello"}}` + "\n" + + `{"type":"session_event","data":"some event"}` + "\n" + + `{"type":"message","id":"m2","message":{"role":"assistant","content":"hi"}}` + "\n" + + `{"type":"message","id":"m3","message":{"role":"user","content":"bye"}}` + "\n", + ) + + // With startLine=0, all 3 messages should be returned + allLines, err := ParseDroidTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes(0) error = %v", err) + } + if len(allLines) != 3 { + t.Fatalf("startLine=0: got %d lines, want 3", len(allLines)) + } + + // With startLine=2, skip raw lines 0-1 (session_start + m1). + // Lines 2 (session_event) is skipped by filter, lines 3-4 (m2, m3) are messages. + fromLine2, err := ParseDroidTranscriptFromBytes(data, 2) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes(2) error = %v", err) + } + if len(fromLine2) != 2 { + t.Fatalf("startLine=2: got %d lines, want 2", len(fromLine2)) + } + if fromLine2[0].UUID != "m2" { + t.Errorf("startLine=2: lines[0].UUID = %q, want \"m2\"", fromLine2[0].UUID) + } + if fromLine2[1].UUID != "m3" { + t.Errorf("startLine=2: lines[1].UUID = %q, want \"m3\"", fromLine2[1].UUID) + } + + // With startLine=3, skip raw lines 0-2 (session_start + m1 + session_event). + // Lines 3-4 (m2, m3) are messages. + fromLine3, err := ParseDroidTranscriptFromBytes(data, 3) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes(3) error = %v", err) + } + if len(fromLine3) != 2 { + t.Fatalf("startLine=3: got %d lines, want 2", len(fromLine3)) + } + if fromLine3[0].UUID != "m2" { + t.Errorf("startLine=3: lines[0].UUID = %q, want \"m2\"", fromLine3[0].UUID) + } + + // With startLine beyond end, should return no lines + beyondEnd, err := ParseDroidTranscriptFromBytes(data, 100) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes(100) error = %v", err) + } + if len(beyondEnd) != 0 { + t.Fatalf("startLine=100: got %d lines, want 0", len(beyondEnd)) + } +} + +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, 0) + 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, 0) + 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_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 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) +} + +// makeUserTextLine returns a Droid-format JSONL line with a user text message (array content). +func makeUserTextLine(t *testing.T, id, text string) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "user", + "content": []map[string]interface{}{ + {"type": "text", "text": text}, + }, + }) + line := mustMarshal(t, map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} + +// makeAssistantTextLine returns a Droid-format JSONL line with an assistant text message. +func makeAssistantTextLine(t *testing.T, id, text string) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "assistant", + "content": []map[string]interface{}{ + {"type": "text", "text": text}, + }, + }) + line := mustMarshal(t, map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} + +// makeAssistantTokenLine returns a Droid-format JSONL line with an assistant message that has usage data. +func makeAssistantTokenLine(t *testing.T, id, msgID string, inputTokens, outputTokens int) string { + t.Helper() + innerMsg := mustMarshal(t, map[string]interface{}{ + "role": "assistant", + "id": msgID, + "usage": map[string]int{ + "input_tokens": inputTokens, + "output_tokens": outputTokens, + }, + }) + line := mustMarshal(t, map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(innerMsg), + }) + return string(line) +} + +func TestExtractPrompts(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + writeJSONLFile(t, transcriptPath, + makeUserTextLine(t, "u1", "Fix the login bug"), + makeAssistantTextLine(t, "a1", "I'll fix the login bug."), + makeUserTextLine(t, "u2", "Now add tests"), + ) + + ag := &FactoryAIDroidAgent{} + prompts, err := ag.ExtractPrompts(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 2 { + t.Fatalf("ExtractPrompts() got %d prompts, want 2", len(prompts)) + } + if prompts[0] != "Fix the login bug" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Fix the login bug") + } + if prompts[1] != "Now add tests" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "Now add tests") + } +} + +func TestExtractPrompts_StripsIDETags(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + // User message with IDE context tags injected by VSCode extension + promptWithTags := `/repo/main.goFix the bug` + writeJSONLFile(t, transcriptPath, + makeUserTextLine(t, "u1", promptWithTags), + ) + + ag := &FactoryAIDroidAgent{} + prompts, err := ag.ExtractPrompts(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts)) + } + if prompts[0] != "Fix the bug" { + t.Errorf("prompts[0] = %q, want %q (IDE tags should be stripped)", prompts[0], "Fix the bug") + } +} + +func TestExtractPrompts_WithOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + writeJSONLFile(t, transcriptPath, + makeUserTextLine(t, "u1", "First prompt"), + makeAssistantTextLine(t, "a1", "Done."), + makeUserTextLine(t, "u2", "Second prompt"), + makeAssistantTextLine(t, "a2", "Done again."), + ) + + ag := &FactoryAIDroidAgent{} + // Skip first 2 lines (first user+assistant turn) + prompts, err := ag.ExtractPrompts(transcriptPath, 2) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts)) + } + if prompts[0] != "Second prompt" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Second prompt") + } +} + +func TestExtractSummary(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + writeJSONLFile(t, transcriptPath, + makeUserTextLine(t, "u1", "Fix the bug"), + makeAssistantTextLine(t, "a1", "Working on it..."), + makeUserTextLine(t, "u2", "Thanks"), + makeAssistantTextLine(t, "a2", "All done! The login bug is fixed."), + ) + + ag := &FactoryAIDroidAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "All done! The login bug is fixed." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "All done! The login bug is fixed.") + } +} + +func TestExtractSummary_SkipsToolUseBlocks(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + // Last assistant message has tool_use (no text), second-to-last has text + writeJSONLFile(t, transcriptPath, + makeUserTextLine(t, "u1", "Edit main.go"), + makeAssistantTextLine(t, "a1", "I updated the file."), + makeWriteToolLine(t, "a2", "/repo/main.go"), + ) + + ag := &FactoryAIDroidAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + // Should find "I updated the file." since the tool_use message has no text block + if summary != "I updated the file." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "I updated the file.") + } +} + +func TestExtractSummary_EmptyTranscript(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + if err := os.WriteFile(transcriptPath, []byte(""), 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &FactoryAIDroidAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "" { + t.Errorf("ExtractSummary() = %q, want empty string", summary) + } +} + +func TestParseDroidTranscript_MalformedLines(t *testing.T) { + t.Parallel() + + // Transcript with some broken JSON lines interspersed with valid ones + data := []byte( + `{"type":"message","id":"m1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + "\n" + + `{"broken json` + "\n" + + `not even close to json` + "\n" + + `{"type":"message","id":"m2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}` + "\n" + + `{"type":"session_event","data":"ignored"}` + "\n", + ) + + lines, err := ParseDroidTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseDroidTranscriptFromBytes() error = %v", err) + } + + // Only the 2 valid "message" type lines should be parsed + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 (malformed lines should be silently skipped)", len(lines)) + } + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } +} + +func TestCalculateTotalTokenUsageFromTranscript_WithSubagentFiles(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: assistant message with tokens + Task spawning subagent "sub1" + writeJSONLFile(t, transcriptPath, + makeAssistantTokenLine(t, "a1", "msg_main1", 100, 50), + makeTaskToolUseLine(t, "a2", "toolu_task2"), + makeTaskResultLine(t, "u2", "toolu_task2", "sub99"), + ) + + // Subagent transcript: assistant message with its own tokens + writeJSONLFile(t, subagentsDir+"/agent-sub99.jsonl", + makeAssistantTokenLine(t, "sa1", "msg_sub1", 200, 80), + makeAssistantTokenLine(t, "sa2", "msg_sub2", 150, 60), + ) + + usage, err := CalculateTotalTokenUsageFromTranscript(transcriptPath, 0, subagentsDir) + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromTranscript() error: %v", err) + } + + // Main agent: 100 input, 50 output, 1 API call + if usage.InputTokens != 100 { + t.Errorf("main InputTokens = %d, want 100", usage.InputTokens) + } + if usage.OutputTokens != 50 { + t.Errorf("main OutputTokens = %d, want 50", usage.OutputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("main APICallCount = %d, want 1", usage.APICallCount) + } + + // Subagent tokens should be aggregated + if usage.SubagentTokens == nil { + t.Fatal("SubagentTokens is nil, expected subagent token data") + } + if usage.SubagentTokens.InputTokens != 350 { + t.Errorf("subagent InputTokens = %d, want 350 (200+150)", usage.SubagentTokens.InputTokens) + } + if usage.SubagentTokens.OutputTokens != 140 { + t.Errorf("subagent OutputTokens = %d, want 140 (80+60)", usage.SubagentTokens.OutputTokens) + } + if usage.SubagentTokens.APICallCount != 2 { + t.Errorf("subagent APICallCount = %d, want 2", usage.SubagentTokens.APICallCount) + } +} diff --git a/cmd/entire/cli/agent/factoryaidroid/types.go b/cmd/entire/cli/agent/factoryaidroid/types.go new file mode 100644 index 000000000..758aae97e --- /dev/null +++ b/cmd/entire/cli/agent/factoryaidroid/types.go @@ -0,0 +1,95 @@ +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 ( + ToolCreate = "Create" + ToolWrite = "Write" + ToolEdit = "Edit" + ToolMultiEdit = "MultiEdit" + ToolNotebookEdit = "NotebookEdit" +) + +// FileModificationTools lists tools that create or modify files. +var FileModificationTools = []string{ + ToolCreate, + ToolWrite, + ToolEdit, + ToolMultiEdit, + 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 37f7a2905..7ed3324ba 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -91,17 +91,19 @@ type AgentType string // Agent name constants (registry keys) const ( - AgentNameClaudeCode AgentName = "claude-code" - AgentNameGemini AgentName = "gemini" - AgentNameOpenCode AgentName = "opencode" + AgentNameClaudeCode AgentName = "claude-code" + AgentNameFactoryAIDroid AgentName = "factoryai-droid" + AgentNameGemini AgentName = "gemini" + AgentNameOpenCode AgentName = "opencode" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( - AgentTypeClaudeCode AgentType = "Claude Code" - AgentTypeGemini AgentType = "Gemini CLI" - AgentTypeOpenCode AgentType = "OpenCode" - AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility + AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeFactoryAIDroid AgentType = "Factory AI Droid" + AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpenCode AgentType = "OpenCode" + 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 7c46a8234..8a9bfc760 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" + // AgentNameOpenCode is the name for OpenCode agent. const AgentNameOpenCode = "opencode" @@ -61,6 +64,8 @@ func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { return NewClaudeCodeRunner(config) case AgentNameGemini: return NewGeminiCLIRunner(config) + case AgentNameFactoryAIDroid: + return NewFactoryAIDroidRunner(config) case AgentNameOpenCode: return NewOpenCodeRunner(config) default: @@ -330,6 +335,151 @@ func (r *GeminiCLIRunner) RunPromptWithTools(ctx context.Context, workDir string 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_CLAUDE_MODEL") + if model == "" { + model = "claude-haiku-4-5-20251001" + } + } + + 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 ANTHROPIC_API_KEY is set. +// Droid uses BYOK (Bring Your Own Key) with Anthropic API for E2E tests. +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("ANTHROPIC_API_KEY") == "" { + return false, fmt.Errorf("ANTHROPIC_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) { + _ = tools + return r.runPromptWithExec(ctx, workDir, prompt) +} + +func (r *FactoryAIDroidRunner) runPromptWithExec(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + args := []string{ + "exec", + "--cwd", workDir, + "--auto", r.AutoLevel, + "-o", "text", + "--model", "E2E Claude Model", + } + + // Droid uses its own permission system (.factory/settings.json), not --enabled-tools. + // E2E tests pass Claude-specific tool names that Droid doesn't recognize. + + 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 + + // Prevent TTY prompts in git hooks during agent-initiated commits. + // Without this, the prepare-commit-msg hook detects Droid's inherited TTY + // and blocks waiting for user input on the trailer confirmation prompt. + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_TTY=0", + ) + + 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 droidCreditsExhausted(result.Stdout, result.Stderr) { + result.ExitCode = 1 + return result, errors.New("droid account credits exhausted; reload tokens at https://app.factory.ai/settings/billing") + } + if droidRateLimited(result.Stdout, result.Stderr) { + result.ExitCode = 1 + return result, errors.New("droid rate limited (429 Too Many Requests); retry after a short wait") + } + + 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 +} + +func droidCreditsExhausted(stdout string, stderr string) bool { + lower := strings.ToLower(stdout + "\n" + stderr) + return strings.Contains(lower, "ready for more? reload your tokens") || + strings.Contains(lower, "reload your tokens at https://app.factory.ai/settings/billing") +} + +func droidRateLimited(stdout string, stderr string) bool { + lower := strings.ToLower(stdout + "\n" + stderr) + return strings.Contains(lower, "error: 429") || + strings.Contains(lower, "\"code\":\"429\"") || + strings.Contains(lower, "too many requests") +} + // OpenCodeRunner implements AgentRunner for OpenCode. // See: https://opencode.ai/docs/cli/ type OpenCodeRunner struct { diff --git a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go index c012fa302..791e2fa9f 100644 --- a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go +++ b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go @@ -11,9 +11,10 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/agent" - _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // Register claude-code agent - _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // Register gemini agent - _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" // Register opencode agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // Register claude-code agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" // Register factoryai-droid agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // Register gemini agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" // Register opencode agent "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 5585783c9..f01e0da6f 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -103,6 +103,29 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { env.WriteFile("opencode.json", opencodeConfig) } + // Inject BYOK customModels config for Droid before `entire enable`. + // `entire enable` merges hooks into .factory/settings.json while preserving + // unknown keys like customModels, so the BYOK config survives. + // Uses ${ANTHROPIC_API_KEY} (Droid env-var reference syntax) so the actual + // key never appears in the file or git. + if defaultAgent == AgentNameFactoryAIDroid { + if droidRunner, ok := env.Agent.(*FactoryAIDroidRunner); ok { + byokConfig := `{ + "customModels": [ + { + "model": "` + droidRunner.Model + `", + "displayName": "E2E Claude Model", + "baseUrl": "https://api.anthropic.com", + "apiKey": "` + os.Getenv("ANTHROPIC_API_KEY") + `", + "provider": "anthropic", + "maxOutputTokens": 8192 + } + ] +}` + env.WriteFile(".factory/settings.json", byokConfig) + } + } + // Use `entire enable` to set up everything (hooks, settings, etc.) // This sets up .entire/settings.json and .claude/settings.json with hooks env.RunEntireEnable(strategyName) @@ -306,6 +329,7 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -347,6 +371,7 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) @@ -387,6 +412,7 @@ func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) { "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -428,6 +454,7 @@ func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) { postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) @@ -460,6 +487,7 @@ func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -518,6 +546,7 @@ func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_DROID_PROJECT_DIR="+filepath.Join(env.RepoDir, ".factory"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index e622cd4da..a44db1271 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -547,7 +547,7 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT return nil } return scoped - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) @@ -1547,7 +1547,7 @@ func transcriptOffset(transcriptBytes []byte, agentType agent.AgentType) int { return 0 } return len(t.Messages) - case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: return countLines(transcriptBytes) } return countLines(transcriptBytes) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index fbec53e3b..6ae41b5a8 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/entireio/cli/cmd/entire/cli/agent/opencode" diff --git a/cmd/entire/cli/integration_test/agent_strategy_test.go b/cmd/entire/cli/integration_test/agent_strategy_test.go index 53a8feb7b..1e78bab52 100644 --- a/cmd/entire/cli/integration_test/agent_strategy_test.go +++ b/cmd/entire/cli/integration_test/agent_strategy_test.go @@ -241,3 +241,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 359c54573..413343193 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/agent/opencode" // Register OpenCode agent "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -820,6 +821,387 @@ func TestGeminiCLIHelperMethods(t *testing.T) { } +// --- Factory AI Droid Agent Tests --- + +// 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 8 hooks: SessionStart (session-start + user-prompt-submit), SessionEnd, + // Stop, UserPromptSubmit, PreToolUse[Task], PostToolUse[Task], PreCompact + if count != 8 { + t.Errorf("InstallHooks() count = %d, want 8", 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 != 8 { + t.Errorf("force InstallHooks() count = %d, want 8", 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") + } + }) + +} + +// TestFactoryAIDroidSessionMethods verifies ReadSession, WriteSession, and GetSessionDir. +func TestFactoryAIDroidSessionMethods(t *testing.T) { + t.Parallel() + + t.Run("ReadSession reads and parses transcript", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "transcript.jsonl") + content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}} +{"type":"message","id":"msg2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}` + if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag, _ := agent.Get("factoryai-droid") + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + if session.SessionID != "test" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test") + } + if len(session.NativeData) == 0 { + t.Error("NativeData should not be empty") + } + }) + + t.Run("ReadSession errors on missing file", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("factoryai-droid") + _, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: "/nonexistent/path/transcript.jsonl", + }) + if err == nil { + t.Error("ReadSession() should error on missing file") + } + }) + + t.Run("WriteSession round-trips with ReadSession", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + originalPath := filepath.Join(tmpDir, "original.jsonl") + restoredPath := filepath.Join(tmpDir, "sub", "restored.jsonl") + + content := `{"type":"message","id":"msg1","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}` + if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write original: %v", err) + } + + ag, _ := agent.Get("factoryai-droid") + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: originalPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + session.SessionRef = restoredPath + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + restored, err := os.ReadFile(restoredPath) + if err != nil { + t.Fatalf("failed to read restored: %v", err) + } + if string(restored) != content { + t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content) + } + }) + + 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) + } + }) +} + // --- OpenCode Agent Tests --- // TestOpenCodeAgentDetection verifies OpenCode agent detection and default behavior. diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index c27a4cb31..6cc231e2c 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -752,6 +752,364 @@ func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) e return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath) } +// --- Factory AI Droid Hook Runner --- + +// 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) +} + // --- OpenCode Hook Runner --- // OpenCodeHookRunner executes OpenCode hooks in the test environment. 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/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 045adfdd6..e5533d4a2 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" "github.com/entireio/cli/cmd/entire/cli/agent/opencode" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -225,7 +226,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI slog.String("error", sliceErr.Error())) } scopedTranscript = scoped - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart) } if len(scopedTranscript) > 0 { @@ -547,6 +548,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), 0) + 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 + } + // OpenCode uses JSONL with a different per-line schema than Claude Code if agentType == agent.AgentTypeOpenCode { prompts, err := opencode.ExtractAllUserPrompts([]byte(content)) @@ -595,6 +616,16 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } + // Droid has its own envelope format — use its parser to normalize first. + // startOffset is a raw JSONL line count, so pass it to the parser which + // applies the offset before filtering non-message entries. + if agentType == agent.AgentTypeFactoryAIDroid { + lines, err := factoryaidroid.ParseDroidTranscriptFromBytes(data, startOffset) + if err != nil || len(lines) == 0 { + return &agent.TokenUsage{} + } + return factoryaidroid.CalculateTokenUsage(lines) + } // OpenCode uses JSONL with token info on assistant messages (different schema from Claude Code) if agentType == agent.AgentTypeOpenCode { return opencode.CalculateTokenUsageFromBytes(data, startOffset) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index ad150973b..4f7a9d752 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -1,9 +1,12 @@ package strategy import ( + "encoding/json" "strings" "testing" "unicode/utf8" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) func TestGenerateContextFromPrompts_CJKTruncation(t *testing.T) { @@ -85,3 +88,112 @@ func TestGenerateContextFromPrompts_ShortCJKNotTruncated(t *testing.T) { t.Error("short CJK prompt should not be truncated") } } + +// droidMessage builds a Droid JSONL "message" line with the given id, role, and optional usage. +func droidMessage(t *testing.T, id, role string, usage map[string]int) string { + t.Helper() + inner := map[string]interface{}{ + "role": role, + "content": []interface{}{}, + } + if usage != nil { + inner["id"] = id + inner["usage"] = usage + } + msg, err := json.Marshal(inner) + if err != nil { + t.Fatalf("failed to marshal inner message: %v", err) + } + line := map[string]interface{}{ + "type": "message", + "id": id, + "message": json.RawMessage(msg), + } + b, err := json.Marshal(line) + if err != nil { + t.Fatalf("failed to marshal droid line: %v", err) + } + return string(b) +} + +func TestCalculateTokenUsage_DroidStartOffsetSkipsNonMessageLines(t *testing.T) { + t.Parallel() + + // Build a Droid transcript with non-message entries interspersed: + // Line 0: session_start (non-message) + // Line 1: user message (no tokens) + // Line 2: assistant message with 10 input, 20 output tokens + // Line 3: session_event (non-message) + // Line 4: assistant message with 5 input, 30 output tokens + transcript := "" + + `{"type":"session_start","id":"s1"}` + "\n" + + droidMessage(t, "m1", "user", nil) + "\n" + + droidMessage(t, "m2", "assistant", map[string]int{ + "input_tokens": 10, "output_tokens": 20, + }) + "\n" + + `{"type":"session_event","data":"heartbeat"}` + "\n" + + droidMessage(t, "m3", "assistant", map[string]int{ + "input_tokens": 5, "output_tokens": 30, + }) + "\n" + + data := []byte(transcript) + + // With startOffset=0: should count all messages (m2 + m3) + usageAll := calculateTokenUsage(agent.AgentTypeFactoryAIDroid, data, 0) + if usageAll.InputTokens != 15 { + t.Errorf("startOffset=0: InputTokens = %d, want 15", usageAll.InputTokens) + } + if usageAll.OutputTokens != 50 { + t.Errorf("startOffset=0: OutputTokens = %d, want 50", usageAll.OutputTokens) + } + if usageAll.APICallCount != 2 { + t.Errorf("startOffset=0: APICallCount = %d, want 2", usageAll.APICallCount) + } + + // With startOffset=3: skip lines 0-2 (session_start, m1, m2). + // Only line 3 (session_event, filtered) and line 4 (m3) remain. + // Should count only m3's tokens. + usageFrom3 := calculateTokenUsage(agent.AgentTypeFactoryAIDroid, data, 3) + if usageFrom3.InputTokens != 5 { + t.Errorf("startOffset=3: InputTokens = %d, want 5", usageFrom3.InputTokens) + } + if usageFrom3.OutputTokens != 30 { + t.Errorf("startOffset=3: OutputTokens = %d, want 30", usageFrom3.OutputTokens) + } + if usageFrom3.APICallCount != 1 { + t.Errorf("startOffset=3: APICallCount = %d, want 1", usageFrom3.APICallCount) + } + + // Regression: using the OLD buggy code would have parsed all messages (ignoring + // non-message entries), producing [m1, m2, m3], then sliced at index 3 which + // is out of bounds — returning all tokens instead of just m3's. + // With startOffset=1: skip only line 0 (session_start). + // Lines 1 (m1), 2 (m2), 3 (session_event, filtered), 4 (m3) remain. + usageFrom1 := calculateTokenUsage(agent.AgentTypeFactoryAIDroid, data, 1) + if usageFrom1.InputTokens != 15 { + t.Errorf("startOffset=1: InputTokens = %d, want 15", usageFrom1.InputTokens) + } + if usageFrom1.APICallCount != 2 { + t.Errorf("startOffset=1: APICallCount = %d, want 2", usageFrom1.APICallCount) + } +} + +// Verify that startOffset beyond transcript length returns empty usage. +func TestCalculateTokenUsage_DroidStartOffsetBeyondEnd(t *testing.T) { + t.Parallel() + + data := []byte( + `{"type":"session_start","id":"s1"}` + "\n" + + droidMessage(t, "m1", "assistant", map[string]int{ + "input_tokens": 10, "output_tokens": 20, + }) + "\n", + ) + + usage := calculateTokenUsage(agent.AgentTypeFactoryAIDroid, data, 100) + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index ab0842a57..0c00ee9cc 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/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -117,6 +118,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) + case agent.AgentTypeFactoryAIDroid: + return buildCondensedTranscriptFromDroid(content) case agent.AgentTypeOpenCode: return buildCondensedTranscriptFromOpenCode(content) case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: @@ -213,6 +216,15 @@ func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) { return entries, nil } +// buildCondensedTranscriptFromDroid parses Droid transcript and extracts a condensed view. +func buildCondensedTranscriptFromDroid(content []byte) ([]Entry, error) { + droidLines, err := factoryaidroid.ParseDroidTranscriptFromBytes(content, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse Droid transcript: %w", err) + } + return BuildCondensedTranscript(droidLines), nil +} + // extractOpenCodeToolDetail extracts a detail string from an OpenCode tool's input map. // OpenCode uses camelCase keys (e.g., "filePath" instead of "file_path"). func extractOpenCodeToolDetail(input map[string]interface{}) string { diff --git a/mise.toml b/mise.toml index ea69f7e83..adb466d3a 100644 --- a/mise.toml +++ b/mise.toml @@ -135,3 +135,7 @@ run = "E2E_AGENT=gemini go test -tags=e2e -count=1 -parallel 1 -timeout=30m -v . [tasks."test:e2e:opencode"] description = "Run E2E tests with OpenCode" run = "E2E_AGENT=opencode go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:factoryai-droid"] +description = "Run E2E tests with Factory AI Droid" +run = "E2E_AGENT=factoryai-droid go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..."