package main import ( "context" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) func TestClientsSeeTheTurnAsItHappens(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5)) return } modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 100, 0, 5)) } 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 []*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") require.NotContains(t, second.Output(), "no turn runs") require.NoError(t, first.Close()) } func TestStopInterruptsTheTurn(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { close(arrived) <-release } socket := daemonStartTest(t, server) client := terminalStartTestClient(t, socket) client.Type("a long task") <-arrived late := terminalStartTestClient(t, socket) late.WaitFor(t, "[connected to OneChat]\n[working]") client.Type(stopCommand) client.WaitFor(t, "[error] interrupted\n[done]") } func TestListenSocketReplacesAnOldSocketFile(t *testing.T) { path := filepath.Join(t.TempDir(), socketFileName) require.NoError(t, os.WriteFile(path, nil, 0o600)) listener, err := daemonListenSocket(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 := daemonListenSocket(filepath.Join(t.TempDir(), strings.Repeat("d", 100), socketFileName)) require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path") } func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) { dir := filepath.Join(t.TempDir(), strings.Repeat("d", 100)) t.Setenv("ONECHAT_DIR", dir) err := daemon(t.Context(), io.Discard) require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path") entries, err := os.ReadDir(dir) require.NoError(t, err) require.Empty(t, entries) } // 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 daemonStartTest(t *testing.T, server *ModelFake) string { t.Helper() return daemonStartTestWithBot(t, server, nil) } // daemonStartTestWithBot is daemonStartTest with bot, which can be nil. func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { t.Helper() dir := t.TempDir() agent, err := openAgent(filepath.Join(dir, daemonAgentDirName)) 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 := daemonListenSocket(socket) require.NoError(t, err) client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error) go func() { done <- daemonServe(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 }