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