+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
+}