]> git.giacomolli.uk - onechat/blobdiff - daemon_test.go
Start a turn when a schedule comes due
[onechat] / daemon_test.go
index fe409293d85f50638eeb7cc017942e0e19488e01..8bc8c6e9c3f290a5af3579556e55792ccc305360 100644 (file)
 package main
 
 import (
-       "bytes"
-       "context"
        "io"
-       "net"
        "net/http"
        "os"
        "path/filepath"
        "strings"
-       "sync"
        "testing"
-       "time"
 
        "github.com/stretchr/testify/require"
 )
 
 func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
-       server := newFakeResponses(t)
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
                if call.number == 0 {
-                       writeEvents(w, completedEvent([]any{functionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5))
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5))
                        return
                }
 
-               writeEvents(w, completedEvent([]any{messageItem("It said hi.")}, 100, 0, 5))
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 100, 0, 5))
        }
 
-       socket := startTestDaemon(t, server)
-       first := startTestClient(t, socket)
-       second := startTestClient(t, socket)
+       socket := daemonStartTest(t, server)
+       first := terminalStartTestClient(t, socket)
+       second := terminalStartTestClient(t, socket)
        first.WaitFor(t, "[connected to OneChat]")
        second.WaitFor(t, "[connected to OneChat]")
 
        first.Type("Run echo hi.")
-       for _, client := range []*testClient{first, second} {
+       for _, client := range []*TerminalTestClient{first, second} {
                client.WaitFor(t, "[done]")
                require.Equal(t, "[connected to OneChat]\n[working]\n[tool] shell {\"command\":\"echo hi\"}\n[result] hi\nIt said hi.\n[done]\n", client.Output())
        }
 
        first.Type(stopCommand)
        first.WaitFor(t, "[error] no turn runs")
+       first.Type(stopCommand + " BlueFox")
+       first.WaitFor(t, "[error] no subagent BlueFox runs")
        require.NotContains(t, second.Output(), "no turn runs")
        require.NoError(t, first.Close())
 }
 
 func TestStopInterruptsTheTurn(t *testing.T) {
-       server := newFakeResponses(t)
+       server := newModelFake(t)
        arrived := make(chan struct{})
        release := make(chan struct{})
        t.Cleanup(func() {
                close(release)
        })
 
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
                close(arrived)
                <-release
        }
 
-       socket := startTestDaemon(t, server)
-       client := startTestClient(t, socket)
+       socket := daemonStartTest(t, server)
+       client := terminalStartTestClient(t, socket)
        client.Type("a long task")
        <-arrived
 
-       late := startTestClient(t, socket)
+       late := terminalStartTestClient(t, socket)
        late.WaitFor(t, "[connected to OneChat]\n[working]")
 
        client.Type(stopCommand)
        client.WaitFor(t, "[error] interrupted\n[done]")
 }
 
-func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
-       dir := t.TempDir()
-       listener, err := listenSocket(filepath.Join(dir, socketFileName))
-       require.NoError(t, err)
-
-       accepted := make(chan net.Conn)
-       go func() {
-               conn, err := listener.Accept()
-               if err == nil {
-                       accepted <- conn
-               }
-       }()
-
-       conn, err := net.Dial("unix", filepath.Join(dir, socketFileName))
-       require.NoError(t, err)
-
-       in, _ := io.Pipe()
-       done := make(chan error)
-       go func() {
-               done <- chatSession(t.Context(), conn, in, io.Discard)
-       }()
-
-       require.NoError(t, (<-accepted).Close())
-       require.ErrorIs(t, <-done, errDaemonClosed)
-       require.NoError(t, listener.Close())
-}
-
 func TestListenSocketReplacesAnOldSocketFile(t *testing.T) {
        path := filepath.Join(t.TempDir(), socketFileName)
        require.NoError(t, os.WriteFile(path, nil, 0o600))
 
-       listener, err := listenSocket(path)
+       listener, err := daemonListenSocket(path)
        require.NoError(t, err)
 
        info, err := os.Stat(path)
@@ -112,7 +82,7 @@ func TestListenSocketReplacesAnOldSocketFile(t *testing.T) {
 }
 
 func TestListenSocketRefusesALongPath(t *testing.T) {
-       _, err := listenSocket(filepath.Join(t.TempDir(), strings.Repeat("d", 100), socketFileName))
+       _, err := daemonListenSocket(filepath.Join(t.TempDir(), strings.Repeat("d", 100), socketFileName))
        require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path")
 }
 
@@ -128,167 +98,11 @@ func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) {
        require.Empty(t, entries)
 }
 
-func TestOneProcessHoldsTheTokens(t *testing.T) {
-       dir := t.TempDir()
-       lock, err := lockTokens(dir)
-       require.NoError(t, err)
-
-       _, err = lockTokens(dir)
-       require.ErrorIs(t, err, errTokensInUse)
-       require.NoError(t, lock.Close())
-
-       lock, err = lockTokens(dir)
-       require.NoError(t, err)
-       require.NoError(t, lock.Close())
-}
-
-func TestEventText(t *testing.T) {
-       testCases := []struct {
-               name  string
-               event Event
-               text  string
-       }{
-               {
-                       name:  "reply as it is",
-                       event: Event{Kind: "reply", Text: "Done."},
-                       text:  "Done.",
-               },
-               {
-                       name:  "user message not shown",
-                       event: Event{Kind: "user", Text: "typed"},
-                       text:  "",
-               },
-               {
-                       name:  "long result cut",
-                       event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)},
-                       text:  "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]",
-               },
-       }
-
-       for _, tc := range testCases {
-               t.Run(tc.name, func(t *testing.T) {
-                       require.Equal(t, tc.text, eventText(tc.event))
-               })
-       }
-}
-
-// testClient is a terminal client of a test daemon, with a pipe as its input.
-type testClient struct {
-       input *io.PipeWriter
-       done  chan error
-       out   *syncBuffer
-}
-
-// Type writes one line to the input of the client.
-func (c *testClient) Type(line string) {
-       _, err := io.WriteString(c.input, line+"\n")
-       if err != nil {
-               panic(err)
-       }
-}
-
-// Output returns what the client showed.
-func (c *testClient) Output() string {
-       return c.out.String()
-}
-
-// WaitFor waits until the client showed text.
-func (c *testClient) WaitFor(t *testing.T, text string) {
-       t.Helper()
-
-       require.Eventually(t, func() bool {
-               return strings.Contains(c.Output(), text)
-       }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
-}
-
-// Close ends the input and waits for the client.
-func (c *testClient) Close() error {
-       err := c.input.Close()
-       if err != nil {
-               return err
-       }
-
-       return <-c.done
-}
-
-// syncBuffer is a buffer that the test and the client use at once.
-type syncBuffer struct {
-       mutex  sync.Mutex
-       buffer bytes.Buffer
-}
-
-func (b *syncBuffer) Write(p []byte) (int, error) {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       return b.buffer.Write(p)
-}
-
-func (b *syncBuffer) String() string {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       return b.buffer.String()
-}
-
-// startTestDaemon serves a new agent on a socket in a temporary directory and
+// daemonStartTest serves a new agent on a socket in a temporary directory and
 // returns the socket path. The commands of the agent run in a temporary
 // directory.
-func startTestDaemon(t *testing.T, server *fakeResponses) string {
+func daemonStartTest(t *testing.T, server *ModelFake) string {
        t.Helper()
 
-       dir := t.TempDir()
-       agent, err := openAgent(filepath.Join(dir, agentDirName))
-       require.NoError(t, err)
-
-       agent.config.Workdir = t.TempDir()
-       clock := newTestClock()
-       memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
-       require.NoError(t, err)
-
-       socket := filepath.Join(dir, socketFileName)
-       listener, err := listenSocket(socket)
-       require.NoError(t, err)
-
-       client := newClient(server.URL, apiKey(testKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
-       ctx, cancel := context.WithCancel(t.Context())
-       done := make(chan error)
-       go func() {
-               done <- serveAgent(ctx, listener, func(notify func(Event)) (*loop, error) {
-                       return newLoop(agent, memory, client, clock.Now, notify)
-               })
-       }()
-
-       t.Cleanup(func() {
-               cancel()
-               require.NoError(t, <-done)
-               require.NoError(t, memory.Close())
-       })
-
-       return socket
-}
-
-// startTestClient connects a client to the daemon at socket.
-func startTestClient(t *testing.T, socket string) *testClient {
-       t.Helper()
-
-       conn, err := net.Dial("unix", socket)
-       require.NoError(t, err)
-
-       reader, writer := io.Pipe()
-       client := &testClient{
-               input: writer,
-               done:  make(chan error, 1),
-               out:   &syncBuffer{},
-       }
-
-       go func() {
-               client.done <- chatSession(t.Context(), conn, reader, client.out)
-       }()
-
-       t.Cleanup(func() {
-               _ = writer.Close()
-       })
-
-       return client
+       return daemonStartTestWithBot(t, server, nil)
 }