From 44aa5457eb878f11e84f1ad5f1ac9f07ff18ce58 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 10:46:44 +1300 Subject: [PATCH 01/12] Add Autohand Code agent name and type constants Register autohand-code as a new agent in the registry with its corresponding display name "Autohand Code". --- cmd/entire/cli/agent/registry.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 098be93a1..f020adaa6 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -101,6 +101,7 @@ const ( AgentNameGemini types.AgentName = "gemini" AgentNameOpenCode types.AgentName = "opencode" AgentNameFactoryAIDroid types.AgentName = "factoryai-droid" + AgentNameAutohandCode types.AgentName = "autohand-code" ) // Agent type constants (type identifiers stored in metadata/trailers) @@ -110,6 +111,7 @@ const ( AgentTypeGemini types.AgentType = "Gemini CLI" AgentTypeOpenCode types.AgentType = "OpenCode" AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" + AgentTypeAutohandCode types.AgentType = "Autohand Code" AgentTypeUnknown types.AgentType = "Agent" // Fallback for backwards compatibility ) From 7c39afbc7cfca995c7adb5d482aa6c3d49b3a4c0 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 10:48:01 +1300 Subject: [PATCH 02/12] Add Autohand Code agent core implementation and types Create the autohandcode package with: - Core agent struct implementing the Agent interface - Self-registration via init() - Session storage at ~/.autohand/sessions//conversation.jsonl - AUTOHAND_HOME env var support for custom home directory - Type definitions for Autohand's config format, hook stdin JSON schema, transcript message format, and file modification tool names (write_file, edit_file, create_file, patch_file) --- .../cli/agent/autohandcode/autohandcode.go | 167 ++++++++++++++++++ cmd/entire/cli/agent/autohandcode/types.go | 139 +++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 cmd/entire/cli/agent/autohandcode/autohandcode.go create mode 100644 cmd/entire/cli/agent/autohandcode/types.go diff --git a/cmd/entire/cli/agent/autohandcode/autohandcode.go b/cmd/entire/cli/agent/autohandcode/autohandcode.go new file mode 100644 index 000000000..1e75bb57f --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/autohandcode.go @@ -0,0 +1,167 @@ +// Package autohandcode implements the Agent interface for Autohand Code CLI. +package autohandcode + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameAutohandCode, NewAutohandCodeAgent) +} + +// AutohandCodeAgent implements the agent.Agent interface for Autohand Code CLI. +// +//nolint:revive // AutohandCodeAgent is clearer than Agent in this context +type AutohandCodeAgent struct{} + +// NewAutohandCodeAgent creates a new Autohand Code agent instance. +func NewAutohandCodeAgent() agent.Agent { + return &AutohandCodeAgent{} +} + +// Name returns the agent registry key. +func (a *AutohandCodeAgent) Name() types.AgentName { return agent.AgentNameAutohandCode } + +// Type returns the agent type identifier. +func (a *AutohandCodeAgent) Type() types.AgentType { return agent.AgentTypeAutohandCode } + +// Description returns a human-readable description. +func (a *AutohandCodeAgent) Description() string { + return "Autohand Code - autonomous coding agent" +} + +// IsPreview returns true as Autohand Code integration is in preview. +func (a *AutohandCodeAgent) IsPreview() bool { return true } + +// ProtectedDirs returns directories that Autohand Code uses for config/state. +func (a *AutohandCodeAgent) ProtectedDirs() []string { return []string{".autohand"} } + +// DetectPresence checks if Autohand Code is configured in the repository. +func (a *AutohandCodeAgent) DetectPresence(ctx context.Context) (bool, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." + } + if _, err := os.Stat(filepath.Join(repoRoot, ".autohand")); err == nil { + return true, nil + } + return false, nil +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (a *AutohandCodeAgent) 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 (a *AutohandCodeAgent) ChunkTranscript(_ context.Context, 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 (a *AutohandCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// GetSessionID extracts the session ID from hook input. +func (a *AutohandCodeAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } + +// GetSessionDir returns the directory where Autohand Code stores session transcripts. +// Path: ~/.autohand/sessions/ +func (a *AutohandCodeAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_AUTOHAND_PROJECT_DIR"); override != "" { + return override, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + if envHome := os.Getenv("AUTOHAND_HOME"); envHome != "" { + return filepath.Join(envHome, "sessions"), nil + } + return filepath.Join(homeDir, ".autohand", "sessions"), nil +} + +// ResolveSessionFile returns the path to an Autohand Code session file. +// Autohand stores transcripts at ~/.autohand/sessions//conversation.jsonl +func (a *AutohandCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID, "conversation.jsonl") +} + +// ReadSession reads a session from Autohand Code's storage (JSONL transcript file). +func (a *AutohandCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: a.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to Autohand Code's storage (JSONL transcript file). +func (a *AutohandCodeAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != a.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, a.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an Autohand Code session. +func (a *AutohandCodeAgent) FormatResumeCommand(sessionID string) string { + return "autohand resume " + sessionID +} diff --git a/cmd/entire/cli/agent/autohandcode/types.go b/cmd/entire/cli/agent/autohandcode/types.go new file mode 100644 index 000000000..b877f6fab --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/types.go @@ -0,0 +1,139 @@ +package autohandcode + +import "encoding/json" + +// AutohandConfig represents the .autohand/config.json structure. +type AutohandConfig struct { + Hooks AutohandHooksSettings `json:"hooks"` +} + +// AutohandHooksSettings contains the hooks configuration. +type AutohandHooksSettings struct { + Enabled *bool `json:"enabled,omitempty"` + Hooks []AutohandHookDef `json:"hooks,omitempty"` +} + +// AutohandHookDef represents a single hook definition. +type AutohandHookDef struct { + Event string `json:"event"` + Command string `json:"command"` + Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Timeout int `json:"timeout,omitempty"` + Async bool `json:"async,omitempty"` + Matcher string `json:"matcher,omitempty"` + Filter *AutohandFilter `json:"filter,omitempty"` +} + +// AutohandFilter limits when a hook fires. +type AutohandFilter struct { + Tool []string `json:"tool,omitempty"` + Path []string `json:"path,omitempty"` +} + +// AutohandPermissionRule represents a permission rule in config. +type AutohandPermissionRule struct { + Tool string `json:"tool"` + Pattern string `json:"pattern,omitempty"` + Action string `json:"action"` +} + +// hookInputBase is the JSON structure piped to hook stdin for all events. +type hookInputBase struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` +} + +// hookInputPrompt extends hookInputBase for pre-prompt events. +type hookInputPrompt struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Instruction string `json:"instruction"` + MentionedFiles []string `json:"mentioned_files"` +} + +// hookInputStop extends hookInputBase for stop/post-response events. +type hookInputStop struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + TokensUsed int `json:"tokens_used"` + ToolCallsCount int `json:"tool_calls_count"` + TurnToolCalls int `json:"turn_tool_calls"` + TurnDuration int `json:"turn_duration"` + Duration int `json:"duration"` +} + +// hookInputTool extends hookInputBase for pre-tool/post-tool events. +type hookInputTool struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolUseID string `json:"tool_use_id"` + ToolResponse string `json:"tool_response"` + ToolSuccess *bool `json:"tool_success"` +} + +// hookInputSubagent extends hookInputBase for subagent-stop events. +type hookInputSubagent struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + SubagentID string `json:"subagent_id"` + SubagentName string `json:"subagent_name"` + SubagentType string `json:"subagent_type"` + SubagentSuccess *bool `json:"subagent_success"` + SubagentError string `json:"subagent_error"` + SubagentDuration int `json:"subagent_duration"` +} + +// Tool names used in Autohand Code transcripts for file modification. +const ( + ToolWriteFile = "write_file" + ToolEditFile = "edit_file" + ToolCreateFile = "create_file" + ToolPatchFile = "patch_file" +) + +// FileModificationTools lists tools that create or modify files. +var FileModificationTools = []string{ + ToolWriteFile, + ToolEditFile, + ToolCreateFile, + ToolPatchFile, +} + +// autohandToolCall represents a tool call in the Autohand transcript format. +type autohandToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// autohandToolInput represents the input to a file modification tool. +type autohandToolInput struct { + Path string `json:"path"` + File string `json:"file"` +} + +// autohandMessage represents a single JSONL line in the Autohand transcript. +type autohandMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Timestamp string `json:"timestamp"` + ToolCalls []autohandToolCall `json:"toolCalls,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` +} + +// autohandTokenMeta is extracted from _meta for token tracking. +type autohandTokenMeta struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} From 4a7114773be8459cd355273a288aa29605754879 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 10:49:26 +1300 Subject: [PATCH 03/12] Add hook management for Autohand Code agent Implement InstallHooks, UninstallHooks, AreHooksInstalled for .autohand/config.json. Hooks use Autohand's array-based config format with event/command/description fields. Installs hooks for session-start, pre-prompt, stop, session-end, and subagent-stop events. Adds permission deny rule for .entire/metadata files. --- cmd/entire/cli/agent/autohandcode/hooks.go | 327 +++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 cmd/entire/cli/agent/autohandcode/hooks.go diff --git a/cmd/entire/cli/agent/autohandcode/hooks.go b/cmd/entire/cli/agent/autohandcode/hooks.go new file mode 100644 index 000000000..06c58cdde --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/hooks.go @@ -0,0 +1,327 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "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 AutohandCodeAgent implements HookSupport +var _ agent.HookSupport = (*AutohandCodeAgent)(nil) + +// Autohand Code hook names - these become subcommands under `entire hooks autohand-code` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameStop = "stop" + HookNamePrePrompt = "pre-prompt" + HookNameSubagentStop = "subagent-stop" + HookNameNotification = "notification" +) + +// AutohandConfigFileName is the config file used by Autohand Code. +const AutohandConfigFileName = "config.json" + +// metadataDenyRule blocks Autohand from reading Entire session metadata +var metadataDenyRule = AutohandPermissionRule{ + Tool: "read_file", + Pattern: ".entire/metadata/**", + Action: "deny", +} + +// entireHookPrefix identifies Entire hooks in the config +const entireHookPrefix = "entire hooks autohand-code " + +// entireHookLocalDevPrefix identifies local dev Entire hooks +const entireHookLocalDevPrefix = "go run ${AUTOHAND_PROJECT_DIR}/cmd/entire/main.go hooks autohand-code " + +// InstallHooks installs Autohand Code hooks in .autohand/config.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (a *AutohandCodeAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + + // Read existing config if it exists + var rawConfig map[string]json.RawMessage + existingData, readErr := os.ReadFile(configPath) //nolint:gosec // path is constructed from cwd + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawConfig); err != nil { + return 0, fmt.Errorf("failed to parse existing config.json: %w", err) + } + } else { + rawConfig = make(map[string]json.RawMessage) + } + + // Parse hooks section + var hooksSettings AutohandHooksSettings + if hooksRaw, ok := rawConfig["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &hooksSettings); err != nil { + return 0, fmt.Errorf("failed to parse hooks in config.json: %w", err) + } + } + + // If force is true, remove all existing Entire hooks first + if force { + hooksSettings.Hooks = removeEntireHooks(hooksSettings.Hooks) + } + + // Define hook commands + var prefix string + if localDev { + prefix = entireHookLocalDevPrefix + } else { + prefix = entireHookPrefix + } + + count := 0 + + // Add hooks if they don't exist + hookDefs := []struct { + event string + verb string + desc string + }{ + {event: "session-start", verb: "session-start", desc: "Entire: session start checkpoint"}, + {event: "pre-prompt", verb: "pre-prompt", desc: "Entire: capture user prompt"}, + {event: "stop", verb: "stop", desc: "Entire: save checkpoint on agent stop"}, + {event: "session-end", verb: "session-end", desc: "Entire: session end cleanup"}, + {event: "subagent-stop", verb: "subagent-stop", desc: "Entire: subagent checkpoint"}, + } + + for _, def := range hookDefs { + cmd := prefix + def.verb + if !hookCommandExists(hooksSettings.Hooks, cmd) { + enabled := true + hooksSettings.Hooks = append(hooksSettings.Hooks, AutohandHookDef{ + Event: def.event, + Command: cmd, + Description: def.desc, + Enabled: &enabled, + Timeout: 10000, // 10s for checkpoint operations + }) + count++ + } + } + + // Add permissions deny rule if not present + permissionsChanged := false + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawConfig["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + return 0, fmt.Errorf("failed to parse permissions in config.json: %w", err) + } + } + if rawPermissions == nil { + rawPermissions = make(map[string]json.RawMessage) + } + + var rules []AutohandPermissionRule + if rulesRaw, ok := rawPermissions["rules"]; ok { + if err := json.Unmarshal(rulesRaw, &rules); err != nil { + // If parsing fails, start with empty rules + rules = nil + } + } + if !permissionRuleExists(rules, metadataDenyRule) { + rules = append(rules, metadataDenyRule) + rulesJSON, err := json.Marshal(rules) + if err != nil { + return 0, fmt.Errorf("failed to marshal permission rules: %w", err) + } + rawPermissions["rules"] = rulesJSON + permissionsChanged = true + } + + if count == 0 && !permissionsChanged { + return 0, nil // All hooks and permissions already installed + } + + // Marshal hooks back + hooksJSON, err := json.Marshal(hooksSettings) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawConfig["hooks"] = hooksJSON + + // Marshal permissions back + permJSON, err := json.Marshal(rawPermissions) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions: %w", err) + } + rawConfig["permissions"] = permJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .autohand directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawConfig, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write config.json: %w", err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from Autohand Code config. +func (a *AutohandCodeAgent) UninstallHooks(ctx context.Context) error { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No config file means nothing to uninstall + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + return fmt.Errorf("failed to parse config.json: %w", err) + } + + // Parse and filter hooks + var hooksSettings AutohandHooksSettings + if hooksRaw, ok := rawConfig["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &hooksSettings); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + + hooksSettings.Hooks = removeEntireHooks(hooksSettings.Hooks) + + // Remove the metadata deny rule from permissions + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawConfig["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + rawPermissions = nil + } + } + + if rawPermissions != nil { + if rulesRaw, ok := rawPermissions["rules"]; ok { + var rules []AutohandPermissionRule + if err := json.Unmarshal(rulesRaw, &rules); err == nil { + filteredRules := make([]AutohandPermissionRule, 0, len(rules)) + for _, rule := range rules { + if rule != metadataDenyRule { + filteredRules = append(filteredRules, rule) + } + } + if len(filteredRules) > 0 { + rulesJSON, err := json.Marshal(filteredRules) + if err == nil { + rawPermissions["rules"] = rulesJSON + } + } else { + delete(rawPermissions, "rules") + } + } + } + if len(rawPermissions) > 0 { + permJSON, err := json.Marshal(rawPermissions) + if err == nil { + rawConfig["permissions"] = permJSON + } + } else { + delete(rawConfig, "permissions") + } + } + + // Marshal hooks back + if len(hooksSettings.Hooks) > 0 || hooksSettings.Enabled != nil { + hooksJSON, err := json.Marshal(hooksSettings) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawConfig["hooks"] = hooksJSON + } else { + delete(rawConfig, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write config.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (a *AutohandCodeAgent) AreHooksInstalled(ctx context.Context) bool { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + return false + } + + // Check for at least one of our hooks (new or old format) + return hookCommandExists(config.Hooks.Hooks, entireHookPrefix+"stop") || + hookCommandExists(config.Hooks.Hooks, entireHookLocalDevPrefix+"stop") +} + +// Helper functions + +func hookCommandExists(hooks []AutohandHookDef, command string) bool { + for _, hook := range hooks { + if hook.Command == command { + return true + } + } + return false +} + +func isEntireHook(command string) bool { + return strings.HasPrefix(command, entireHookPrefix) || + strings.HasPrefix(command, entireHookLocalDevPrefix) +} + +func removeEntireHooks(hooks []AutohandHookDef) []AutohandHookDef { + result := make([]AutohandHookDef, 0, len(hooks)) + for _, hook := range hooks { + if !isEntireHook(hook.Command) { + result = append(result, hook) + } + } + return result +} + +func permissionRuleExists(rules []AutohandPermissionRule, target AutohandPermissionRule) bool { + for _, rule := range rules { + if rule == target { + return true + } + } + return false +} From f469a61c5dd60c30517c0729a8a112682311e881 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 10:51:04 +1300 Subject: [PATCH 04/12] Add lifecycle event parsing for Autohand Code agent Implement ParseHookEvent mapping Autohand hooks to normalized events: - session-start -> SessionStart - pre-prompt -> TurnStart (with instruction as prompt) - stop -> TurnEnd - session-end -> SessionEnd - subagent-stop -> SubagentEnd - notification -> no-op Also implement TranscriptAnalyzer, TokenCalculator, SubagentAwareExtractor, and HookResponseWriter interfaces. Transcript path is computed from session_id since Autohand does not include transcript_path in hook stdin. --- .../cli/agent/autohandcode/lifecycle.go | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 cmd/entire/cli/agent/autohandcode/lifecycle.go diff --git a/cmd/entire/cli/agent/autohandcode/lifecycle.go b/cmd/entire/cli/agent/autohandcode/lifecycle.go new file mode 100644 index 000000000..693c92001 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/lifecycle.go @@ -0,0 +1,249 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/textutil" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// Compile-time interface assertions. +var ( + _ agent.TranscriptAnalyzer = (*AutohandCodeAgent)(nil) + _ agent.TokenCalculator = (*AutohandCodeAgent)(nil) + _ agent.SubagentAwareExtractor = (*AutohandCodeAgent)(nil) + _ agent.HookResponseWriter = (*AutohandCodeAgent)(nil) +) + +// WriteHookResponse outputs the hook response as plain text to stdout. +// Autohand parses JSON stdout only for control flow (pre-tool hooks). +// For lifecycle hooks, plain text is safe and displays in the terminal. +func (a *AutohandCodeAgent) WriteHookResponse(message string) error { + if _, err := fmt.Fprintln(os.Stdout, message); err != nil { + return fmt.Errorf("failed to write hook response: %w", err) + } + return nil +} + +// HookNames returns the hook verbs Autohand Code supports. +// These become subcommands: entire hooks autohand-code +func (a *AutohandCodeAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNamePrePrompt, + HookNameSubagentStop, + HookNameNotification, + } +} + +// ParseHookEvent translates an Autohand Code hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (a *AutohandCodeAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return a.parseSessionStart(stdin) + case HookNamePrePrompt: + return a.parseTurnStart(stdin) + case HookNameStop: + return a.parseTurnEnd(stdin) + case HookNameSessionEnd: + return a.parseSessionEnd(stdin) + case HookNameSubagentStop: + return a.parseSubagentEnd(stdin) + case HookNameNotification: + // Acknowledged hook 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 (a *AutohandCodeAgent) GetTranscriptPosition(path string) (int, error) { + _, pos, err := ParseAutohandTranscript(path, 0) + if err != nil { + return 0, err + } + return pos, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line offset. +func (a *AutohandCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { + lines, currentPos, err := ParseAutohandTranscript(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 (a *AutohandCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + lines, _, err := ParseAutohandTranscript(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 (a *AutohandCodeAgent) ExtractSummary(sessionRef string) (string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return "", fmt.Errorf("failed to read transcript: %w", err) + } + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + return "", fmt.Errorf("failed to parse transcript: %w", err) + } + + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != transcript.TypeAssistant { + continue + } + // For Autohand, the Message field contains the full message object. + // Try to extract the content field as a plain text string. + var msg autohandMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + var textContent string + if err := json.Unmarshal(msg.Content, &textContent); err == nil && textContent != "" { + return textContent, nil + } + // Also try the shared AssistantMessage format (content blocks) + var assistantMsg transcript.AssistantMessage + if err := json.Unmarshal(lines[i].Message, &assistantMsg); err != nil { + continue + } + for _, block := range assistantMsg.Content { + if block.Type == transcript.ContentTypeText && block.Text != "" { + return block.Text, nil + } + } + } + return "", nil +} + +// --- TokenCalculator --- + +// CalculateTokenUsage computes token usage from pre-loaded transcript bytes starting at the given line offset. +func (a *AutohandCodeAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromBytes(transcriptData, fromOffset, "") +} + +// --- SubagentAwareExtractor --- + +// ExtractAllModifiedFiles extracts files modified by both the main agent and any spawned subagents. +func (a *AutohandCodeAgent) ExtractAllModifiedFiles(transcriptData []byte, fromOffset int, subagentsDir string) ([]string, error) { + return ExtractAllModifiedFilesFromBytes(transcriptData, fromOffset, subagentsDir) +} + +// CalculateTotalTokenUsage computes token usage including all spawned subagents. +func (a *AutohandCodeAgent) CalculateTotalTokenUsage(transcriptData []byte, fromOffset int, subagentsDir string) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromBytes(transcriptData, fromOffset, subagentsDir) +} + +// --- Internal hook parsing functions --- + +// resolveTranscriptPath computes the transcript path from session_id. +// Autohand stores transcripts at ~/.autohand/sessions//conversation.jsonl +func (a *AutohandCodeAgent) resolveTranscriptPath(sessionID string) string { + if sessionID == "" { + return "" + } + sessionDir, err := a.GetSessionDir("") + if err != nil { + return "" + } + return filepath.Join(sessionDir, sessionID, "conversation.jsonl") +} + +func (a *AutohandCodeAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputBase](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputPrompt](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Prompt: raw.Instruction, + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputStop](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputBase](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputSubagent](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + SubagentID: raw.SubagentID, + Timestamp: time.Now(), + }, nil +} From 4f433258bf8c02409cc9423e75b01384c82162d4 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 10:51:58 +1300 Subject: [PATCH 05/12] Add transcript parser for Autohand Code agent Parse Autohand's JSONL transcript format which uses direct role fields (no envelope wrapper). Extract modified files from the toolCalls array on assistant messages. Calculate token usage from _meta fields. Support subagent transcript aggregation. --- .../cli/agent/autohandcode/transcript.go | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 cmd/entire/cli/agent/autohandcode/transcript.go diff --git a/cmd/entire/cli/agent/autohandcode/transcript.go b/cmd/entire/cli/agent/autohandcode/transcript.go new file mode 100644 index 000000000..e983f1c26 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/transcript.go @@ -0,0 +1,322 @@ +package autohandcode + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + + "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 + +// ParseAutohandTranscript parses an Autohand JSONL file into normalized transcript.Line entries. +// Autohand messages have a direct role field, unlike Droid's envelope format. +// Non-message entries (system role, tool role) are filtered to keep only user/assistant. +func ParseAutohandTranscript(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 parseAutohandTranscriptFromReader(file, startLine) +} + +// ParseAutohandTranscriptFromBytes parses Autohand JSONL content from a byte slice. +// startLine skips the first N raw JSONL lines before parsing (0 = parse all). +// Returns parsed lines, total raw line count, and any error. +func ParseAutohandTranscriptFromBytes(content []byte, startLine int) ([]transcript.Line, int, error) { + return parseAutohandTranscriptFromReader(bytes.NewReader(content), startLine) +} + +func parseAutohandTranscriptFromReader(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 := parseAutohandLine(lineBytes); ok { + lines = append(lines, line) + } + } + totalLines++ + + if err == io.EOF { + break + } + } + + return lines, totalLines, nil +} + +// parseAutohandLine converts a single Autohand JSONL line into a normalized transcript.Line. +// Returns false if the line is not a user or assistant message. +func parseAutohandLine(lineBytes []byte) (transcript.Line, bool) { + var msg autohandMessage + if err := json.Unmarshal(lineBytes, &msg); err != nil { + return transcript.Line{}, false + } + + // Only process "user" and "assistant" messages — skip "tool", "system", etc. + if msg.Role != transcript.TypeUser && msg.Role != transcript.TypeAssistant { + return transcript.Line{}, false + } + + // Build a normalized message for downstream consumers. + // For Autohand, we store the full message object so that: + // - ExtractModifiedFiles can access toolCalls + // - ExtractUserContent can access the content field + // - ExtractSummary can access the content field + normalizedMsg, err := json.Marshal(msg) + if err != nil { + return transcript.Line{}, false + } + + return transcript.Line{ + Type: msg.Role, + UUID: msg.Timestamp, // Use timestamp as a pseudo-UUID (Autohand has no message IDs) + Message: normalizedMsg, + }, true +} + +// ExtractModifiedFiles extracts files modified by tool calls from transcript. +// Autohand stores tool calls in a separate toolCalls array on assistant messages. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]bool) + var files []string + + for _, line := range lines { + if line.Type != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + for _, tc := range msg.ToolCalls { + if !slices.Contains(FileModificationTools, tc.Name) { + continue + } + + var input autohandToolInput + if err := json.Unmarshal(tc.Input, &input); err != nil { + continue + } + + file := input.Path + if file == "" { + file = input.File + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + + return files +} + +// CalculateTokenUsage calculates token usage from an Autohand transcript. +// Autohand stores token metadata in the _meta field of assistant messages. +func CalculateTokenUsage(transcriptLines []TranscriptLine) *agent.TokenUsage { + usage := &agent.TokenUsage{} + + for _, line := range transcriptLines { + if line.Type != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + if msg.Meta == nil { + continue + } + + // Extract token info from _meta + if inputTokens, ok := extractIntFromMeta(msg.Meta, "input_tokens"); ok { + usage.InputTokens += inputTokens + } + if outputTokens, ok := extractIntFromMeta(msg.Meta, "output_tokens"); ok { + usage.OutputTokens += outputTokens + } + if cacheCreation, ok := extractIntFromMeta(msg.Meta, "cache_creation_input_tokens"); ok { + usage.CacheCreationTokens += cacheCreation + } + if cacheRead, ok := extractIntFromMeta(msg.Meta, "cache_read_input_tokens"); ok { + usage.CacheReadTokens += cacheRead + } + usage.APICallCount++ + } + + return usage +} + +// extractIntFromMeta safely extracts an integer from a map[string]any. +func extractIntFromMeta(meta map[string]any, key string) (int, bool) { + v, ok := meta[key] + if !ok { + return 0, false + } + switch val := v.(type) { + case float64: + return int(val), true + case int: + return val, true + case json.Number: + n, err := val.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} + +// CalculateTokenUsageFromFile calculates token usage from a transcript file. +func CalculateTokenUsageFromFile(path string, startLine int) (*agent.TokenUsage, error) { + if path == "" { + return &agent.TokenUsage{}, nil + } + + lines, _, err := ParseAutohandTranscript(path, startLine) + if err != nil { + return nil, err + } + + return CalculateTokenUsage(lines), nil +} + +// CalculateTotalTokenUsageFromBytes calculates token usage from pre-loaded transcript bytes, +// including subagents. +func CalculateTotalTokenUsageFromBytes(data []byte, startLine int, subagentsDir string) (*agent.TokenUsage, error) { + if len(data) == 0 { + return &agent.TokenUsage{}, nil + } + + parsed, _, err := ParseAutohandTranscriptFromBytes(data, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + mainUsage := CalculateTokenUsage(parsed) + + agentIDs := ExtractSpawnedAgentIDs(parsed) + if len(agentIDs) > 0 && subagentsDir != "" { + 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 { + 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 +} + +// ExtractAllModifiedFilesFromBytes extracts files modified by both the main agent and +// any subagents from pre-loaded transcript bytes. +func ExtractAllModifiedFilesFromBytes(data []byte, startLine int, subagentsDir string) ([]string, error) { + if len(data) == 0 { + return nil, nil + } + + parsed, _, err := ParseAutohandTranscriptFromBytes(data, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + files := ExtractModifiedFiles(parsed) + fileSet := make(map[string]bool, len(files)) + for _, f := range files { + fileSet[f] = true + } + + agentIDs := ExtractSpawnedAgentIDs(parsed) + if subagentsDir == "" { + return files, nil + } + for agentID := range agentIDs { + agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)) + agentLines, _, agentErr := ParseAutohandTranscript(agentPath, 0) + if agentErr != nil { + continue + } + for _, f := range ExtractModifiedFiles(agentLines) { + if !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + + return files, nil +} + +// ExtractSpawnedAgentIDs extracts agent IDs from subagent-stop data in the transcript. +// 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 != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + // Look for Task tool calls that spawned subagents + for _, tc := range msg.ToolCalls { + if tc.Name != "task" && tc.Name != "Task" { + continue + } + // The tool call ID is used as the subagent reference + if tc.ID != "" { + agentIDs[tc.ID] = tc.ID + } + } + } + + return agentIDs +} From cde069ae1d857549ead879c99c8686d10f7bd25b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 11:39:40 +1300 Subject: [PATCH 06/12] Add comprehensive unit tests for Autohand Code agent 86 tests covering all agent components: - autohandcode_test.go: identity, session storage, transcript I/O - hooks_test.go: install/uninstall, idempotency, force reinstall, permission rules, user hook preservation - lifecycle_test.go: event parsing for all hook types, table-driven - transcript_test.go: JSONL parsing, file extraction from toolCalls, token usage from _meta, subagent ID extraction, deduplication --- .../agent/autohandcode/autohandcode_test.go | 435 +++++++ .../cli/agent/autohandcode/hooks_test.go | 542 ++++++++ .../cli/agent/autohandcode/lifecycle_test.go | 292 +++++ .../cli/agent/autohandcode/transcript_test.go | 1087 +++++++++++++++++ 4 files changed, 2356 insertions(+) create mode 100644 cmd/entire/cli/agent/autohandcode/autohandcode_test.go create mode 100644 cmd/entire/cli/agent/autohandcode/hooks_test.go create mode 100644 cmd/entire/cli/agent/autohandcode/lifecycle_test.go create mode 100644 cmd/entire/cli/agent/autohandcode/transcript_test.go diff --git a/cmd/entire/cli/agent/autohandcode/autohandcode_test.go b/cmd/entire/cli/agent/autohandcode/autohandcode_test.go new file mode 100644 index 000000000..612d069ee --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/autohandcode_test.go @@ -0,0 +1,435 @@ +package autohandcode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface assertions. +var ( + _ agent.Agent = (*AutohandCodeAgent)(nil) + _ agent.HookSupport = (*AutohandCodeAgent)(nil) + _ agent.TranscriptAnalyzer = (*AutohandCodeAgent)(nil) + _ agent.TokenCalculator = (*AutohandCodeAgent)(nil) + _ agent.SubagentAwareExtractor = (*AutohandCodeAgent)(nil) + _ agent.HookResponseWriter = (*AutohandCodeAgent)(nil) +) + +// TestDetectPresence uses t.Chdir so it cannot be parallel. +func TestDetectPresence(t *testing.T) { + t.Run("autohand directory exists", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.Mkdir(".autohand", 0o755); err != nil { + t.Fatalf("failed to create .autohand: %v", err) + } + + ag := &AutohandCodeAgent{} + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("no autohand directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + present, err := ag.DetectPresence(context.Background()) + 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 := &AutohandCodeAgent{} + 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 := &AutohandCodeAgent{} + _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") + if err == nil { + t.Error("ReadTranscript() should error on missing file") + } +} + +func TestChunkTranscript_LargeContent(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + + // 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(context.Background(), 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 := &AutohandCodeAgent{} + + original := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi there"} +{"role":"user","content":"thanks"}` + + chunks, err := ag.ChunkTranscript(context.Background(), []byte(original), 60) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if string(reassembled) != original { + t.Errorf("Round-trip mismatch:\n got: %q\nwant: %q", string(reassembled), original) + } +} + +func TestGetSessionDir(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + + dir, err := ag.GetSessionDir("/any/repo/path") + 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, ".autohand", "sessions") + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q", dir, expected) + } +} + +// TestGetSessionDir_AutohandHomeEnv cannot use t.Parallel() due to t.Setenv. +func TestGetSessionDir_AutohandHomeEnv(t *testing.T) { + ag := &AutohandCodeAgent{} + t.Setenv("AUTOHAND_HOME", "/tmp/custom-autohand") + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + expected := "/tmp/custom-autohand/sessions" + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q (AUTOHAND_HOME override)", dir, expected) + } +} + +// TestGetSessionDir_EnvOverride cannot use t.Parallel() due to t.Setenv. +func TestGetSessionDir_EnvOverride(t *testing.T) { + ag := &AutohandCodeAgent{} + override := "/tmp/test-autohand-sessions" + t.Setenv("ENTIRE_TEST_AUTOHAND_PROJECT_DIR", override) + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != override { + t.Errorf("GetSessionDir() = %q, want %q (env override)", dir, override) + } +} + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + result := ag.ResolveSessionFile("/home/user/.autohand/sessions", "abc-123") + expected := "/home/user/.autohand/sessions/abc-123/conversation.jsonl" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +// --- ReadSession / WriteSession tests --- + +func TestReadSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "transcript.jsonl") + + // Write an Autohand-format JSONL transcript with a file-modifying tool call + content := `{"role":"user","content":"create a file","timestamp":"2026-01-01T00:00:00.000Z"} +{"role":"assistant","content":"I will create the file","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"hello.txt","content":"hi"}}]}` + if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session-123", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session-123" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session-123") + } + if session.AgentName != agent.AgentNameAutohandCode { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameAutohandCode) + } + if session.SessionRef != transcriptPath { + t.Errorf("SessionRef = %q, want %q", session.SessionRef, transcriptPath) + } + if len(session.NativeData) == 0 { + t.Error("NativeData should not be empty") + } + if len(session.ModifiedFiles) == 0 { + t.Error("ModifiedFiles should contain at least one file") + } +} + +func TestReadSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + _, err := ag.ReadSession(&agent.HookInput{SessionID: "test"}) + if err == nil { + t.Error("ReadSession() should error on empty SessionRef") + } +} + +func TestReadSession_MissingFile(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + _, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: "/nonexistent/path/transcript.jsonl", + }) + if err == nil { + t.Error("ReadSession() should error on missing file") + } +} + +func TestWriteSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // Write to a nested path to test directory creation + transcriptPath := filepath.Join(tmpDir, "sessions", "project", "transcript.jsonl") + nativeData := []byte(`{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}`) + + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + SessionID: "test-session-456", + AgentName: agent.AgentNameAutohandCode, + SessionRef: transcriptPath, + NativeData: nativeData, + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written correctly + written, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(written) != string(nativeData) { + t.Errorf("written data = %q, want %q", string(written), string(nativeData)) + } +} + +func TestWriteSession_NilSession(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if err := ag.WriteSession(context.Background(), nil); err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/tmp/test.jsonl", + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error for wrong agent name") + } +} + +func TestWriteSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: agent.AgentNameAutohandCode, + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error on empty SessionRef") + } +} + +func TestWriteSession_EmptyNativeData(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: agent.AgentNameAutohandCode, + SessionRef: "/tmp/test.jsonl", + }) + if err == nil { + t.Error("WriteSession() should error on empty NativeData") + } +} + +func TestReadWriteSession_RoundTrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + originalPath := filepath.Join(tmpDir, "original.jsonl") + restoredPath := filepath.Join(tmpDir, "restored.jsonl") + + content := `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"} +{"role":"assistant","content":"hi there","timestamp":"2026-01-01T00:00:01.000Z"}` + if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write original: %v", err) + } + + ag := &AutohandCodeAgent{} + + // Read from original location + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "round-trip-test", + SessionRef: originalPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Write to new location + session.SessionRef = restoredPath + if err := ag.WriteSession(context.Background(), session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify content matches + restored, err := os.ReadFile(restoredPath) + if err != nil { + t.Fatalf("failed to read restored: %v", err) + } + if string(restored) != content { + t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + cmd := ag.FormatResumeCommand("abc-123") + expected := "autohand resume abc-123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestName(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if ag.Name() != agent.AgentNameAutohandCode { + t.Errorf("Name() = %q, want %q", ag.Name(), agent.AgentNameAutohandCode) + } +} + +func TestType(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if ag.Type() != agent.AgentTypeAutohandCode { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeAutohandCode) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() should not be empty") + } +} + +func TestIsPreview(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if !ag.IsPreview() { + t.Error("IsPreview() = false, want true") + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".autohand" { + t.Errorf("ProtectedDirs() = %v, want [.autohand]", dirs) + } +} diff --git a/cmd/entire/cli/agent/autohandcode/hooks_test.go b/cmd/entire/cli/agent/autohandcode/hooks_test.go new file mode 100644 index 000000000..464500eba --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/hooks_test.go @@ -0,0 +1,542 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + count, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // 5 hooks: session-start, pre-prompt, stop, session-end, subagent-stop + if count != 5 { + t.Errorf("InstallHooks() count = %d, want 5", count) + } + + // Verify config.json was created with hooks + config := readAutohandConfig(t, tempDir) + + if len(config.Hooks.Hooks) != 5 { + t.Errorf("Hook count = %d, want 5", len(config.Hooks.Hooks)) + } + + // Verify hook events + assertAutohandHookExists(t, config.Hooks.Hooks, "session-start", entireHookPrefix+"session-start", "session-start") + assertAutohandHookExists(t, config.Hooks.Hooks, "pre-prompt", entireHookPrefix+"pre-prompt", "pre-prompt") + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", entireHookPrefix+"stop", "stop") + assertAutohandHookExists(t, config.Hooks.Hooks, "session-end", entireHookPrefix+"session-end", "session-end") + assertAutohandHookExists(t, config.Hooks.Hooks, "subagent-stop", entireHookPrefix+"subagent-stop", "subagent-stop") + + // Verify AreHooksInstalled returns true + if !ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return true after install") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + count1, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 5 { + t.Errorf("first InstallHooks() count = %d, want 5", count1) + } + + // Second install should add 0 hooks + count2, err := ag.InstallHooks(context.Background(), 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 5 hooks total + config := readAutohandConfig(t, tempDir) + if len(config.Hooks.Hooks) != 5 { + t.Errorf("Hook count = %d after double install, want 5", len(config.Hooks.Hooks)) + } +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + // Verify local dev commands use AUTOHAND_PROJECT_DIR format + assertAutohandHookExists(t, config.Hooks.Hooks, "session-start", + entireHookLocalDevPrefix+"session-start", "session-start localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", + entireHookLocalDevPrefix+"stop", "stop localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "pre-prompt", + entireHookLocalDevPrefix+"pre-prompt", "pre-prompt localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "session-end", + entireHookLocalDevPrefix+"session-end", "session-end localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "subagent-stop", + entireHookLocalDevPrefix+"subagent-stop", "subagent-stop localDev") +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should replace hooks + count, err := ag.InstallHooks(context.Background(), false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 5 { + t.Errorf("force InstallHooks() count = %d, want 5", count) + } +} + +func TestInstallHooks_PermissionsDeny_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify permissions.rules contains our deny rule + if !containsDenyRule(rules, metadataDenyRule) { + t.Errorf("permissions.rules = %v, want to contain deny rule for .entire/metadata/**", rules) + } +} + +func TestInstallHooks_PermissionsDeny_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install + _, err = ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Count occurrences of our rule + count := 0 + for _, rule := range rules { + if rule == metadataDenyRule { + count++ + } + } + if count != 1 { + t.Errorf("permissions.rules contains %d copies of deny rule, want 1", count) + } +} + +func TestInstallHooks_PermissionsDeny_PreservesUserRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config.json with existing user permission rule + writeAutohandConfigFile(t, tempDir, `{ + "permissions": { + "rules": [{"tool":"exec_command","pattern":"rm -rf *","action":"deny"}] + } +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify both rules exist + userRule := AutohandPermissionRule{Tool: "exec_command", Pattern: "rm -rf *", Action: "deny"} + if !containsDenyRule(rules, userRule) { + t.Errorf("permissions.rules = %v, want to contain user rule", rules) + } + if !containsDenyRule(rules, metadataDenyRule) { + t.Errorf("permissions.rules = %v, want to contain Entire rule", rules) + } +} + +func TestInstallHooks_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config.json with unknown top-level fields + writeAutohandConfigFile(t, tempDir, `{ + "model": "gpt-4", + "customSetting": {"nested": "value"} +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read raw config to check for unknown fields + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + // Verify "model" field is preserved + if _, ok := rawConfig["model"]; !ok { + t.Errorf("model field was not preserved") + } + + // Verify "customSetting" field is preserved + if _, ok := rawConfig["customSetting"]; !ok { + t.Errorf("customSetting field was not preserved") + } +} + +//nolint:tparallel // Parent uses t.Chdir() which prevents t.Parallel(); subtests only read from pre-loaded data +func TestInstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with user hooks on the same events we use + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"echo user stop hook","description":"User stop hook","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + t.Run("user hook preserved", func(t *testing.T) { + t.Parallel() + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", "echo user stop hook", "user stop hook") + }) + + t.Run("entire hook added", func(t *testing.T) { + t.Parallel() + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", entireHookPrefix+"stop", "entire stop hook") + }) +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify hooks are installed + if !ag.AreHooksInstalled(context.Background()) { + t.Error("hooks should be installed before uninstall") + } + + // Uninstall + err = ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify hooks are removed + if ag.AreHooksInstalled(context.Background()) { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // Should not error when no config file exists + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() should not error when no config file: %v", err) + } +} + +func TestUninstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with both user and entire hooks + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"echo user hook","description":"User hook","enabled":true}, + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + // Verify only user hooks remain + if len(config.Hooks.Hooks) != 1 { + t.Errorf("Hook count = %d after uninstall, want 1 (user only)", len(config.Hooks.Hooks)) + } + + // Verify it is the user hook + if len(config.Hooks.Hooks) > 0 { + if config.Hooks.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) + + ag := &AutohandCodeAgent{} + + // First install (which adds the deny rule) + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify deny rule was added + rules := readAutohandPermissionRules(t, tempDir) + if !containsDenyRule(rules, metadataDenyRule) { + t.Fatal("deny rule should be present after install") + } + + // Uninstall + err = ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify deny rule was removed + rules = readAutohandPermissionRules(t, tempDir) + if containsDenyRule(rules, metadataDenyRule) { + t.Error("deny rule should be removed after uninstall") + } +} + +func TestUninstallHooks_PreservesUserDenyRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with user deny rule and entire deny rule + writeAutohandConfigFile(t, tempDir, `{ + "permissions": { + "rules": [ + {"tool":"exec_command","pattern":"rm -rf *","action":"deny"}, + {"tool":"read_file","pattern":".entire/metadata/**","action":"deny"} + ] + }, + "hooks": { + "hooks": [ + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify user deny rule is preserved + userRule := AutohandPermissionRule{Tool: "exec_command", Pattern: "rm -rf *", Action: "deny"} + if !containsDenyRule(rules, userRule) { + t.Errorf("user deny rule was removed, got: %v", rules) + } + + // Verify entire deny rule is removed + if containsDenyRule(rules, metadataDenyRule) { + t.Errorf("entire deny rule should be removed, got: %v", rules) + } +} + +func TestAreHooksInstalled_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + if ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return false when no config exists") + } +} + +func TestAreHooksInstalled_EmptyConfig(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + writeAutohandConfigFile(t, tempDir, `{}`) + + ag := &AutohandCodeAgent{} + if ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return false with empty config") + } +} + +func TestAreHooksInstalled_Installed(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + if !ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return true when stop hook is present") + } +} + +// --- Helper functions --- + +func writeAutohandConfigFile(t *testing.T, tempDir, content string) { + t.Helper() + autohandDir := filepath.Join(tempDir, ".autohand") + if err := os.MkdirAll(autohandDir, 0o755); err != nil { + t.Fatalf("failed to create .autohand dir: %v", err) + } + configPath := filepath.Join(autohandDir, AutohandConfigFileName) + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write config.json: %v", err) + } +} + +func readAutohandConfig(t *testing.T, tempDir string) AutohandConfig { + t.Helper() + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + return config +} + +func readAutohandPermissionRules(t *testing.T, tempDir string) []AutohandPermissionRule { + t.Helper() + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + permRaw, ok := rawConfig["permissions"] + if !ok { + return nil + } + + var rawPermissions map[string]json.RawMessage + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + t.Fatalf("failed to parse permissions: %v", err) + } + + rulesRaw, ok := rawPermissions["rules"] + if !ok { + return nil + } + + var rules []AutohandPermissionRule + if err := json.Unmarshal(rulesRaw, &rules); err != nil { + t.Fatalf("failed to parse rules: %v", err) + } + return rules +} + +func containsDenyRule(rules []AutohandPermissionRule, target AutohandPermissionRule) bool { + for _, rule := range rules { + if rule == target { + return true + } + } + return false +} + +func assertAutohandHookExists(t *testing.T, hooks []AutohandHookDef, event, command, description string) { + t.Helper() + for _, h := range hooks { + if h.Event == event && h.Command == command { + return + } + } + t.Errorf("%s hook not found (event=%q, command=%q)", description, event, command) +} diff --git a/cmd/entire/cli/agent/autohandcode/lifecycle_test.go b/cmd/entire/cli/agent/autohandcode/lifecycle_test.go new file mode 100644 index 000000000..60081fd97 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/lifecycle_test.go @@ -0,0 +1,292 @@ +package autohandcode + +import ( + "context" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "test-session", "cwd": "/workspace", "hook_event_name": "session-start"}` + + event, err := ag.ParseHookEvent(context.Background(), 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) + } + // SessionRef should be resolved from session_id + if event.SessionRef == "" { + t.Error("expected non-empty SessionRef") + } +} + +func TestParseHookEvent_TurnStart(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-1", "cwd": "/workspace", "hook_event_name": "pre-prompt", "instruction": "Fix the bug"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNamePrePrompt, 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) + } + if event.SessionID != "sess-1" { + t.Errorf("expected session_id 'sess-1', got %q", event.SessionID) + } +} + +func TestParseHookEvent_TurnStart_NoInstruction(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + // pre-prompt with no instruction field + input := `{"session_id": "sess-empty", "cwd": "/workspace", "hook_event_name": "pre-prompt"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNamePrePrompt, 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 != "" { + t.Errorf("expected empty prompt, got %q", event.Prompt) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-2", "cwd": "/workspace", "hook_event_name": "stop", "tokens_used": 1000, "tool_calls_count": 5}` + + event, err := ag.ParseHookEvent(context.Background(), 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) + } + if event.SessionID != "sess-2" { + t.Errorf("expected session_id 'sess-2', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-3", "cwd": "/workspace", "hook_event_name": "session-end"}` + + event, err := ag.ParseHookEvent(context.Background(), 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_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-5", "cwd": "/workspace", "hook_event_name": "subagent-stop", "subagent_id": "agent-789", "subagent_name": "helper"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameSubagentStop, 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_Notification(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), 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 := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), "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 := &AutohandCodeAgent{} + _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("")) + if err == nil { + t.Fatal("expected error for empty input") + } +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("not json")) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestHookNames(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + names := ag.HookNames() + + expected := []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNamePrePrompt, + HookNameSubagentStop, + HookNameNotification, + } + + if len(names) != len(expected) { + t.Fatalf("HookNames() returned %d names, want %d", len(names), len(expected)) + } + + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + + for _, exp := range expected { + if !nameSet[exp] { + t.Errorf("HookNames() missing %q", exp) + } + } +} + +func TestWriteHookResponse(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + // WriteHookResponse writes to os.Stdout, so we just verify it does not error. + // A more thorough test would capture stdout, but this is sufficient for coverage. + err := ag.WriteHookResponse("test message") + if err != nil { + t.Fatalf("WriteHookResponse() error = %v", err) + } +} + +func TestParseHookEvent_AllHookTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hookName string + input string + expectedType agent.EventType + nilEvent bool + }{ + { + name: "session-start", + hookName: HookNameSessionStart, + input: `{"session_id":"s1","cwd":"/workspace","hook_event_name":"session-start"}`, + expectedType: agent.SessionStart, + }, + { + name: "pre-prompt", + hookName: HookNamePrePrompt, + input: `{"session_id":"s2","cwd":"/workspace","hook_event_name":"pre-prompt","instruction":"hello"}`, + expectedType: agent.TurnStart, + }, + { + name: "stop", + hookName: HookNameStop, + input: `{"session_id":"s3","cwd":"/workspace","hook_event_name":"stop","tokens_used":100}`, + expectedType: agent.TurnEnd, + }, + { + name: "session-end", + hookName: HookNameSessionEnd, + input: `{"session_id":"s4","cwd":"/workspace","hook_event_name":"session-end"}`, + expectedType: agent.SessionEnd, + }, + { + name: "subagent-stop", + hookName: HookNameSubagentStop, + input: `{"session_id":"s5","cwd":"/workspace","hook_event_name":"subagent-stop","subagent_id":"sub1"}`, + expectedType: agent.SubagentEnd, + }, + { + name: "notification returns nil", + hookName: HookNameNotification, + input: `{"session_id":"s6","cwd":"/workspace","hook_event_name":"notification"}`, + nilEvent: true, + }, + { + name: "unknown returns nil", + hookName: "totally-unknown", + input: `{"session_id":"s7"}`, + nilEvent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), tt.hookName, strings.NewReader(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.nilEvent { + if event != nil { + t.Errorf("expected nil event, got %+v", event) + } + return + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != tt.expectedType { + t.Errorf("expected %v, got %v", tt.expectedType, event.Type) + } + }) + } +} diff --git a/cmd/entire/cli/agent/autohandcode/transcript_test.go b/cmd/entire/cli/agent/autohandcode/transcript_test.go new file mode 100644 index 000000000..4c2ba7218 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/transcript_test.go @@ -0,0 +1,1087 @@ +package autohandcode + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +func TestParseAutohandTranscript_BasicMessages(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"hi there","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n", + ) + + lines, totalLines, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + if totalLines != 2 { + t.Errorf("totalLines = %d, want 2", totalLines) + } + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandTranscript_FiltersNonUserAssistant(t *testing.T) { + t.Parallel() + + // Autohand format has tool and system roles that should be filtered out + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"I will create the file","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.ts"}}]}` + "\n" + + `{"role":"tool","content":"File written","name":"write_file","tool_call_id":"tc_1"}` + "\n" + + `{"role":"system","content":"System prompt"}` + "\n" + + `{"role":"assistant","content":"Done!","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n", + ) + + lines, totalLines, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + if totalLines != 5 { + t.Errorf("totalLines = %d, want 5", totalLines) + } + // Only user and assistant messages (tool and system should be filtered) + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3 (tool and system should be filtered)", len(lines)) + } + + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } + if lines[2].Type != transcript.TypeAssistant { + t.Errorf("lines[2].Type = %q, want %q", lines[2].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandTranscript_StartLineOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := tmpDir + "/transcript.jsonl" + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"bye","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n", + ) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("failed to write: %v", err) + } + + // Read from line 1 onward (skip first message) + lines, totalLines, err := ParseAutohandTranscript(path, 1) + if err != nil { + t.Fatalf("ParseAutohandTranscript() error = %v", err) + } + + if totalLines != 3 { + t.Errorf("totalLines = %d, want 3", totalLines) + } + + if len(lines) != 2 { + t.Fatalf("got %d lines from offset 1, 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 TestParseAutohandTranscriptFromBytes_StartLineBeyondEnd(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 100) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes(100) error = %v", err) + } + if len(lines) != 0 { + t.Fatalf("startLine=100: got %d lines, want 0", len(lines)) + } +} + +func TestParseAutohandTranscript_MalformedLines(t *testing.T) { + t.Parallel() + + // Transcript with some broken JSON lines interspersed with valid ones + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"broken json` + "\n" + + `not even close to json` + "\n" + + `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + // Only the 2 valid user/assistant lines should be parsed + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 (malformed lines should be silently skipped)", len(lines)) + } + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandLine_Roles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantType string + wantOK bool + }{ + { + name: "user role", + input: `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}`, + wantType: transcript.TypeUser, + wantOK: true, + }, + { + name: "assistant role", + input: `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}`, + wantType: transcript.TypeAssistant, + wantOK: true, + }, + { + name: "tool role filtered", + input: `{"role":"tool","content":"result","name":"write_file","tool_call_id":"tc_1"}`, + wantOK: false, + }, + { + name: "system role filtered", + input: `{"role":"system","content":"system prompt"}`, + wantOK: false, + }, + { + name: "empty JSON", + input: `{}`, + wantOK: false, + }, + { + name: "invalid JSON", + input: `not json`, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + line, ok := parseAutohandLine([]byte(tt.input)) + if ok != tt.wantOK { + t.Errorf("parseAutohandLine() ok = %v, want %v", ok, tt.wantOK) + } + if ok && line.Type != tt.wantType { + t.Errorf("parseAutohandLine() type = %q, want %q", line.Type, tt.wantType) + } + }) + } +} + +func TestParseAutohandLine_TimestampAsUUID(t *testing.T) { + t.Parallel() + + input := []byte(`{"role":"user","content":"hello","timestamp":"2026-01-01T12:00:00.000Z"}`) + line, ok := parseAutohandLine(input) + if !ok { + t.Fatal("expected ok = true") + } + // Autohand uses timestamp as pseudo-UUID since there are no message IDs + if line.UUID != "2026-01-01T12:00:00.000Z" { + t.Errorf("UUID = %q, want %q (timestamp as UUID)", line.UUID, "2026-01-01T12:00:00.000Z") + } +} + +// --- ExtractModifiedFiles tests --- + +func TestExtractModifiedFiles_WriteFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"creating file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.ts","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "foo.ts" { + t.Errorf("ExtractModifiedFiles() = %v, want [foo.ts]", files) + } +} + +func TestExtractModifiedFiles_EditFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"editing file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"edit_file","input":{"path":"bar.go","old":"x","new":"y"}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "bar.go" { + t.Errorf("ExtractModifiedFiles() = %v, want [bar.go]", files) + } +} + +func TestExtractModifiedFiles_CreateFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"creating file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"create_file","input":{"path":"new.py","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "new.py" { + t.Errorf("ExtractModifiedFiles() = %v, want [new.py]", files) + } +} + +func TestExtractModifiedFiles_PatchFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"patching file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"patch_file","input":{"path":"app.js","patch":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "app.js" { + t.Errorf("ExtractModifiedFiles() = %v, want [app.js]", files) + } +} + +func TestExtractModifiedFiles_FileFieldFallback(t *testing.T) { + t.Parallel() + + // Some tools use "file" instead of "path" + data := []byte(`{"role":"assistant","content":"creating","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"file":"alt.txt","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "alt.txt" { + t.Errorf("ExtractModifiedFiles() = %v, want [alt.txt]", files) + } +} + +func TestExtractModifiedFiles_MultipleToolCalls(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"creating files","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.go"}},{"id":"tc_2","name":"edit_file","input":{"path":"bar.go"}}]}` + "\n" + + `{"role":"assistant","content":"more","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_3","name":"create_file","input":{"path":"baz.go"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 3 { + t.Fatalf("ExtractModifiedFiles() got %d files, want 3", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("missing foo.go") + } + if !hasFile("bar.go") { + t.Error("missing bar.go") + } + if !hasFile("baz.go") { + t.Error("missing baz.go") + } +} + +func TestExtractModifiedFiles_Deduplication(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"first write","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"same.go"}}]}` + "\n" + + `{"role":"assistant","content":"second write","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_2","name":"write_file","input":{"path":"same.go"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 { + t.Errorf("ExtractModifiedFiles() got %d files, want 1 (deduplicated)", len(files)) + } +} + +func TestExtractModifiedFiles_IgnoresNonFileTools(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"running command","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"exec_command","input":{"command":"ls"}}]}` + "\n" + + `{"role":"assistant","content":"reading","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_2","name":"read_file","input":{"path":"readme.md"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 0 { + t.Errorf("ExtractModifiedFiles() = %v, want empty (non-file tools should be ignored)", files) + } +} + +func TestExtractModifiedFiles_Empty(t *testing.T) { + t.Parallel() + + files := ExtractModifiedFiles(nil) + if files != nil { + t.Errorf("ExtractModifiedFiles(nil) = %v, want nil", files) + } +} + +// --- CalculateTokenUsage tests --- + +func TestCalculateTokenUsage_WithMeta(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"first response"`), + Meta: map[string]any{ + "input_tokens": float64(100), + "output_tokens": float64(50), + "cache_creation_input_tokens": float64(200), + "cache_read_input_tokens": float64(30), + }, + }), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"second response"`), + Meta: map[string]any{ + "input_tokens": float64(50), + "output_tokens": float64(25), + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } + if usage.InputTokens != 150 { + t.Errorf("InputTokens = %d, want 150", usage.InputTokens) + } + if usage.OutputTokens != 75 { + t.Errorf("OutputTokens = %d, want 75", usage.OutputTokens) + } + if usage.CacheCreationTokens != 200 { + t.Errorf("CacheCreationTokens = %d, want 200", usage.CacheCreationTokens) + } + if usage.CacheReadTokens != 30 { + t.Errorf("CacheReadTokens = %d, want 30", usage.CacheReadTokens) + } +} + +func TestCalculateTokenUsage_NoMeta(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"response without meta"`), + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0 (no _meta means no API call counted)", usage.APICallCount) + } + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } +} + +func TestCalculateTokenUsage_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeUser, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{Role: "user", Content: json.RawMessage(`"hello"`)}), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"response"`), + Meta: map[string]any{ + "input_tokens": float64(10), + "output_tokens": float64(20), + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"response","timestamp":"2026-01-01T00:00:01.000Z","_meta":{"input_tokens":100,"output_tokens":50}}` + "\n" + + `{"role":"user","content":"bye","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"goodbye","timestamp":"2026-01-01T00:00:03.000Z","_meta":{"input_tokens":200,"output_tokens":100}}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Test from line 0 - all turns + usage1, err := CalculateTokenUsageFromFile(transcriptPath, 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile(0) error: %v", err) + } + if usage1.InputTokens != 300 { + t.Errorf("From line 0: InputTokens = %d, want 300", usage1.InputTokens) + } + if usage1.OutputTokens != 150 { + t.Errorf("From line 0: OutputTokens = %d, want 150", usage1.OutputTokens) + } + if usage1.APICallCount != 2 { + t.Errorf("From line 0: APICallCount = %d, want 2", usage1.APICallCount) + } + + // Test from line 2 - second turn only + usage2, err := CalculateTokenUsageFromFile(transcriptPath, 2) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile(2) error: %v", err) + } + if usage2.InputTokens != 200 { + t.Errorf("From line 2: InputTokens = %d, want 200", usage2.InputTokens) + } + if usage2.OutputTokens != 100 { + t.Errorf("From line 2: OutputTokens = %d, want 100", usage2.OutputTokens) + } + if usage2.APICallCount != 1 { + t.Errorf("From line 2: APICallCount = %d, want 1", usage2.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile_EmptyPath(t *testing.T) { + t.Parallel() + + usage, err := CalculateTokenUsageFromFile("", 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile('') error: %v", err) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +// --- ExtractSpawnedAgentIDs tests --- + +func TestExtractSpawnedAgentIDs_FromTaskCalls(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning agent"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-abc", Name: "Task", Input: json.RawMessage(`{"prompt":"do something"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 1 { + t.Fatalf("Expected 1 agent ID, got %d", len(agentIDs)) + } + if _, ok := agentIDs["agent-abc"]; !ok { + t.Errorf("Expected agent ID 'agent-abc', got %v", agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_LowercaseTask(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-def", Name: "task", Input: json.RawMessage(`{"prompt":"hello"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 1 { + t.Fatalf("Expected 1 agent ID, got %d", len(agentIDs)) + } + if _, ok := agentIDs["agent-def"]; !ok { + t.Errorf("Expected agent ID 'agent-def', got %v", agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_MultipleAgents(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"first task"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-1", Name: "Task", Input: json.RawMessage(`{}`)}, + }, + }), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"second task"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-2", Name: "Task", Input: json.RawMessage(`{}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 2 { + t.Fatalf("Expected 2 agent IDs, got %d", len(agentIDs)) + } +} + +func TestExtractSpawnedAgentIDs_NoTaskCalls(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"just writing"`), + ToolCalls: []autohandToolCall{ + {ID: "tc_1", Name: "write_file", Input: json.RawMessage(`{"path":"foo.go"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 0 { + t.Errorf("Expected 0 agent IDs, got %d: %v", len(agentIDs), agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeUser, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{Role: "user", Content: json.RawMessage(`"hello"`)}), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 0 { + t.Errorf("Expected 0 agent IDs, got %d", len(agentIDs)) + } +} + +// --- ExtractAllModifiedFilesFromBytes tests --- + +func TestExtractAllModifiedFilesFromBytes_IncludesSubagentFiles(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + 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 + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/main.go") + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + // Subagent transcript: Write to helper.go + Edit to utils.go + subTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:02.000Z", "/repo/helper.go") + "\n" + + makeAutohandEditToolLine(t, "2026-01-01T00:00:03.000Z", "/repo/utils.go") + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() 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 TestExtractAllModifiedFilesFromBytes_DeduplicatesAcrossAgents(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + if err := os.MkdirAll(subagentsDir, 0o755); err != nil { + t.Fatalf("failed to create subagents dir: %v", err) + } + + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/shared.go") + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + subTranscript := makeAutohandEditToolLine(t, "2026-01-01T00:00:02.000Z", "/repo/shared.go") + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + + if len(files) != 1 { + t.Errorf("expected 1 file (deduplicated), got %d: %v", len(files), files) + } +} + +func TestExtractAllModifiedFilesFromBytes_NoSubagents(t *testing.T) { + t.Parallel() + + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/solo.go") + "\n" + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, "/nonexistent") + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + + if len(files) != 1 || files[0] != "/repo/solo.go" { + t.Errorf("expected [/repo/solo.go], got %v", files) + } +} + +func TestExtractAllModifiedFilesFromBytes_EmptyData(t *testing.T) { + t.Parallel() + + files, err := ExtractAllModifiedFilesFromBytes(nil, 0, "") + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + if files != nil { + t.Errorf("expected nil, got %v", files) + } +} + +// --- CalculateTotalTokenUsageFromBytes tests --- + +func TestCalculateTotalTokenUsageFromBytes_WithSubagents(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + if err := os.MkdirAll(subagentsDir, 0o755); err != nil { + t.Fatalf("failed to create subagents dir: %v", err) + } + + mainTranscript := makeAutohandTokenLine(t, "2026-01-01T00:00:00.000Z", 100, 50) + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + subTranscript := makeAutohandTokenLine(t, "2026-01-01T00:00:02.000Z", 200, 80) + "\n" + + makeAutohandTokenLine(t, "2026-01-01T00:00:03.000Z", 150, 60) + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + usage, err := CalculateTotalTokenUsageFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromBytes() error: %v", err) + } + + // Main agent tokens + if usage.InputTokens != 100 { + t.Errorf("main InputTokens = %d, want 100", usage.InputTokens) + } + if usage.OutputTokens != 50 { + t.Errorf("main OutputTokens = %d, want 50", usage.OutputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("main APICallCount = %d, want 1", usage.APICallCount) + } + + // Subagent tokens + if usage.SubagentTokens == nil { + t.Fatal("SubagentTokens is nil") + } + if usage.SubagentTokens.InputTokens != 350 { + t.Errorf("subagent InputTokens = %d, want 350 (200+150)", usage.SubagentTokens.InputTokens) + } + if usage.SubagentTokens.OutputTokens != 140 { + t.Errorf("subagent OutputTokens = %d, want 140 (80+60)", usage.SubagentTokens.OutputTokens) + } + if usage.SubagentTokens.APICallCount != 2 { + t.Errorf("subagent APICallCount = %d, want 2", usage.SubagentTokens.APICallCount) + } +} + +func TestCalculateTotalTokenUsageFromBytes_EmptyData(t *testing.T) { + t.Parallel() + + usage, err := CalculateTotalTokenUsageFromBytes(nil, 0, "") + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromBytes() error: %v", err) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +// --- ExtractPrompts tests --- + +func TestExtractPrompts(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"Fix the login bug","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"I'll fix the bug.","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Now add tests","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + prompts, err := ag.ExtractPrompts(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 2 { + t.Fatalf("ExtractPrompts() got %d prompts, want 2", len(prompts)) + } + if prompts[0] != "Fix the login bug" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Fix the login bug") + } + if prompts[1] != "Now add tests" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "Now add tests") + } +} + +func TestExtractPrompts_WithOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"First prompt","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"Done.","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Second prompt","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"Done again.","timestamp":"2026-01-01T00:00:03.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + // Skip first 2 lines (first user+assistant turn) + prompts, err := ag.ExtractPrompts(transcriptPath, 2) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts)) + } + if prompts[0] != "Second prompt" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Second prompt") + } +} + +// --- ExtractSummary tests --- + +func TestExtractSummary(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"Fix the bug","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"Working on it...","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Thanks","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"All done! The bug is fixed.","timestamp":"2026-01-01T00:00:03.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "All done! The bug is fixed." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "All done! The bug is fixed.") + } +} + +func TestExtractSummary_EmptyTranscript(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + if err := os.WriteFile(transcriptPath, []byte(""), 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &AutohandCodeAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "" { + t.Errorf("ExtractSummary() = %q, want empty string", summary) + } +} + +// --- Test helpers --- + +// 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 +} + +// makeAutohandWriteToolLine returns an Autohand-format JSONL line with a write_file tool call. +func makeAutohandWriteToolLine(t *testing.T, timestamp, filePath string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"creating file"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: "tc_" + timestamp, + Name: ToolWriteFile, + Input: json.RawMessage(`{"path":"` + filePath + `","content":"..."}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandEditToolLine returns an Autohand-format JSONL line with an edit_file tool call. +func makeAutohandEditToolLine(t *testing.T, timestamp, filePath string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"editing file"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: "tc_" + timestamp, + Name: ToolEditFile, + Input: json.RawMessage(`{"path":"` + filePath + `","old":"x","new":"y"}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandTaskToolLine returns an Autohand-format JSONL line with a Task tool call. +func makeAutohandTaskToolLine(t *testing.T, timestamp, agentID string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning subagent"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: agentID, + Name: "Task", + Input: json.RawMessage(`{"prompt":"do something"}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandTokenLine returns an Autohand-format JSONL line with _meta token data. +func makeAutohandTokenLine(t *testing.T, timestamp string, inputTokens, outputTokens int) string { + t.Helper() + + // Build the _meta map with proper typing for JSON marshal/unmarshal + meta := map[string]any{ + "input_tokens": inputTokens, + "output_tokens": outputTokens, + } + + msg := map[string]any{ + "role": "assistant", + "content": "response", + "timestamp": timestamp, + "_meta": meta, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(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) + } +} From 38291b5e111ec29f3504312c0f136ff4d344d824 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 11:40:37 +1300 Subject: [PATCH 07/12] Wire up Autohand Code agent registration imports Add blank imports in config.go and hooks_cmd.go to trigger init() self-registration. This makes autohand-code available in the agent registry and generates hook subcommands under entire hooks autohand-code. --- cmd/entire/cli/config.go | 1 + cmd/entire/cli/hooks_cmd.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 21fcba405..1d2dc1dcb 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/strategy" // Import agents to register them + _ "github.com/entireio/cli/cmd/entire/cli/agent/autohandcode" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" ) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 7611a1945..00433a32e 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -3,6 +3,7 @@ package cli 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/autohandcode" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" From 8f4d1689304516386f4fdf0bcd1754690ffcbac0 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 11:42:28 +1300 Subject: [PATCH 08/12] Fix gofmt formatting in autohandcode package Apply gofmt -s -w to fix struct tag alignment and spacing issues in types.go and hooks.go. --- cmd/entire/cli/agent/autohandcode/hooks.go | 6 +-- cmd/entire/cli/agent/autohandcode/types.go | 44 +++++++++++----------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/cmd/entire/cli/agent/autohandcode/hooks.go b/cmd/entire/cli/agent/autohandcode/hooks.go index 06c58cdde..812f1f3db 100644 --- a/cmd/entire/cli/agent/autohandcode/hooks.go +++ b/cmd/entire/cli/agent/autohandcode/hooks.go @@ -92,9 +92,9 @@ func (a *AutohandCodeAgent) InstallHooks(ctx context.Context, localDev bool, for // Add hooks if they don't exist hookDefs := []struct { - event string - verb string - desc string + event string + verb string + desc string }{ {event: "session-start", verb: "session-start", desc: "Entire: session start checkpoint"}, {event: "pre-prompt", verb: "pre-prompt", desc: "Entire: capture user prompt"}, diff --git a/cmd/entire/cli/agent/autohandcode/types.go b/cmd/entire/cli/agent/autohandcode/types.go index b877f6fab..a60779ec8 100644 --- a/cmd/entire/cli/agent/autohandcode/types.go +++ b/cmd/entire/cli/agent/autohandcode/types.go @@ -9,8 +9,8 @@ type AutohandConfig struct { // AutohandHooksSettings contains the hooks configuration. type AutohandHooksSettings struct { - Enabled *bool `json:"enabled,omitempty"` - Hooks []AutohandHookDef `json:"hooks,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Hooks []AutohandHookDef `json:"hooks,omitempty"` } // AutohandHookDef represents a single hook definition. @@ -47,10 +47,10 @@ type hookInputBase struct { // hookInputPrompt extends hookInputBase for pre-prompt events. type hookInputPrompt struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - Instruction string `json:"instruction"` + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Instruction string `json:"instruction"` MentionedFiles []string `json:"mentioned_files"` } @@ -80,15 +80,15 @@ type hookInputTool struct { // hookInputSubagent extends hookInputBase for subagent-stop events. type hookInputSubagent struct { - SessionID string `json:"session_id"` - CWD string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - SubagentID string `json:"subagent_id"` - SubagentName string `json:"subagent_name"` - SubagentType string `json:"subagent_type"` - SubagentSuccess *bool `json:"subagent_success"` - SubagentError string `json:"subagent_error"` - SubagentDuration int `json:"subagent_duration"` + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + SubagentID string `json:"subagent_id"` + SubagentName string `json:"subagent_name"` + SubagentType string `json:"subagent_type"` + SubagentSuccess *bool `json:"subagent_success"` + SubagentError string `json:"subagent_error"` + SubagentDuration int `json:"subagent_duration"` } // Tool names used in Autohand Code transcripts for file modification. @@ -122,13 +122,13 @@ type autohandToolInput struct { // autohandMessage represents a single JSONL line in the Autohand transcript. type autohandMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Timestamp string `json:"timestamp"` - ToolCalls []autohandToolCall `json:"toolCalls,omitempty"` - Name string `json:"name,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - Meta map[string]any `json:"_meta,omitempty"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Timestamp string `json:"timestamp"` + ToolCalls []autohandToolCall `json:"toolCalls,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` } // autohandTokenMeta is extracted from _meta for token tracking. From afcb103a60ae1867e5389daec13158f430b10b04 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 11:44:08 +1300 Subject: [PATCH 09/12] Add integration tests for Autohand Code hook setup Verify entire enable --agent autohand-code: - Installs all 5 required hooks in .autohand/config.json - Adds permission deny rule for .entire/metadata/** - Preserves existing user hooks and custom config fields --- .../setup_autohand_hooks_test.go | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 cmd/entire/cli/integration_test/setup_autohand_hooks_test.go diff --git a/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go b/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go new file mode 100644 index 000000000..4820ebc7a --- /dev/null +++ b/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go @@ -0,0 +1,173 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent/autohandcode" +) + +// Use the real Autohand types to avoid schema drift. +type AutohandConfig = autohandcode.AutohandConfig + +// TestSetupAutohandHooks_AddsAllRequiredHooks is a smoke test verifying that +// `entire enable --agent autohand-code` adds all required hooks to the correct file. +func TestSetupAutohandHooks_AddsAllRequiredHooks(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire() + + // Create initial commit (required for setup) + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Run entire enable --agent autohand-code (non-interactive) + output, err := env.RunCLIWithError("enable", "--agent", "autohand-code") + if err != nil { + t.Fatalf("enable autohand-code command failed: %v\nOutput: %s", err, output) + } + + // Read the generated config.json + config := readAutohandConfigFile(t, env) + + // Verify all hooks exist (5 total) + expectedEvents := map[string]bool{ + "session-start": false, + "pre-prompt": false, + "stop": false, + "session-end": false, + "subagent-stop": false, + } + for _, hook := range config.Hooks.Hooks { + if _, ok := expectedEvents[hook.Event]; ok { + if strings.Contains(hook.Command, "entire hooks autohand-code") { + expectedEvents[hook.Event] = true + } + } + } + for event, found := range expectedEvents { + if !found { + t.Errorf("%s hook should exist", event) + } + } + + // Verify permissions.rules contains metadata deny rule + configPath := filepath.Join(env.RepoDir, ".autohand", autohandcode.AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + content := string(data) + if !strings.Contains(content, ".entire/metadata/**") { + t.Error("config.json should contain permission deny rule for .entire/metadata/**") + } +} + +// TestSetupAutohandHooks_PreservesExistingSettings verifies that +// enable autohand-code doesn't nuke existing settings or user-configured hooks. +func TestSetupAutohandHooks_PreservesExistingSettings(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire() + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Create existing config with custom fields and user hooks + autohandDir := filepath.Join(env.RepoDir, ".autohand") + if err := os.MkdirAll(autohandDir, 0o755); err != nil { + t.Fatalf("failed to create .autohand dir: %v", err) + } + + existingConfig := `{ + "customSetting": "should-be-preserved", + "hooks": { + "enabled": true, + "hooks": [ + {"event": "stop", "command": "echo user-stop-hook", "description": "My custom hook"} + ] + } +}` + configPath := filepath.Join(autohandDir, autohandcode.AutohandConfigFileName) + if err := os.WriteFile(configPath, []byte(existingConfig), 0o644); err != nil { + t.Fatalf("failed to write existing config: %v", err) + } + + // Run enable autohand-code + output, err := env.RunCLIWithError("enable", "--agent", "autohand-code") + if err != nil { + t.Fatalf("enable autohand-code failed: %v\nOutput: %s", err, output) + } + + // Verify custom setting is preserved + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]interface{} + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + if rawConfig["customSetting"] != "should-be-preserved" { + t.Error("customSetting should be preserved after enable autohand-code") + } + + // Verify user hooks are preserved + config := readAutohandConfigFile(t, env) + + foundUserHook := false + for _, hook := range config.Hooks.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 + foundSessionStart := false + foundPrePrompt := false + for _, hook := range config.Hooks.Hooks { + if hook.Event == "session-start" && strings.Contains(hook.Command, "entire hooks autohand-code") { + foundSessionStart = true + } + if hook.Event == "pre-prompt" && strings.Contains(hook.Command, "entire hooks autohand-code") { + foundPrePrompt = true + } + } + if !foundSessionStart { + t.Error("session-start hook should be added") + } + if !foundPrePrompt { + t.Error("pre-prompt hook should be added") + } +} + +// Helper functions + +func readAutohandConfigFile(t *testing.T, env *TestEnv) AutohandConfig { + t.Helper() + configPath := filepath.Join(env.RepoDir, ".autohand", autohandcode.AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read %s at %s: %v", autohandcode.AutohandConfigFileName, configPath, err) + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + return config +} From 9df293f01e5ac033a5bf129f3c2c0ec5df1d84af Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 3 Mar 2026 11:44:23 +1300 Subject: [PATCH 10/12] Update CLAUDE.md to include Autohand Code in agent list Add Autohand Code and Factory AI Droid to the agent implementations list in the Key Directories section. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a54637ee..4ee13d396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ This repo contains the CLI for Entire. - `entire/`: Main CLI entry point - `entire/cli`: CLI utilities and helpers - `entire/cli/commands`: actual command implementations -- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) +- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Autohand Code) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) - `entire/cli/strategy`: strategy implementation (manual-commit) - see section below - `entire/cli/checkpoint`: checkpoint storage abstractions (temporary and committed) - `entire/cli/session`: session state management From a12963a60e4b665cddb1e3bb2157c031bee7f0c4 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 4 Mar 2026 12:07:31 +1300 Subject: [PATCH 11/12] Update cmd/entire/cli/agent/autohandcode/hooks.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/agent/autohandcode/hooks.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/entire/cli/agent/autohandcode/hooks.go b/cmd/entire/cli/agent/autohandcode/hooks.go index 812f1f3db..5d25f99ab 100644 --- a/cmd/entire/cli/agent/autohandcode/hooks.go +++ b/cmd/entire/cli/agent/autohandcode/hooks.go @@ -133,8 +133,7 @@ func (a *AutohandCodeAgent) InstallHooks(ctx context.Context, localDev bool, for var rules []AutohandPermissionRule if rulesRaw, ok := rawPermissions["rules"]; ok { if err := json.Unmarshal(rulesRaw, &rules); err != nil { - // If parsing fails, start with empty rules - rules = nil + return 0, fmt.Errorf("failed to parse permissions.rules in config.json: %w", err) } } if !permissionRuleExists(rules, metadataDenyRule) { From 89108dcb6ef3f7571b58bf25b79760d9e02a47db Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Wed, 4 Mar 2026 12:08:07 +1300 Subject: [PATCH 12/12] Update cmd/entire/cli/agent/autohandcode/transcript.go Doesn't change the functionality. Just name convention. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/agent/autohandcode/transcript.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/agent/autohandcode/transcript.go b/cmd/entire/cli/agent/autohandcode/transcript.go index e983f1c26..1a00f1ce7 100644 --- a/cmd/entire/cli/agent/autohandcode/transcript.go +++ b/cmd/entire/cli/agent/autohandcode/transcript.go @@ -291,8 +291,8 @@ func ExtractAllModifiedFilesFromBytes(data []byte, startLine int, subagentsDir s return files, nil } -// ExtractSpawnedAgentIDs extracts agent IDs from subagent-stop data in the transcript. -// Returns a map of agentID -> toolUseID for all spawned agents. +// ExtractSpawnedAgentIDs scans assistant messages for Task/task tool calls and +// returns a map of spawned agent IDs, keyed and valued by the tool call ID. func ExtractSpawnedAgentIDs(transcriptLines []TranscriptLine) map[string]string { agentIDs := make(map[string]string)