453 lines
12 KiB
Go
453 lines
12 KiB
Go
package claude_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"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 TestImageInput(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testContext(t)
|
|
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
|
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 255, A: 255}}, image.Point{}, draw.Src)
|
|
buf := bytes.Buffer{}
|
|
err := png.Encode(&buf, img)
|
|
if err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
|
|
err = s.Send("Without using any tools, what single colour fills the attached image? Reply with just the colour name.", claude.Image{MediaType: "image/png", Data: buf.Bytes()})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
text := waitResult(t, ctx, s, nil)
|
|
|
|
if !strings.Contains(strings.ToLower(text), "red") {
|
|
t.Fatalf("model didn't see the image; 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 TestForkAt(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testContext(t)
|
|
|
|
first, err := claude.Start(ctx, options(t, "first", claude.Options{Cwd: scratchRepo(t)}))
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
send(t, first, "Run the bash command `echo APPLE-1`. After it finishes, run the bash command `echo BANANA-2`. Then reply with the single word ok.")
|
|
cut := ""
|
|
waitResult(t, ctx, first, func(ev claude.Event) {
|
|
if cut != "" || ev.Type != "user" {
|
|
return
|
|
}
|
|
blocks, err := ev.Blocks()
|
|
if err != nil {
|
|
t.Fatalf("parse user: %v", err)
|
|
}
|
|
for _, b := range blocks {
|
|
if b.Type == "tool_result" && strings.Contains(string(b.Content), "APPLE-1") {
|
|
cut, err = ev.UUID()
|
|
if err != nil {
|
|
t.Fatalf("parse uuid: %v", err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
id := first.ID()
|
|
closeSession(t, first)
|
|
|
|
if cut == "" {
|
|
t.Fatalf("never saw the APPLE-1 tool result")
|
|
}
|
|
|
|
forkID := "8a1b2c3d-0000-4000-8000-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
|
|
fork, err := claude.Start(ctx, options(t, "fork", claude.Options{
|
|
Cwd: scratchRepo(t),
|
|
SessionID: forkID,
|
|
Resume: id,
|
|
ResumeAt: cut,
|
|
ForkSession: true,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("fork: %v", err)
|
|
}
|
|
t.Cleanup(func() { closeSession(t, fork) })
|
|
|
|
send(t, fork, "Without running anything, list the exact output of every bash command you have run in this conversation, one per line, and nothing else.")
|
|
text := waitResult(t, ctx, fork, nil)
|
|
|
|
if !strings.Contains(text, "APPLE-1") {
|
|
t.Fatalf("fork lost the conversation before the cut; assistant said %q", text)
|
|
}
|
|
if strings.Contains(text, "BANANA-2") {
|
|
t.Fatalf("fork kept the conversation after the cut; assistant said %q", text)
|
|
}
|
|
if fork.ID() != forkID {
|
|
t.Fatalf("fork has session id %s, want %s", fork.ID(), forkID)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|