]> git.giacomolli.uk - onechat/commitdiff
Run the tools of an agent and log each call
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 22:12:48 +0000 (19:12 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Fri, 6 Nov 2026 22:12:48 +0000 (19:12 -0300)
The agent gets four more tools after zoom and date: shell, read, write,
and edit. Turns and compactions send all six, so they keep one cached
prefix. shell runs a command with bash as the user in the working
directory of the agent, with no input and no sandbox, in its own process
group, so an interrupt kills the command and its children. read, write,
and edit take a path relative to the working directory or an absolute
one, and edit replaces a text that occurs exactly once.

Each call goes to the log as a tool message, the log is flushed, and
only then does the tool run, so the log never misses an action outside
the agent. The result goes to the log as an echo message. An interrupt
before the flush logs that the tool did not run. A tool error is the
output, so the model sees it.

Each output keeps its first and last 15,000 characters, 30,000 in all,
with a mark of the cut bytes between them. shell and read keep only the
ends while they read, so a long output does not fill the memory.

Issue: 5daf23e

memory.go
prompt.go
prompt_test.go
tools.go [new file with mode: 0644]
tools_test.go [new file with mode: 0644]

index ca8277c629889037c17c4ef221bcf94c049f0e8c..792fe21f42b2e69dbb85b1e114320124d5f10fa0 100644 (file)
--- a/memory.go
+++ b/memory.go
@@ -543,6 +543,30 @@ func (m *Memory) View() string {
        return m.chat.Render()
 }
 
+// Len returns the number of messages in the log.
+func (m *Memory) Len() int {
+       m.mutex.Lock()
+       defer m.mutex.Unlock()
+
+       return m.log.Len()
+}
+
+// Message reads the message with the given ID.
+func (m *Memory) Message(id int) (Message, error) {
+       m.mutex.Lock()
+       defer m.mutex.Unlock()
+
+       return m.log.Message(id)
+}
+
+// Node reads the node (l, i) if it is built.
+func (m *Memory) Node(level, index int) (Node, bool, error) {
+       m.mutex.Lock()
+       defer m.mutex.Unlock()
+
+       return m.tree.Node(level, index)
+}
+
 // Context returns the compaction view for the build of node (l, i). It ends
 // before the message of a leaf, or after the last message of a merge, and it
 // stops at the first line that is not built.
index e48c5a61b714350b61dc3bcca68b025c40b05383..b536a36d9658722969d477bd936ad176f1e69b2c 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -123,9 +123,9 @@ func prompt(name, instructions string) string {
        return text + instructionsHeading + instructions
 }
 
-// agentTools returns the function tools of every call: zoom and date. A
-// compaction sends them too and never calls them, because a change of the
-// tools ends the shared prompt cache.
+// agentTools returns the function tools of every call: zoom, date, shell,
+// read, write, and edit. A compaction sends them too and never calls them,
+// because a change of the tools ends the shared prompt cache.
 func agentTools() []Tool {
        return []Tool{
                {
@@ -147,6 +147,45 @@ func agentTools() []Tool {
                        }),
                        Strict: true,
                },
+               {
+                       Type:        "function",
+                       Name:        "shell",
+                       Description: "Run a command with bash, as the user, in the working directory, with no input. Gives its output and error output, and its exit status when it fails.",
+                       Parameters: objectSchema(map[string]any{
+                               "command": stringSchema("The bash command."),
+                       }),
+                       Strict: true,
+               },
+               {
+                       Type:        "function",
+                       Name:        "read",
+                       Description: "Read a text file.",
+                       Parameters: objectSchema(map[string]any{
+                               "path": stringSchema("The file, absolute or relative to the working directory."),
+                       }),
+                       Strict: true,
+               },
+               {
+                       Type:        "function",
+                       Name:        "write",
+                       Description: "Write a text file whole, replacing it when it exists. Makes the missing directories.",
+                       Parameters: objectSchema(map[string]any{
+                               "path": stringSchema("The file, absolute or relative to the working directory."),
+                               "text": stringSchema("The whole new content."),
+                       }),
+                       Strict: true,
+               },
+               {
+                       Type:        "function",
+                       Name:        "edit",
+                       Description: "Replace the one occurrence of old in a text file with new.",
+                       Parameters: objectSchema(map[string]any{
+                               "path": stringSchema("The file, absolute or relative to the working directory."),
+                               "old":  stringSchema("The exact text to replace. It must occur once in the file."),
+                               "new":  stringSchema("The text that replaces old."),
+                       }),
+                       Strict: true,
+               },
        }
 }
 
@@ -175,3 +214,11 @@ func integerSchema(description string) map[string]any {
                "description": description,
        }
 }
+
+// stringSchema returns the JSON schema of a string.
+func stringSchema(description string) map[string]any {
+       return map[string]any{
+               "type":        "string",
+               "description": description,
+       }
+}
index 4c88ad8a80d2683573ccc877523488691f6de5ac..c9d3691ba038ab6830a9de6925c497663a22dd76 100644 (file)
@@ -48,7 +48,12 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) {
 
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := agentTools()
-       require.Equal(t, []string{"zoom", "date"}, []string{tools[0].Name, tools[1].Name})
+       names := make([]string, len(tools))
+       for n, tool := range tools {
+               names[n] = tool.Name
+       }
+
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, names)
 
        for _, tool := range tools {
                schema := tool.Parameters.(map[string]any)
diff --git a/tools.go b/tools.go
new file mode 100644 (file)
index 0000000..73c584a
--- /dev/null
+++ b/tools.go
@@ -0,0 +1,451 @@
+package main
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "math/bits"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "syscall"
+       "time"
+       "unicode/utf8"
+)
+
+const (
+       // clipCharacters is the most characters of a tool output: its head and
+       // its tail, half each.
+       clipCharacters = 30000
+
+       // clipBytes is the most bytes that a tool keeps of each end of an
+       // output, enough for clipCharacters/2 characters of 4 bytes.
+       clipBytes = 4 * clipCharacters / 2
+
+       // shellWaitDelay is how long a command can keep its output open after it
+       // ends or after its kill.
+       shellWaitDelay = 2 * time.Second
+)
+
+// chatLog is the log that the tools write their calls and results to.
+type chatLog interface {
+       Append(kind, text string) (Message, error)
+       Flush(ctx context.Context) error
+}
+
+// chatReader gives the messages and the nodes that zoom and date read.
+type chatReader interface {
+       Len() int
+       Message(id int) (Message, error)
+       Node(level, index int) (Node, bool, error)
+}
+
+// toolbox runs the tool calls of one agent. Each call and each result goes to
+// the log as it happens: the call as a tool message, the result as an echo
+// message. The call is durable before its tool runs, so the log never misses
+// an action outside the agent. Commands and files run as the user in the
+// working directory, with no sandbox.
+type toolbox struct {
+       log     chatLog
+       chat    chatReader
+       workdir string
+}
+
+// shellArguments are the arguments of shell.
+type shellArguments struct {
+       Command string `json:"command"`
+}
+
+// pathArguments are the arguments of read.
+type pathArguments struct {
+       Path string `json:"path"`
+}
+
+// writeArguments are the arguments of write.
+type writeArguments struct {
+       Path string `json:"path"`
+       Text string `json:"text"`
+}
+
+// editArguments are the arguments of edit.
+type editArguments struct {
+       Path string `json:"path"`
+       Old  string `json:"old"`
+       New  string `json:"new"`
+}
+
+// zoomArguments are the arguments of zoom.
+type zoomArguments struct {
+       ID int `json:"id"`
+       N  int `json:"n"`
+}
+
+// dateArguments are the arguments of date.
+type dateArguments struct {
+       ID int `json:"id"`
+}
+
+// Call logs the call of the tool name with its JSON arguments, makes the log
+// durable, runs the tool, and logs its clipped output, which it returns. A
+// failed tool gives its error as the output. An error of Call itself is an
+// error of the log. When ctx ends before the flush, the tool does not run.
+func (b *toolbox) Call(ctx context.Context, name, arguments string) (string, error) {
+       _, err := b.log.Append("tool", callText(name, arguments))
+       if err != nil {
+               return "", err
+       }
+
+       err = b.log.Flush(ctx)
+       if err != nil && ctx.Err() == nil {
+               return "", err
+       }
+
+       output := "not run: the turn was interrupted"
+       if err == nil {
+               output = clip(b.run(ctx, name, arguments))
+       }
+
+       _, err = b.log.Append("echo", output)
+       if err != nil {
+               return "", err
+       }
+
+       return output, nil
+}
+
+// run runs one tool and returns its output, or its error as text.
+func (b *toolbox) run(ctx context.Context, name, arguments string) string {
+       var output string
+       var err error
+       switch name {
+       case "zoom":
+               var args zoomArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output, err = b.zoom(args.ID, args.N)
+               }
+       case "date":
+               var args dateArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output, err = b.date(args.ID)
+               }
+       case "shell":
+               var args shellArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output = b.shell(ctx, args.Command)
+               }
+       case "read":
+               var args pathArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output, err = b.read(args.Path)
+               }
+       case "write":
+               var args writeArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output, err = b.write(args.Path, args.Text)
+               }
+       case "edit":
+               var args editArguments
+               err = decodeArguments(arguments, &args)
+               if err == nil {
+                       output, err = b.edit(args.Path, args.Old, args.New)
+               }
+       default:
+               err = fmt.Errorf("unknown tool %q", name)
+       }
+
+       if err != nil {
+               return "error: " + err.Error()
+       }
+
+       return output
+}
+
+// zoom returns message id whole for n = 1, or else the two lines under the line
+// id+n.
+func (b *toolbox) zoom(id, n int) (string, error) {
+       if n < 1 || bits.OnesCount(uint(n)) != 1 || id < 0 || id%n != 0 {
+               return "", fmt.Errorf("line %d+%d does not exist: n must be a power of 2 and id a multiple of n", id, n)
+       }
+
+       if id+n > b.chat.Len() {
+               return "", fmt.Errorf("line %d+%d goes past the last message, %d", id, n, b.chat.Len()-1)
+       }
+
+       if n == 1 {
+               message, err := b.chat.Message(id)
+               if err != nil {
+                       return "", err
+               }
+
+               return leafSource(message), nil
+       }
+
+       level := bits.TrailingZeros(uint(n)) - 1
+       var lines []string
+       for _, index := range []int{2 * id / n, 2*id/n + 1} {
+               node, built, err := b.chat.Node(level, index)
+               if err != nil {
+                       return "", err
+               }
+
+               text := pending
+               if built {
+                       text = strings.ReplaceAll(node.Text, "\n", " ")
+               }
+
+               lines = append(lines, fmt.Sprintf("%d+%d|%s", index<<level, 1<<level, text))
+       }
+
+       return strings.Join(lines, "\n"), nil
+}
+
+// date returns the date and time of message id, with its weekday.
+func (b *toolbox) date(id int) (string, error) {
+       if id < 0 || id >= b.chat.Len() {
+               return "", fmt.Errorf("message %d does not exist", id)
+       }
+
+       message, err := b.chat.Message(id)
+       if err != nil {
+               return "", err
+       }
+
+       date, err := time.Parse(time.RFC3339Nano, message.Date)
+       if err != nil {
+               return "", err
+       }
+
+       return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
+}
+
+// shell runs command with bash in the working directory and returns its output
+// and error output. A failed command adds its exit status. When ctx ends, the
+// whole process group of the command is killed.
+func (b *toolbox) shell(ctx context.Context, command string) string {
+       cmd := exec.CommandContext(ctx, "bash", "-c", command)
+       cmd.Dir = b.workdir
+       cmd.SysProcAttr = &syscall.SysProcAttr{
+               Setpgid: true,
+       }
+       cmd.Cancel = func() error {
+               return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+       }
+       cmd.WaitDelay = shellWaitDelay
+
+       output := &headTail{}
+       cmd.Stdout = output
+       cmd.Stderr = output
+       err := cmd.Run()
+
+       text := output.String()
+       var exit *exec.ExitError
+       switch {
+       case ctx.Err() != nil:
+               text += "\n[interrupted]"
+       case errors.As(err, &exit):
+               text += "\n[" + exit.String() + "]"
+       case err != nil:
+               text += "\n[error: " + err.Error() + "]"
+       case text == "":
+               text = "(no output)"
+       }
+
+       return text
+}
+
+// read returns the text of the file at path.
+func (b *toolbox) read(path string) (text string, err error) {
+       file, err := os.Open(b.path(path))
+       if err != nil {
+               return "", err
+       }
+
+       defer func() {
+               err = errors.Join(err, file.Close())
+       }()
+
+       output := &headTail{}
+       _, err = io.Copy(output, file)
+       if err != nil {
+               return "", err
+       }
+
+       return output.String(), nil
+}
+
+// write replaces the file at path with text, and makes its missing
+// directories.
+func (b *toolbox) write(path, text string) (string, error) {
+       path = b.path(path)
+       err := os.MkdirAll(filepath.Dir(path), 0o755)
+       if err != nil {
+               return "", err
+       }
+
+       err = os.WriteFile(path, []byte(text), 0o644)
+       if err != nil {
+               return "", err
+       }
+
+       return fmt.Sprintf("wrote %d bytes to %s", len(text), path), nil
+}
+
+// edit replaces the one occurrence of old in the file at path with
+// replacement. It keeps the mode of the file.
+func (b *toolbox) edit(path, old, replacement string) (string, error) {
+       path = b.path(path)
+       data, err := os.ReadFile(path)
+       if err != nil {
+               return "", err
+       }
+
+       if old == "" {
+               return "", errors.New("old is empty")
+       }
+
+       count := strings.Count(string(data), old)
+       if count != 1 {
+               return "", fmt.Errorf("old occurs %d times in %s, not once", count, path)
+       }
+
+       info, err := os.Stat(path)
+       if err != nil {
+               return "", err
+       }
+
+       err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
+       if err != nil {
+               return "", err
+       }
+
+       return "edited " + path, nil
+}
+
+// path returns path, relative to the working directory when it is not
+// absolute.
+func (b *toolbox) path(path string) string {
+       if filepath.IsAbs(path) {
+               return path
+       }
+
+       return filepath.Join(b.workdir, path)
+}
+
+// headTail keeps the first and the last clipBytes bytes written to it, and
+// counts the bytes between them that it drops.
+type headTail struct {
+       head    []byte
+       tail    []byte
+       dropped int
+}
+
+// Write keeps the bytes of p at the ends of the output.
+func (h *headTail) Write(p []byte) (int, error) {
+       n := len(p)
+       room := min(clipBytes-len(h.head), len(p))
+       h.head = append(h.head, p[:room]...)
+       p = p[room:]
+
+       h.tail = append(h.tail, p...)
+       if len(h.tail) > clipBytes {
+               cut := len(h.tail) - clipBytes
+               h.dropped += cut
+               h.tail = bytes.Clone(h.tail[cut:])
+       }
+
+       return n, nil
+}
+
+// String returns the output, clipped when it is too long. Invalid UTF-8 becomes
+// the replacement character.
+func (h *headTail) String() string {
+       replacement := string(utf8.RuneError)
+       if h.dropped == 0 {
+               return clip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
+       }
+
+       head := firstCharacters(strings.ToValidUTF8(string(h.head), replacement), clipCharacters/2)
+       tail := lastCharacters(strings.ToValidUTF8(string(h.tail), replacement), clipCharacters/2)
+       cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
+
+       return joinClipped(head, tail, cut)
+}
+
+// callText returns the log text of a tool call: the name and the JSON
+// arguments.
+func callText(name, arguments string) string {
+       return name + " " + arguments
+}
+
+// decodeArguments decodes the JSON arguments of a call into args. An unknown
+// field is an error.
+func decodeArguments(arguments string, args any) error {
+       decoder := json.NewDecoder(strings.NewReader(arguments))
+       decoder.DisallowUnknownFields()
+       err := decoder.Decode(args)
+       if err != nil {
+               return fmt.Errorf("arguments: %w", err)
+       }
+
+       return nil
+}
+
+// clip returns text when it has at most clipCharacters characters. Otherwise it
+// returns the first and the last clipCharacters/2 characters, with a mark of
+// the cut between them.
+func clip(text string) string {
+       if utf8.RuneCountInString(text) <= clipCharacters {
+               return text
+       }
+
+       head := firstCharacters(text, clipCharacters/2)
+       tail := lastCharacters(text, clipCharacters/2)
+
+       return joinClipped(head, tail, len(text)-len(head)-len(tail))
+}
+
+// joinClipped joins the head and the tail of a clipped output with a mark of
+// the cut bytes.
+func joinClipped(head, tail string, cut int) string {
+       return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail
+}
+
+// firstCharacters returns the first count characters of text.
+func firstCharacters(text string, count int) string {
+       end := 0
+       for range count {
+               if end >= len(text) {
+                       break
+               }
+
+               _, size := utf8.DecodeRuneInString(text[end:])
+               end += size
+       }
+
+       return text[:end]
+}
+
+// lastCharacters returns the last count characters of text.
+func lastCharacters(text string, count int) string {
+       start := len(text)
+       for range count {
+               if start <= 0 {
+                       break
+               }
+
+               _, size := utf8.DecodeLastRuneInString(text[:start])
+               start -= size
+       }
+
+       return text[start:]
+}
diff --git a/tools_test.go b/tools_test.go
new file mode 100644 (file)
index 0000000..a5f8f18
--- /dev/null
@@ -0,0 +1,255 @@
+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 := &recordingLog{
+               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 := &recordingLog{
+               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, &recordingLog{}, 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, &recordingLog{}, 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)
+       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())
+       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,
+               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 TestClipKeepsTheHeadAndTheTail(t *testing.T) {
+       short := strings.Repeat("a", clipCharacters)
+       require.Equal(t, short, clip(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))
+
+       half := strings.Repeat("é", clipCharacters/2)
+       clipped := clip(strings.Repeat("é", clipCharacters+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{}
+       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-clipCharacters))
+       require.Equal(t, clipCharacters+len(fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-clipCharacters)), len(text))
+}
+
+// recordingLog is a chat log that records each append and flush. The flush
+// records whether the marker file exists.
+type recordingLog struct {
+       mutex  sync.Mutex
+       marker string
+       events []string
+}
+
+func (r *recordingLog) 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 *recordingLog) 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 *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,
+       }
+}