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) { if call.number == 0 { writeEvents(w, completedEvent([]any{functionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5)) return } writeEvents(w, completedEvent([]any{messageItem("It said hi.")}, 100, 0, 5)) } socket := startTestDaemon(t, server) first := startTestClient(t, socket) second := startTestClient(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} { 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") require.NotContains(t, second.Output(), "no turn runs") require.NoError(t, first.Close()) } func TestStopInterruptsTheTurn(t *testing.T) { server := newFakeResponses(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) server.reply = func(w http.ResponseWriter, call fakeCall) { close(arrived) <-release } socket := startTestDaemon(t, server) client := startTestClient(t, socket) client.Type("a long task") <-arrived late := startTestClient(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) require.NoError(t, err) info, err := os.Stat(path) require.NoError(t, err) require.Equal(t, os.ModeSocket, info.Mode().Type()) require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) require.NoError(t, listener.Close()) } func TestListenSocketRefusesALongPath(t *testing.T) { _, err := listenSocket(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) _, 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 // returns the socket path. The commands of the agent run in a temporary // directory. func startTestDaemon(t *testing.T, server *fakeResponses) 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 }