]> git.giacomolli.uk - onechat/blobdiff - daemon_test.go
Describe queued event delivery boundaries
[onechat] / daemon_test.go
index 2dfe7f1b2c89a0b9b5042f8d9ffcfb19398b9a10..a32977890b31777e5075690a359bb5769f83ac52 100644 (file)
 package main
 
 import (
 package main
 
 import (
-       "context"
+       "encoding/json"
        "io"
        "io"
+       "net"
        "net/http"
        "os"
        "path/filepath"
        "strings"
        "testing"
        "net/http"
        "os"
        "path/filepath"
        "strings"
        "testing"
+       "time"
 
        "github.com/stretchr/testify/require"
 )
 
 
        "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))
 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
                }
 
                        return
                }
 
@@ -30,6 +125,7 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
        second.WaitFor(t, "[connected to OneChat]")
 
        first.Type("Run echo hi.")
        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())
        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 +133,8 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
 
        first.Type(stopCommand)
        first.WaitFor(t, "[error] no turn runs")
 
        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())
 }
        require.NotContains(t, second.Output(), "no turn runs")
        require.NoError(t, first.Close())
 }
@@ -49,7 +147,7 @@ func TestStopInterruptsTheTurn(t *testing.T) {
                close(release)
        })
 
                close(release)
        })
 
-       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+       server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
                close(arrived)
                <-release
        }
                close(arrived)
                <-release
        }
@@ -97,6 +195,36 @@ func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) {
        require.Empty(t, entries)
 }
 
        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.
 // 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.
@@ -105,38 +233,3 @@ func daemonStartTest(t *testing.T, server *ModelFake) string {
 
        return daemonStartTestWithBot(t, server, nil)
 }
 
        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)) (*TurnLoop, error) {
-                       return newTurnLoop(agent, memory, client, clock.Now, notify)
-               })
-       }()
-
-       t.Cleanup(func() {
-               cancel()
-               require.NoError(t, <-done)
-               require.NoError(t, memory.Close())
-       })
-
-       return socket
-}