package main import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "unicode/utf8" "github.com/stretchr/testify/require" ) func TestToolRunsAfterItsCallIsDurable(t *testing.T) { dir := t.TempDir() log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } box := newTestToolbox(t, log, dir) output, err := box.Call(t.Context(), "shell", `{"command":"touch ran && echo done"}`) require.NoError(t, err) require.Equal(t, "done\n", output) require.Equal(t, []string{ `tool: shell {"command":"touch ran && echo done"}`, "flush, marker absent", "echo: done\n", }, log.Events()) require.FileExists(t, filepath.Join(dir, "ran")) } func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) { dir := t.TempDir() log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } ctx, cancel := context.WithCancel(t.Context()) cancel() box := newTestToolbox(t, log, dir) output, err := box.Call(ctx, "shell", `{"command":"touch ran"}`) require.NoError(t, err) require.Equal(t, "not run: the turn was interrupted", output) require.NoFileExists(t, filepath.Join(dir, "ran")) require.Equal(t, "echo: not run: the turn was interrupted", log.Events()[2]) } func TestShellRunsInTheWorkingDirectory(t *testing.T) { dir := t.TempDir() box := newTestToolbox(t, &ToolRecordingLog{}, dir) testCases := []struct { name string command string output string }{ { name: "working directory", command: "pwd", output: dir + "\n", }, { name: "error output and exit status", command: "echo out; echo err >&2; exit 3", output: "out\nerr\n\n[exit status 3]", }, { name: "no output", command: "true", output: "(no output)", }, { name: "no input", command: "cat", output: "(no output)", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.output, box.run(t.Context(), "shell", fmt.Sprintf(`{"command":%q}`, tc.command))) }) } } func TestShellInterruptKillsTheCommandAndItsChildren(t *testing.T) { box := newTestToolbox(t, &ToolRecordingLog{}, t.TempDir()) ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) defer cancel() start := time.Now() output := box.shell(ctx, "echo started; sleep 30 & sleep 30") require.Less(t, time.Since(start), 10*time.Second) require.Equal(t, "started\n\n[interrupted]", output) } func TestFileTools(t *testing.T) { dir := t.TempDir() box := newTestToolbox(t, &ToolRecordingLog{}, dir) path := filepath.Join(dir, "notes", "a.txt") require.Equal(t, "wrote 12 bytes to "+path, box.run(t.Context(), "write", `{"path":"notes/a.txt","text":"one two two\n"}`)) require.Equal(t, "one two two\n", box.run(t.Context(), "read", fmt.Sprintf(`{"path":%q}`, path))) require.Equal(t, "error: old occurs 2 times in "+path+", not once", box.run(t.Context(), "edit", `{"path":"notes/a.txt","old":"two","new":"2"}`)) require.Equal(t, "edited "+path, box.run(t.Context(), "edit", `{"path":"notes/a.txt","old":"one","new":"1"}`)) require.Equal(t, "1 two two\n", box.run(t.Context(), "read", `{"path":"notes/a.txt"}`)) require.Contains(t, box.run(t.Context(), "read", `{"path":"missing.txt"}`), "error: open "+filepath.Join(dir, "missing.txt")) require.Equal(t, `error: arguments: json: unknown field "file"`, box.run(t.Context(), "read", `{"file":"a.txt"}`)) require.Equal(t, `error: unknown tool "grep"`, box.run(t.Context(), "grep", `{}`)) } func TestZoomAndDateReadTheChat(t *testing.T) { memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog()) require.NoError(t, err) defer func() { require.NoError(t, memory.Close()) }() for _, text := range []string{"zero", "one", "two", "three"} { _, err = memory.Append("user", text) require.NoError(t, err) } box := &Toolbox{ log: memory, chat: memory, subagents: newTestSubagents(t), workdir: t.TempDir(), } testCases := []struct { name string arguments string output string }{ { name: "one message whole", arguments: `{"id":2,"n":1}`, output: "user: two", }, { name: "a line opens into its two halves", arguments: `{"id":0,"n":4}`, output: "0+2|user: zero user: one\n2+2|user: two user: three", }, { name: "an id that is not a multiple of n", arguments: `{"id":1,"n":2}`, output: "error: line 1+2 does not exist: n must be a power of 2 and id a multiple of n", }, { name: "a line past the last message", arguments: `{"id":4,"n":4}`, output: "error: line 4+4 goes past the last message, 3", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.output, box.run(t.Context(), "zoom", tc.arguments)) }) } date := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC).Local().Format("Monday 2006-01-02 15:04:05 -07:00") require.Equal(t, date, box.run(t.Context(), "date", `{"id":3}`)) require.Equal(t, "error: message 4 does not exist", box.run(t.Context(), "date", `{"id":4}`)) } func TestZoomWithANameGivesTheWholeChatOfTheAgent(t *testing.T) { box := newTestToolbox(t, &ToolRecordingLog{}, t.TempDir()) subagent, err := box.subagents.Create(t.Context()) require.NoError(t, err) for _, message := range [][2]string{{"note", "subagent"}, {"user", "Count the files."}} { _, err = subagent.Append(message[0], message[1]) require.NoError(t, err) } output := box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"`+subagent.name+`"}`) require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", output) require.Equal(t, "error: no agent BlueFox", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`)) require.Equal(t, "error: line 0+1 goes past the last message, -1", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":null}`)) } func TestClipKeepsTheHeadAndTheTail(t *testing.T) { short := strings.Repeat("a", toolClipCharacters) require.Equal(t, short, toolClip(short)) text := strings.Repeat("h", toolClipCharacters/2) + strings.Repeat("é", 5000) + strings.Repeat("t", toolClipCharacters/2) require.Equal(t, strings.Repeat("h", toolClipCharacters/2)+"\n[... 10000 bytes cut ...]\n"+strings.Repeat("t", toolClipCharacters/2), toolClip(text)) half := strings.Repeat("é", toolClipCharacters/2) clipped := toolClip(strings.Repeat("é", toolClipCharacters+2)) require.True(t, utf8.ValidString(clipped)) require.Equal(t, half+"\n[... 4 bytes cut ...]\n"+half, clipped) } func TestHeadTailKeepsTheEndsOfALongOutput(t *testing.T) { output := &ToolHeadTail{} for n := range 1000 { _, err := fmt.Fprintf(output, "%04d%s\n", n, strings.Repeat("x", 995)) require.NoError(t, err) } text := output.String() require.True(t, strings.HasPrefix(text, "0000xxx")) require.True(t, strings.HasSuffix(text, "0999"+strings.Repeat("x", 995)+"\n")) require.Contains(t, text, fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-toolClipCharacters)) require.Len(t, text, toolClipCharacters+len(fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-toolClipCharacters))) } // ToolRecordingLog is a chat log that records each append and flush. The flush // records whether the marker file exists. type ToolRecordingLog struct { mutex sync.Mutex marker string events []string } func (r *ToolRecordingLog) Append(kind, text string) (Message, error) { r.mutex.Lock() defer r.mutex.Unlock() r.events = append(r.events, kind+": "+text) return Message{}, nil } func (r *ToolRecordingLog) Flush(ctx context.Context) error { r.mutex.Lock() defer r.mutex.Unlock() state := "absent" _, err := os.Stat(r.marker) if err == nil { state = "present" } r.events = append(r.events, "flush, marker "+state) return ctx.Err() } func (r *ToolRecordingLog) Events() []string { r.mutex.Lock() defer r.mutex.Unlock() return append([]string(nil), r.events...) } // newTestToolbox returns a toolbox with log, an empty chat, and the working // directory dir. func newTestToolbox(t *testing.T, log ToolLog, dir string) *Toolbox { t.Helper() memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog()) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, memory.Close()) }) return &Toolbox{ log: log, chat: memory, subagents: newTestSubagents(t), workdir: dir, } }