X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/153f3f9b4b09d521412eacca30eddeed395c3f41..03ef9525ba0e62037b55606b851ee746967a003f:/daemon_test.go?ds=inline diff --git a/daemon_test.go b/daemon_test.go index 3d84faa..5670f6f 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -1,35 +1,148 @@ package main import ( - "context" + "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", + }, + { + AgentID: "Garden", + Source: "check", + ID: "1", + Text: "data", + }, + { + AgentID: strings.Repeat("f", 2*agentIDBytes), + Source: "check", + 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) + + _, err = io.WriteString(conn, `{"type":"notify","notify":{"agent":"Garden","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 := startTestDaemon(t, server) + 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()) @@ -37,6 +150,8 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) { 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()) } @@ -49,12 +164,12 @@ func TestStopInterruptsTheTurn(t *testing.T) { close(release) }) - server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) { close(arrived) <-release } - socket := startTestDaemon(t, server) + socket := daemonStartTest(t, server) client := terminalStartTestClient(t, socket) client.Type("a long task") <-arrived @@ -70,7 +185,7 @@ 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) @@ -81,7 +196,7 @@ func TestListenSocketReplacesAnOldSocketFile(t *testing.T) { } 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") } @@ -97,60 +212,41 @@ func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) { require.Empty(t, entries) } -func TestOneProcessHoldsTheTokens(t *testing.T) { - dir := t.TempDir() - lock, err := lockTokens(dir) - require.NoError(t, err) +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$`, + }, + } - _, err = lockTokens(dir) - require.ErrorIs(t, err, errTokensInUse) - require.NoError(t, lock.Close()) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("JOURNAL_STREAM", tc.journal) - lock, err = lockTokens(dir) - require.NoError(t, err) - require.NoError(t, lock.Close()) + var out strings.Builder + + daemonLogger(&out, "onechat: ").Info("the agent is ready") + require.Regexp(t, tc.line, out.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 *ModelFake) string { - t.Helper() - - return startTestDaemonWithBot(t, server, nil) -} - -// startTestDaemonWithBot is startTestDaemon with bot, which can be nil. -func startTestDaemonWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { +func daemonStartTest(t *testing.T, server *ModelFake) 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 := 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 <- 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 + return daemonStartTestWithBot(t, server, nil) }