X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/97274864799ac2eea84bf80c9727a810b695db55..790279faf0fda789d93a75f6f5996e65d5de4a18:/daemon_test.go?ds=inline diff --git a/daemon_test.go b/daemon_test.go index 91d6ecc..6a4d98c 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -1,107 +1,79 @@ 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(_ http.ResponseWriter, _ 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,171 +84,57 @@ 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") } -func TestOneProcessHoldsTheTokens(t *testing.T) { - dir := t.TempDir() - lock, err := lockTokens(dir) - require.NoError(t, err) +func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) { + dir := filepath.Join(t.TempDir(), strings.Repeat("d", 100)) + t.Setenv("ONECHAT_DIR", dir) - _, err = lockTokens(dir) - require.ErrorIs(t, err, errTokensInUse) - require.NoError(t, lock.Close()) + err := daemon(t.Context(), io.Discard) + require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path") - lock, err = lockTokens(dir) + entries, err := os.ReadDir(dir) require.NoError(t, err) - require.NoError(t, lock.Close()) + require.Empty(t, entries) } -func TestEventText(t *testing.T) { +func TestDaemonLogHasATimeStampOnlyOutsideTheJournal(t *testing.T) { testCases := []struct { - name string - event Event - text string + name string + journal string + line 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: "the journal adds the time itself", + journal: "8:12345", + line: `^<6>onechat: the agent is ready\n$`, }, { - 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]", + name: "a terminal gets the time in the line", + journal: "", + line: `^\d{4}/\d\d/\d\d \d\d:\d\d:\d\d INFO onechat: the agent is ready\n$`, }, } 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 -} + t.Setenv("JOURNAL_STREAM", tc.journal) -// 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) - } -} + var out strings.Builder -// 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 + daemonLogger(&out, "onechat: ").Info("the agent is ready") + require.Regexp(t, tc.line, out.String()) + }) } - - 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) }