package main import ( "encoding/json" "io" "net" "net/http" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" ) func TestNotificationReceiptIsPrivateToItsSubmittingSocket(t *testing.T) { server := newModelFake(t) socket := daemonStartTest(t, server) observer, err := net.Dial("unix", socket) require.NoError(t, err) require.NoError(t, observer.SetDeadline(time.Now().Add(10*time.Second))) decoder := json.NewDecoder(observer) var event Event require.NoError(t, decoder.Decode(&event)) require.Equal(t, "ready", event.Kind) submitter, err := net.Dial("unix", socket) require.NoError(t, err) require.NoError(t, submitter.SetDeadline(time.Now().Add(10*time.Second))) require.NoError(t, json.NewEncoder(submitter).Encode(SocketRequest{ Type: "notify", Notify: &NotifyInput{ Source: "check", ID: "1", Text: "A new result.", }, })) accepted := false answer := json.NewDecoder(submitter) for !accepted { require.NoError(t, answer.Decode(&event)) accepted = event.Kind == "notify" } require.Equal(t, "accepted", event.Status) for { require.NoError(t, decoder.Decode(&event)) require.NotEqual(t, "notify", event.Kind) if event.Kind == "idle" { break } } require.NoError(t, submitter.Close()) require.NoError(t, observer.Close()) } func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) { server := newModelFake(t) socket := daemonStartTest(t, server) conn, err := net.Dial("unix", socket) require.NoError(t, err) require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) decoder := json.NewDecoder(conn) encoder := json.NewEncoder(conn) var event Event require.NoError(t, decoder.Decode(&event)) require.Equal(t, "ready", event.Kind) testCases := []*NotifyInput{ nil, { Source: "invalid source", ID: "1", Text: "data", }, } for _, tc := range testCases { require.NoError(t, encoder.Encode(SocketRequest{ Type: "notify", Notify: tc, })) require.NoError(t, decoder.Decode(&event)) require.Equal(t, "error", event.Kind) require.NotContains(t, event.Text, "invalid source") } _, err = io.WriteString(conn, `{"type":"notify","agent":"Garden","notify":{"source":"check","id":"1","text":"data"}}`+"\n") require.NoError(t, err) require.NoError(t, decoder.Decode(&event)) require.Equal(t, "error", event.Kind) require.Zero(t, server.Calls()) require.NoError(t, conn.Close()) } 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") first.Type(stopCommand + " BlueFox") first.WaitFor(t, "[error] stop subagent: no subagent BlueFox 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(_ http.ResponseWriter, _ 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) } func TestDaemonLogHasATimeStampOnlyOutsideTheJournal(t *testing.T) { testCases := []struct { name string journal string line string }{ { name: "the journal adds the time itself", journal: "8:12345", line: `^<6>onechat: the agent is ready\n$`, }, { 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) { t.Setenv("JOURNAL_STREAM", tc.journal) var out strings.Builder daemonLogger(&out, "onechat: ").Info("the agent is ready") require.Regexp(t, tc.line, out.String()) }) } } // 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) }