X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/f2d227db9c3b556c7b0e1cfe735ccc54311850e5..e86df5fad9aa2f280aad845adeefd239b0e944ae:/tools_test.go diff --git a/tools_test.go b/tools_test.go index a5f8f18..c03e7fc 100644 --- a/tools_test.go +++ b/tools_test.go @@ -16,7 +16,7 @@ import ( func TestToolRunsAfterItsCallIsDurable(t *testing.T) { dir := t.TempDir() - log := &recordingLog{ + log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } @@ -29,12 +29,13 @@ func TestToolRunsAfterItsCallIsDurable(t *testing.T) { "flush, marker absent", "echo: done\n", }, log.Events()) + require.FileExists(t, filepath.Join(dir, "ran")) } func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) { dir := t.TempDir() - log := &recordingLog{ + log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } @@ -51,29 +52,29 @@ func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) { func TestShellRunsInTheWorkingDirectory(t *testing.T) { dir := t.TempDir() - box := newTestToolbox(t, &recordingLog{}, dir) + box := newTestToolbox(t, &ToolRecordingLog{}, dir) testCases := []struct { name string command string output string }{ { - name: "working directory", + name: "runs in the configured working directory", command: "pwd", output: dir + "\n", }, { - name: "error output and exit status", + name: "returns both streams and the failed exit status", command: "echo out; echo err >&2; exit 3", output: "out\nerr\n\n[exit status 3]", }, { - name: "no output", + name: "marks an empty successful output", command: "true", output: "(no output)", }, { - name: "no input", + name: "closes standard input without user input", command: "cat", output: "(no output)", }, @@ -87,19 +88,20 @@ func TestShellRunsInTheWorkingDirectory(t *testing.T) { } func TestShellInterruptKillsTheCommandAndItsChildren(t *testing.T) { - box := newTestToolbox(t, &recordingLog{}, t.TempDir()) + 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, &recordingLog{}, dir) + 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"}`)) @@ -107,13 +109,13 @@ func TestFileTools(t *testing.T) { 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.Contains(t, box.run(t.Context(), "read", `{"path":"missing.txt"}`), "error: open tool file: 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()) + memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer(), discardLog()) require.NoError(t, err) defer func() { @@ -125,10 +127,11 @@ func TestZoomAndDateReadTheChat(t *testing.T) { require.NoError(t, err) } - box := &toolbox{ - log: memory, - chat: memory, - workdir: t.TempDir(), + box := &Toolbox{ + log: memory, + chat: memory, + subagents: newTestSubagents(t), + workdir: t.TempDir(), } testCases := []struct { @@ -137,7 +140,7 @@ func TestZoomAndDateReadTheChat(t *testing.T) { output string }{ { - name: "one message whole", + name: "returns one message without shortening it", arguments: `{"id":2,"n":1}`, output: "user: two", }, @@ -147,12 +150,12 @@ func TestZoomAndDateReadTheChat(t *testing.T) { output: "0+2|user: zero user: one\n2+2|user: two user: three", }, { - name: "an id that is not a multiple of n", + name: "rejects an unaligned line ID", 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", + name: "rejects a line beyond the chat", arguments: `{"id":4,"n":4}`, output: "error: line 4+4 goes past the last message, 3", }, @@ -169,21 +172,38 @@ func TestZoomAndDateReadTheChat(t *testing.T) { 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: open worker log: 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", clipCharacters) - require.Equal(t, short, clip(short)) + short := strings.Repeat("a", toolClipCharacters) + require.Equal(t, short, toolClip(short)) - text := strings.Repeat("h", clipCharacters/2) + strings.Repeat("é", 5000) + strings.Repeat("t", clipCharacters/2) - require.Equal(t, strings.Repeat("h", clipCharacters/2)+"\n[... 10000 bytes cut ...]\n"+strings.Repeat("t", clipCharacters/2), clip(text)) + 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("é", clipCharacters/2) - clipped := clip(strings.Repeat("é", clipCharacters+2)) + 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 := &headTail{} + output := &ToolHeadTail{} for n := range 1000 { _, err := fmt.Fprintf(output, "%04d%s\n", n, strings.Repeat("x", 995)) require.NoError(t, err) @@ -192,19 +212,46 @@ func TestHeadTailKeepsTheEndsOfALongOutput(t *testing.T) { 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-clipCharacters)) - require.Equal(t, clipCharacters+len(fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-clipCharacters)), len(text)) + 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))) } -// recordingLog is a chat log that records each append and flush. The flush +// ToolRecordingLog is a chat log that records each append and flush. The flush // records whether the marker file exists. -type recordingLog struct { +type ToolRecordingLog struct { mutex sync.Mutex marker string events []string } -func (r *recordingLog) Append(kind, text string) (Message, error) { +// 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, + } +} + +func (r *ToolRecordingLog) Events() []string { + r.mutex.Lock() + defer r.mutex.Unlock() + + return append([]string(nil), r.events...) +} + +func (r *ToolRecordingLog) Append(kind, text string) (Message, error) { r.mutex.Lock() defer r.mutex.Unlock() @@ -213,7 +260,7 @@ func (r *recordingLog) Append(kind, text string) (Message, error) { return Message{}, nil } -func (r *recordingLog) Flush(ctx context.Context) error { +func (r *ToolRecordingLog) Flush(ctx context.Context) error { r.mutex.Lock() defer r.mutex.Unlock() @@ -227,29 +274,3 @@ func (r *recordingLog) Flush(ctx context.Context) error { return ctx.Err() } - -func (r *recordingLog) 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 chatLog, dir string) *toolbox { - t.Helper() - - memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer()) - require.NoError(t, err) - - t.Cleanup(func() { - require.NoError(t, memory.Close()) - }) - - return &toolbox{ - log: log, - chat: memory, - workdir: dir, - } -}