Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions cmd/floop/cmd_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,19 @@ func formatCorrectionCapturedMessage(correctionID string) string {
return fmt.Sprintf("### Correction Captured\nfloop auto-detected a correction from your message (id: %s)", correctionID)
}

// floopDirExists checks whether the .floop directory exists under root.
func floopDirExists(root string) bool {
_, err := os.Stat(filepath.Join(root, ".floop"))
return !os.IsNotExist(err)
}

// hookLog appends a structured JSON log entry to .floop/hook-debug.log.
// Silently no-ops if the .floop directory doesn't exist (pre-init state).
func hookLog(root, hookName, stage, outcome string, extra map[string]interface{}) {
floopDir := filepath.Join(root, ".floop")
if _, err := os.Stat(floopDir); os.IsNotExist(err) {
if !floopDirExists(root) {
return
}
floopDir := filepath.Join(root, ".floop")
logPath := filepath.Join(floopDir, "hook-debug.log")
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
Expand Down Expand Up @@ -274,8 +280,7 @@ func runDetectCorrection(cmd *cobra.Command, root, prompt string, client llm.Cli
right := sanitize.SanitizeBehaviorContent(result.Right)

// Ensure .floop exists
floopDir := filepath.Join(root, ".floop")
if _, err := os.Stat(floopDir); os.IsNotExist(err) {
if !floopDirExists(root) {
fmt.Fprintf(os.Stderr, "detect-correction: floop_dir_missing (root=%s)\n", root)
return nil
}
Expand Down Expand Up @@ -310,7 +315,7 @@ func runDetectCorrection(cmd *cobra.Command, root, prompt string, client llm.Cli
processedAt := time.Now()
correction.ProcessedAt = &processedAt

correctionsPath := filepath.Join(floopDir, "corrections.jsonl")
correctionsPath := filepath.Join(root, ".floop", "corrections.jsonl")
f, err := os.OpenFile(correctionsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err == nil {
json.NewEncoder(f).Encode(correction)
Expand Down Expand Up @@ -340,8 +345,7 @@ This applies to: explicit corrections, preferences, "don't do X", repeated feedb
// Used by session-start and first-prompt hooks.
func runHookPrompt(cmd *cobra.Command, root string) error {
// Check initialization silently
floopDir := filepath.Join(root, ".floop")
if _, err := os.Stat(floopDir); os.IsNotExist(err) {
if !floopDirExists(root) {
return nil
}

Expand Down Expand Up @@ -406,8 +410,7 @@ func runHookPrompt(cmd *cobra.Command, root string) error {
// for hook usage (always markdown, silent on errors).
func runHookActivate(cmd *cobra.Command, root, file, task string, tokenBudget int, sessionID string) error {
// Check initialization silently
floopDir := filepath.Join(root, ".floop")
if _, err := os.Stat(floopDir); os.IsNotExist(err) {
if !floopDirExists(root) {
return nil
}

Expand Down
111 changes: 52 additions & 59 deletions internal/llm/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,42 +147,36 @@ func (c *SubagentClient) detectAvailability() bool {
inSession := c.inCLISession()
if !inSession {
c.logger.Debug("subagent not available: no CLI session env vars")
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": false,
"reason": "no CLI session env vars",
})
}
c.logDecision(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": false,
"reason": "no CLI session env vars",
})
return false
}

// Find the CLI executable
cliPath := c.findCLI()
if cliPath == "" {
c.logger.Debug("subagent not available: CLI not found")
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": false,
"reason": "CLI executable not found",
})
}
c.logDecision(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": false,
"reason": "CLI executable not found",
})
return false
}

c.cliPath = cliPath
c.logger.Debug("subagent available", "cli_path", cliPath)
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": true,
"cli_path": cliPath,
})
}
c.logDecision(map[string]any{
"event": "llm_availability",
"provider": "subagent",
"available": true,
"cli_path": cliPath,
})
return true
}

Expand Down Expand Up @@ -295,14 +289,12 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string
start := time.Now()

c.logger.Debug("subagent request", "model", c.model, "prompt_len", len(prompt))
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_request",
"operation": "subagent",
"model": c.model,
"prompt_len": len(prompt),
})
}
c.logDecision(map[string]any{
"event": "llm_request",
"operation": "subagent",
"model": c.model,
"prompt_len": len(prompt),
})

// At trace level, log full prompt content
c.logger.Log(ctx, logging.LevelTrace, "subagent prompt content", "prompt", prompt)
Expand Down Expand Up @@ -334,15 +326,13 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string
if err := cmd.Run(); err != nil {
duration := time.Since(start)
c.logger.Debug("subagent failed", "duration_ms", duration.Milliseconds(), "error", err)
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"success": false,
"error": err.Error(),
})
}
c.logDecision(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"success": false,
"error": err.Error(),
})

if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("subagent timed out after %v", c.timeout)
Expand All @@ -355,28 +345,24 @@ func (c *SubagentClient) runSubagent(ctx context.Context, prompt string) (string

if response == "" {
c.logger.Debug("subagent empty response", "duration_ms", duration.Milliseconds())
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"success": false,
"error": "empty response",
})
}
c.logDecision(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"success": false,
"error": "empty response",
})
return "", fmt.Errorf("subagent returned empty response")
}

c.logger.Debug("subagent response", "duration_ms", duration.Milliseconds(), "response_len", len(response))
if c.decisions != nil {
c.decisions.Log(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"response_len": len(response),
"success": true,
})
}
c.logDecision(map[string]any{
"event": "llm_response",
"operation": "subagent",
"duration_ms": duration.Milliseconds(),
"response_len": len(response),
"success": true,
})

// At trace level, log full response content
c.logger.Log(ctx, logging.LevelTrace, "subagent response content", "response", response)
Expand All @@ -392,6 +378,13 @@ func (c *SubagentClient) ensureLogger() {
}
}

// logDecision logs a decision event if the decision logger is configured.
func (c *SubagentClient) logDecision(fields map[string]any) {
if c.decisions != nil {
c.decisions.Log(fields)
}
}

// DetectAndCreate attempts to create a SubagentClient if running in a CLI session.
// Returns nil if not in a CLI session or if detection fails.
func DetectAndCreate() Client {
Expand Down
77 changes: 77 additions & 0 deletions internal/llm/subagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,83 @@ func TestSubagentClient_DetectAvailability_LogsDecision(t *testing.T) {
}
}

func TestSubagentClient_DetectAvailability_CLINotFound_LogsDecision(t *testing.T) {
// Save and restore env
envVars := []string{"CLAUDE_CODE", "CLAUDE_SESSION_ID", "ANTHROPIC_CLI"}
saved := make(map[string]string)
for _, v := range envVars {
saved[v] = os.Getenv(v)
}
defer func() {
for k, v := range saved {
if v == "" {
os.Unsetenv(k)
} else {
os.Setenv(k, v)
}
}
}()

// Clear all CLI env vars, then set one so inCLISession() returns true
for _, v := range envVars {
os.Unsetenv(v)
}
os.Setenv("CLAUDE_CODE", "1")

dir := t.TempDir()
dl := logging.NewDecisionLogger(dir, "debug")
defer dl.Close()

var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))

// Use AllowedCLIDirs pointing to an empty directory so findCLI() returns ""
emptyDir := t.TempDir()
client := NewSubagentClient(SubagentConfig{
Logger: logger,
DecisionLogger: dl,
AllowedCLIDirs: []string{emptyDir},
})

// Trigger availability detection — should hit "CLI not found" path
result := client.Available()
if result {
t.Error("expected Available() = false when no CLI in allowed dirs")
}

// Check decision log
dl.Close()
data, err := os.ReadFile(filepath.Join(dir, "decisions.jsonl"))
if err != nil {
t.Fatalf("failed to read decisions.jsonl: %v", err)
}

lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 {
t.Fatal("expected at least 1 decision entry, got 0")
}

var entry map[string]any
if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil {
t.Fatalf("failed to parse decision entry: %v", err)
}

if entry["event"] != "llm_availability" {
t.Errorf("event = %v, want llm_availability", entry["event"])
}
if entry["available"] != false {
t.Errorf("available = %v, want false", entry["available"])
}
if entry["reason"] != "CLI executable not found" {
t.Errorf("reason = %v, want 'CLI executable not found'", entry["reason"])
}

// Check debug log
if !strings.Contains(buf.String(), "CLI not found") {
t.Errorf("expected debug log about CLI not found, got: %q", buf.String())
}
}

func TestSubagentClient_RunSubagent_LogsDecision(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses Unix shell scripts as mock CLIs")
Expand Down
Loading