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())
}
}
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)
}
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")
}
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: "own user message not shown",
- event: Event{Kind: "user", Text: "typed", Source: terminalSource},
- text: "",
- },
- {
- name: "user message of another source marked",
- event: Event{Kind: "user", Text: "from the phone", Source: "telegram"},
- text: "[telegram] from the phone",
- },
- {
- 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()
- return startTestDaemonWithBot(t, server, nil)
-}
-
-// startTestDaemonWithBot is startTestDaemon with bot, which can be nil.
-func startTestDaemonWithBot(t *testing.T, server *fakeResponses, bot *TelegramBot) 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, bot, 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)
}