package claude_test import ( "context" "encoding/json" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" "g.fc.run/misc/byre/internal/claude" "g.fc.run/misc/byre/internal/mcp" "g.fc.run/misc/byre/internal/report" ) const ( testModel = "sonnet" gitName = "Byre Test" gitEmail = "test@byre.invalid" ) func TestCommitIdentityHooksAndReport(t *testing.T) { t.Parallel() ctx := testContext(t) repo := scratchRepo(t) reports := []report.Report{} mu := sync.Mutex{} gate := &report.Gate{OnReport: func(r report.Report) { mu.Lock() defer mu.Unlock() reports = append(reports, r) }} tools := []string{} s := start(t, ctx, claude.Options{ Cwd: repo, MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}}, Hooks: map[string][]claude.HookMatcher{ "Stop": {{Func: gate.StopHook}}, "PostToolUse": {{Func: func(ctx context.Context, input json.RawMessage) (map[string]any, error) { in := struct { ToolName string `json:"tool_name"` }{} err := json.Unmarshal(input, &in) if err != nil { return nil, err } mu.Lock() defer mu.Unlock() tools = append(tools, in.ToolName) return map[string]any{}, nil }}}, }, }) send(t, s, `Create a file hello.txt containing exactly the word hi, then git add and git commit it with the message "add hello".`) waitResult(t, ctx, s, nil) mu.Lock() defer mu.Unlock() t.Logf("tools=%v reports=%d nudges=%d", tools, len(reports), gate.Nudges()) if len(reports) == 0 { t.Fatalf("report was never called") } if len(tools) == 0 { t.Fatalf("PostToolUse hook never fired") } log := git(t, repo, "log", "-1", "--format=%an <%ae>%n%cn <%ce>%n%B") want := gitName + " <" + gitEmail + ">\n" + gitName + " <" + gitEmail + ">\nadd hello" if !strings.HasPrefix(log, want) { t.Fatalf("unexpected commit:\n%s", log) } lower := strings.ToLower(log) if strings.Contains(lower, "co-authored-by") || strings.Contains(lower, "claude") { t.Fatalf("agent attribution in commit:\n%s", log) } } func TestStopHookBlocks(t *testing.T) { t.Parallel() ctx := testContext(t) blocked := false mu := sync.Mutex{} s := start(t, ctx, claude.Options{ Cwd: scratchRepo(t), Hooks: map[string][]claude.HookMatcher{ "Stop": {{Func: func(ctx context.Context, input json.RawMessage) (map[string]any, error) { mu.Lock() defer mu.Unlock() if blocked { return map[string]any{}, nil } blocked = true return map[string]any{"decision": "block", "reason": "Before stopping, reply with the single word PINEAPPLE."}, nil }}}, }, }) send(t, s, "Reply with the single word ok.") text := waitResult(t, ctx, s, nil) if !blocked { t.Fatalf("Stop hook never fired") } if !strings.Contains(text, "PINEAPPLE") { t.Fatalf("Stop hook block reason not acted on; assistant said %q", text) } } func TestInterrupt(t *testing.T) { t.Parallel() ctx := testContext(t) s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)}) send(t, s, "Run the bash command `sleep 120` in the foreground (not in the background), then say done.") interruptedAt := time.Time{} waitResult(t, ctx, s, func(ev claude.Event) { if !interruptedAt.IsZero() || !hasToolUse(t, ev) { return } interruptedAt = time.Now() err := s.Interrupt(ctx) if err != nil { t.Errorf("interrupt: %v", err) } }) if interruptedAt.IsZero() { t.Fatalf("never saw a tool use to interrupt") } elapsed := time.Since(interruptedAt) t.Logf("turn ended %s after interrupt", elapsed) if elapsed > 30*time.Second { t.Fatalf("interrupt took %s to end the turn", elapsed) } send(t, s, "Reply with the single word ok.") text := waitResult(t, ctx, s, nil) if !strings.Contains(strings.ToLower(text), "ok") { t.Fatalf("session unusable after interrupt; assistant said %q", text) } } func TestResume(t *testing.T) { t.Parallel() ctx := testContext(t) repo := scratchRepo(t) first, err := claude.Start(ctx, options(t, "first", claude.Options{Cwd: repo})) if err != nil { t.Fatalf("start: %v", err) } send(t, first, "Remember the codeword AZURE-17. Reply with the single word ok.") waitResult(t, ctx, first, nil) id := first.ID() closeSession(t, first) if id == "" { t.Fatalf("no session id from first session") } resumed, err := claude.Start(ctx, options(t, "resumed", claude.Options{Cwd: repo, Resume: id})) if err != nil { t.Fatalf("resume: %v", err) } t.Cleanup(func() { closeSession(t, resumed) }) send(t, resumed, "What was the codeword? Reply with just the codeword.") text := waitResult(t, ctx, resumed, nil) if !strings.Contains(text, "AZURE-17") { t.Fatalf("resumed session lost context; assistant said %q", text) } if resumed.ID() != id { t.Fatalf("resume changed session id: want %s, got %s", id, resumed.ID()) } } func testContext(t *testing.T) context.Context { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) return ctx } func scratchRepo(t *testing.T) string { dir := t.TempDir() git(t, dir, "init", "--initial-branch=main") git(t, dir, "commit", "--allow-empty", "--message", "root") return dir } func options(t *testing.T, suffix string, opts claude.Options) claude.Options { name := t.Name() if suffix != "" { name += "-" + suffix } err := os.MkdirAll(filepath.Join("testdata", "transcripts"), 0o755) if err != nil { t.Fatalf("mkdir transcripts: %v", err) } f, err := os.Create(filepath.Join("testdata", "transcripts", name+".jsonl")) if err != nil { t.Fatalf("create transcript: %v", err) } t.Cleanup(func() { f.Close() }) opts.Model = testModel opts.GitName = gitName opts.GitEmail = gitEmail opts.Transcript = f return opts } func start(t *testing.T, ctx context.Context, opts claude.Options) *claude.Session { s, err := claude.Start(ctx, options(t, "", opts)) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { closeSession(t, s) }) return s } func closeSession(t *testing.T, s *claude.Session) { err := s.Close() if err != nil { t.Logf("claude exit: %v", err) } } func send(t *testing.T, s *claude.Session, text string) { err := s.Send(text) if err != nil { t.Fatalf("send: %v", err) } } func waitResult(t *testing.T, ctx context.Context, s *claude.Session, onEvent func(claude.Event)) string { text := []string{} for { select { case ev, ok := <-s.Events(): if !ok { t.Fatalf("claude exited before result") } if onEvent != nil { onEvent(ev) } if ev.Type == "assistant" { blocks, err := ev.Blocks() if err != nil { t.Fatalf("parse assistant: %v", err) } for _, b := range blocks { if b.Type == "text" { text = append(text, b.Text) } } } if ev.Type == "result" { res, err := ev.Result() if err != nil { t.Fatalf("parse result: %v", err) } t.Logf("result %s: %d turns, %dms, $%.4f", res.Subtype, res.NumTurns, res.DurationMS, res.TotalCostUSD) return strings.Join(text, "\n") } case <-ctx.Done(): t.Fatalf("timed out waiting for result") } } } func hasToolUse(t *testing.T, ev claude.Event) bool { if ev.Type != "assistant" { return false } blocks, err := ev.Blocks() if err != nil { t.Fatalf("parse assistant: %v", err) } for _, b := range blocks { if b.Type == "tool_use" { return true } } return false } func git(t *testing.T, dir string, args ...string) string { cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME="+gitName, "GIT_AUTHOR_EMAIL="+gitEmail, "GIT_COMMITTER_NAME="+gitName, "GIT_COMMITTER_EMAIL="+gitEmail, ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return string(out) }