363 lines
8.9 KiB
Go
363 lines
8.9 KiB
Go
package claude_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"g.fc.run/misc/byre/internal/claude"
|
|
"g.fc.run/misc/byre/internal/mcp"
|
|
)
|
|
|
|
const (
|
|
testModel = "sonnet"
|
|
gitName = "Byre Test"
|
|
gitEmail = "test@byre.invalid"
|
|
)
|
|
|
|
func TestCommitIdentityHooksAndMCP(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testContext(t)
|
|
repo := scratchRepo(t)
|
|
|
|
finished := 0
|
|
mu := sync.Mutex{}
|
|
finish := mcp.Tool{
|
|
Name: "finish",
|
|
Description: "Call this once the task is complete.",
|
|
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
|
|
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
finished++
|
|
return "noted", nil
|
|
},
|
|
}
|
|
|
|
tools := []string{}
|
|
s := start(t, ctx, claude.Options{
|
|
Cwd: repo,
|
|
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{finish}}},
|
|
Hooks: map[string][]claude.HookMatcher{
|
|
"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", then call the finish tool.`)
|
|
waitResult(t, ctx, s, nil)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
t.Logf("tools=%v finished=%d", tools, finished)
|
|
|
|
if finished == 0 {
|
|
t.Fatalf("the MCP finish tool was never called")
|
|
}
|
|
if len(tools) == 0 {
|
|
t.Fatalf("PostToolUse hook never fired")
|
|
}
|
|
if slices.Contains(tools, "ToolSearch") {
|
|
t.Fatalf("byre MCP tools were deferred behind ToolSearch")
|
|
}
|
|
|
|
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 TestTextDeltas(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testContext(t)
|
|
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
|
|
|
|
send(t, s, "Without using any tools, count from one to twenty in words, separated by spaces.")
|
|
deltas := []string{}
|
|
text := waitResult(t, ctx, s, func(ev claude.Event) {
|
|
if ev.Type != "stream_event" {
|
|
return
|
|
}
|
|
d, err := ev.TextDelta()
|
|
if err != nil {
|
|
t.Fatalf("parse stream event: %v", err)
|
|
}
|
|
if d != "" {
|
|
deltas = append(deltas, d)
|
|
}
|
|
})
|
|
|
|
if len(deltas) < 2 {
|
|
t.Fatalf("expected several text deltas, got %d", len(deltas))
|
|
}
|
|
if strings.Join(deltas, "") != text {
|
|
t.Fatalf("deltas %q don't add up to the final text %q", deltas, 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 `ping -c 120 127.0.0.1` in the foreground (not in the background) and wait for it to finish, then say done.")
|
|
|
|
interruptedAt := time.Time{}
|
|
waitResult(t, ctx, s, func(ev claude.Event) {
|
|
if ev.Type == "user" {
|
|
blocks, err := ev.Blocks()
|
|
if err != nil {
|
|
t.Fatalf("parse user: %v", err)
|
|
}
|
|
for _, b := range blocks {
|
|
if b.Type == "tool_result" && (!b.IsError || strings.Contains(string(b.Content), "Blocked")) {
|
|
t.Fatalf("command finished or was refused instead of being interrupted: %s", b.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !interruptedAt.IsZero() || !hasToolUse(t, ev) {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
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)
|
|
}
|