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
}
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())
}
close(release)
})
- server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
close(arrived)
<-release
}
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.
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
-}