From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 16:34:32 +0000 (-0300) Subject: Name and order the terminal client code X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/153f3f9b4b09d521412eacca30eddeed395c3f41?hp=6d1d9d9a7cf90b25a2e10af1c5bc56faee7e494c Name and order the terminal client code Every package-level name of the terminal client starts with terminal, and its file is terminal.go, with its tests in terminal_test.go. The client runs the command "onechat chat", but in the code the chat is the chat of the agent, with its log, tree, and views, so the prefix names the client by its kind. The stop command is in stop.go and the test buffer in buffer_test.go, because the terminal client and the Telegram bot use them both. The declarations of the terminal files follow the order of the go-code skill. The behavior does not change. Issue: 947bddb --- diff --git a/buffer_test.go b/buffer_test.go new file mode 100644 index 0000000..a819bcc --- /dev/null +++ b/buffer_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "bytes" + "sync" +) + +// SyncBuffer is a buffer that the test and the client use at once. +type SyncBuffer struct { + mutex sync.Mutex + buffer bytes.Buffer +} + +func (b *SyncBuffer) Write(p []byte) (int, error) { + b.mutex.Lock() + defer b.mutex.Unlock() + + return b.buffer.Write(p) +} + +func (b *SyncBuffer) String() string { + b.mutex.Lock() + defer b.mutex.Unlock() + + return b.buffer.String() +} diff --git a/daemon_test.go b/daemon_test.go index d1f1bc5..3d84faa 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -1,17 +1,13 @@ package main import ( - "bytes" "context" "io" - "net" "net/http" "os" "path/filepath" "strings" - "sync" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -28,13 +24,13 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) { } socket := startTestDaemon(t, server) - first := startTestClient(t, socket) - second := startTestClient(t, socket) + 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 []*testClient{first, second} { + 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()) } @@ -59,44 +55,17 @@ func TestStopInterruptsTheTurn(t *testing.T) { } socket := startTestDaemon(t, server) - client := startTestClient(t, socket) + client := terminalStartTestClient(t, socket) client.Type("a long task") <-arrived - late := startTestClient(t, socket) + late := terminalStartTestClient(t, socket) late.WaitFor(t, "[connected to OneChat]\n[working]") client.Type(stopCommand) client.WaitFor(t, "[error] interrupted\n[done]") } -func TestClientEndsWhenTheDaemonEnds(t *testing.T) { - dir := t.TempDir() - listener, err := listenSocket(filepath.Join(dir, socketFileName)) - require.NoError(t, err) - - accepted := make(chan net.Conn) - go func() { - conn, err := listener.Accept() - if err == nil { - accepted <- conn - } - }() - - conn, err := net.Dial("unix", filepath.Join(dir, socketFileName)) - require.NoError(t, err) - - in, _ := io.Pipe() - done := make(chan error) - go func() { - done <- chatSession(t.Context(), conn, in, io.Discard) - }() - - require.NoError(t, (<-accepted).Close()) - require.ErrorIs(t, <-done, errDaemonClosed) - require.NoError(t, listener.Close()) -} - func TestListenSocketReplacesAnOldSocketFile(t *testing.T) { path := filepath.Join(t.TempDir(), socketFileName) require.NoError(t, os.WriteFile(path, nil, 0o600)) @@ -142,100 +111,6 @@ func TestOneProcessHoldsTheTokens(t *testing.T) { require.NoError(t, lock.Close()) } -func TestEventText(t *testing.T) { - testCases := []struct { - name string - event Event - text string - }{ - { - name: "reply as it is", - event: Event{Kind: "reply", Text: "Done."}, - text: "Done.", - }, - { - name: "own user message not shown", - event: Event{Kind: "user", Text: "typed", Source: terminalSource}, - text: "", - }, - { - name: "user message of another source marked", - event: Event{Kind: "user", Text: "from the phone", Source: "telegram"}, - text: "[telegram] from the phone", - }, - { - name: "long result cut", - event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)}, - text: "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.text, eventText(tc.event)) - }) - } -} - -// testClient is a terminal client of a test daemon, with a pipe as its input. -type testClient struct { - input *io.PipeWriter - done chan error - out *syncBuffer -} - -// Type writes one line to the input of the client. -func (c *testClient) Type(line string) { - _, err := io.WriteString(c.input, line+"\n") - if err != nil { - panic(err) - } -} - -// Output returns what the client showed. -func (c *testClient) Output() string { - return c.out.String() -} - -// WaitFor waits until the client showed text. -func (c *testClient) WaitFor(t *testing.T, text string) { - t.Helper() - - require.Eventually(t, func() bool { - return strings.Contains(c.Output(), text) - }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output()) -} - -// Close ends the input and waits for the client. -func (c *testClient) Close() error { - err := c.input.Close() - if err != nil { - return err - } - - return <-c.done -} - -// syncBuffer is a buffer that the test and the client use at once. -type syncBuffer struct { - mutex sync.Mutex - buffer bytes.Buffer -} - -func (b *syncBuffer) Write(p []byte) (int, error) { - b.mutex.Lock() - defer b.mutex.Unlock() - - return b.buffer.Write(p) -} - -func (b *syncBuffer) String() string { - b.mutex.Lock() - defer b.mutex.Unlock() - - return b.buffer.String() -} - // startTestDaemon 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. @@ -279,28 +154,3 @@ func startTestDaemonWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s return socket } - -// startTestClient connects a client to the daemon at socket. -func startTestClient(t *testing.T, socket string) *testClient { - t.Helper() - - conn, err := net.Dial("unix", socket) - require.NoError(t, err) - - reader, writer := io.Pipe() - client := &testClient{ - input: writer, - done: make(chan error, 1), - out: &syncBuffer{}, - } - - go func() { - client.done <- chatSession(t.Context(), conn, reader, client.out) - }() - - t.Cleanup(func() { - _ = writer.Close() - }) - - return client -} diff --git a/main.go b/main.go index bdbcb72..f2c263d 100644 --- a/main.go +++ b/main.go @@ -38,7 +38,7 @@ func run() error { case len(args) == 1 && args[0] == "daemon": return daemon(ctx, os.Stdout) case len(args) == 1 && args[0] == "chat": - return chat(ctx, os.Stdin, os.Stdout) + return terminal(ctx, os.Stdin, os.Stdout) case len(args) > 0 && args[0] == "probe": err := probe(ctx, args[1:], os.Stdout) if err != nil { diff --git a/stop.go b/stop.go new file mode 100644 index 0000000..640464f --- /dev/null +++ b/stop.go @@ -0,0 +1,4 @@ +package main + +// stopCommand is the input line that interrupts the running turn. +const stopCommand = "/stop" diff --git a/telegram_test.go b/telegram_test.go index 2f944fa..a1a1ed2 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -22,7 +22,7 @@ func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) { telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) socket := startTestDaemonWithBot(t, server, bot) - terminal := startTestClient(t, socket) + terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") telegram.Add(TelegramUpdate{ @@ -44,7 +44,7 @@ func TestBotIgnoresEveryoneButTheOwner(t *testing.T) { telegram := newTelegramFake(t) bot, logs := telegramStartTestBot(t, telegram) socket := startTestDaemonWithBot(t, newModelFake(t), bot) - terminal := startTestClient(t, socket) + terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") group := telegramOwnerMessage("secret in a group") @@ -150,7 +150,7 @@ func TestBotStartsAtTheStoredOffset(t *testing.T) { }) socket := startTestDaemonWithBot(t, server, bot) - terminal := startTestClient(t, socket) + terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") telegramWaitForSent(t, telegram, "ok") @@ -237,7 +237,7 @@ func TestRepliesGoAsRichMessages(t *testing.T) { telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) socket := startTestDaemonWithBot(t, server, bot) - terminal := startTestClient(t, socket) + terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") terminal.Type("make **it** so") @@ -653,10 +653,10 @@ func TestLoadBotConfig(t *testing.T) { // telegramStartTestBot returns a bot of telegramTestOwner on server, with its // offset file in a temporary directory and short waits, and the buffer of its // log. -func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *syncBuffer) { +func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) { t.Helper() - logs := &syncBuffer{} + logs := &SyncBuffer{} bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0)) bot.minWait = time.Millisecond bot.maxWait = 10 * time.Millisecond diff --git a/chat.go b/terminal.go similarity index 57% rename from chat.go rename to terminal.go index 1e96b24..2906204 100644 --- a/chat.go +++ b/terminal.go @@ -12,19 +12,17 @@ import ( "strings" ) -const ( - // stopCommand is the input line that interrupts the running turn. - stopCommand = "/stop" +// terminalResultLines is the most lines of a tool result that the client shows. +const terminalResultLines = 10 - // resultLines is the most lines of a tool result that the client shows. - resultLines = 10 -) +// errTerminalDaemonClosed tells that the daemon closed the connection. +var errTerminalDaemonClosed = errors.New("the daemon closed the connection") -// chat runs "onechat chat": the terminal client of the daemon. Each line of +// terminal runs "onechat chat": the terminal client of the daemon. Each line of // input is a user message, and the line /stop interrupts the running turn. The // client shows the replies, the tool calls, and their results as they happen. // It ends at the end of the input or when the daemon closes the connection. -func chat(ctx context.Context, in io.Reader, out io.Writer) error { +func terminal(ctx context.Context, in io.Reader, out io.Writer) error { dir, err := dataDir() if err != nil { return err @@ -42,24 +40,21 @@ func chat(ctx context.Context, in io.Reader, out io.Writer) error { return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err) } - return chatSession(ctx, conn, in, out) + return terminalSession(ctx, conn, in, out) } -// errDaemonClosed tells that the daemon closed the connection. -var errDaemonClosed = errors.New("the daemon closed the connection") - -// chatSession runs the client on conn until the input ends, the daemon closes -// the connection, or ctx ends, and closes conn. A read of the input that waits -// for a line can stay behind when the daemon closes the connection. -func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error { +// terminalSession runs the client on conn until the input ends, the daemon +// closes the connection, or ctx ends, and closes conn. A read of the input that +// waits for a line can stay behind when the daemon closes the connection. +func terminalSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error { sent := make(chan error, 1) go func() { - sent <- sendLines(ctx, conn, in) + sent <- terminalSendLines(ctx, conn, in) }() shown := make(chan error, 1) go func() { - shown <- showEvents(conn, out) + shown <- terminalShowEvents(conn, out) }() var err error @@ -68,7 +63,7 @@ func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer err = errors.Join(err, conn.Close()) <-shown case err = <-shown: - err = errors.Join(cmpError(err, errDaemonClosed), conn.Close()) + err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close()) case <-ctx.Done(): err = conn.Close() <-shown @@ -77,17 +72,8 @@ func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer return err } -// cmpError returns err, or fallback when err is nil. -func cmpError(err, fallback error) error { - if err == nil { - return fallback - } - - return err -} - -// sendLines sends each line of in as a request until the input ends. -func sendLines(ctx context.Context, conn net.Conn, in io.Reader) error { +// terminalSendLines sends each line of in as a request until the input ends. +func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error { encoder := json.NewEncoder(conn) scanner := bufio.NewScanner(in) scanner.Buffer(make([]byte, 64*1024), requestBytes) @@ -116,9 +102,9 @@ func sendLines(ctx context.Context, conn net.Conn, in io.Reader) error { return scanner.Err() } -// showEvents writes each event from the daemon to out until the connection -// closes. -func showEvents(conn net.Conn, out io.Writer) error { +// terminalShowEvents writes each event from the daemon to out until the +// connection closes. +func terminalShowEvents(conn net.Conn, out io.Writer) error { scanner := bufio.NewScanner(conn) scanner.Buffer(make([]byte, 64*1024), requestBytes) for scanner.Scan() { @@ -128,7 +114,7 @@ func showEvents(conn net.Conn, out io.Writer) error { return fmt.Errorf("decode event: %w", err) } - text := eventText(event) + text := terminalEventText(event) if text == "" { continue } @@ -142,11 +128,20 @@ func showEvents(conn net.Conn, out io.Writer) error { return scanner.Err() } -// eventText returns the text that the client shows for event, or "" for none. -// The client shows a user message only from another source, such as telegram, -// because the user typed the others. A tool result shows its first resultLines -// lines. -func eventText(event Event) string { +// terminalCmpError returns err, or fallback when err is nil. +func terminalCmpError(err, fallback error) error { + if err == nil { + return fallback + } + + return err +} + +// terminalEventText returns the text that the client shows for event, or "" for +// none. The client shows a user message only from another source, such as +// telegram, because the user typed the others. A tool result shows its first +// terminalResultLines lines. +func terminalEventText(event Event) string { switch event.Kind { case "ready": return "[connected to " + event.Text + "]" @@ -161,7 +156,7 @@ func eventText(event Event) string { case "tool": return "[tool] " + event.Text case "echo": - return "[result] " + firstLines(event.Text, resultLines) + return "[result] " + terminalFirstLines(event.Text, terminalResultLines) case "busy": return "[working]" case "idle": @@ -173,9 +168,9 @@ func eventText(event Event) string { } } -// firstLines returns the first count lines of text, with the number of the -// lines left out. -func firstLines(text string, count int) string { +// terminalFirstLines returns the first count lines of text, with the number of +// the lines left out. +func terminalFirstLines(text string, count int) string { text = strings.TrimRight(text, "\n") lines := strings.Split(text, "\n") if len(lines) <= count { diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..9df3e0a --- /dev/null +++ b/terminal_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "io" + "net" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClientEndsWhenTheDaemonEnds(t *testing.T) { + dir := t.TempDir() + listener, err := listenSocket(filepath.Join(dir, socketFileName)) + require.NoError(t, err) + + accepted := make(chan net.Conn) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + } + }() + + conn, err := net.Dial("unix", filepath.Join(dir, socketFileName)) + require.NoError(t, err) + + in, _ := io.Pipe() + done := make(chan error) + go func() { + done <- terminalSession(t.Context(), conn, in, io.Discard) + }() + + require.NoError(t, (<-accepted).Close()) + require.ErrorIs(t, <-done, errTerminalDaemonClosed) + require.NoError(t, listener.Close()) +} + +func TestEventText(t *testing.T) { + testCases := []struct { + name string + event Event + text string + }{ + { + name: "reply as it is", + event: Event{Kind: "reply", Text: "Done."}, + text: "Done.", + }, + { + name: "own user message not shown", + event: Event{Kind: "user", Text: "typed", Source: terminalSource}, + text: "", + }, + { + name: "user message of another source marked", + event: Event{Kind: "user", Text: "from the phone", Source: "telegram"}, + text: "[telegram] from the phone", + }, + { + name: "long result cut", + event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)}, + text: "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.text, terminalEventText(tc.event)) + }) + } +} + +// TerminalTestClient is a terminal client of a test daemon, with a pipe as its +// input. +type TerminalTestClient struct { + input *io.PipeWriter + done chan error + out *SyncBuffer +} + +// Type writes one line to the input of the client. +func (c *TerminalTestClient) Type(line string) { + _, err := io.WriteString(c.input, line+"\n") + if err != nil { + panic(err) + } +} + +// Output returns what the client showed. +func (c *TerminalTestClient) Output() string { + return c.out.String() +} + +// WaitFor waits until the client showed text. +func (c *TerminalTestClient) WaitFor(t *testing.T, text string) { + t.Helper() + + require.Eventually(t, func() bool { + return strings.Contains(c.Output(), text) + }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output()) +} + +// Close ends the input and waits for the client. +func (c *TerminalTestClient) Close() error { + err := c.input.Close() + if err != nil { + return err + } + + return <-c.done +} + +// terminalStartTestClient connects a client to the daemon at socket. +func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient { + t.Helper() + + conn, err := net.Dial("unix", socket) + require.NoError(t, err) + + reader, writer := io.Pipe() + client := &TerminalTestClient{ + input: writer, + done: make(chan error, 1), + out: &SyncBuffer{}, + } + + go func() { + client.done <- terminalSession(t.Context(), conn, reader, client.out) + }() + + t.Cleanup(func() { + _ = writer.Close() + }) + + return client +}