--- /dev/null
+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()
+}
package main
import (
- "bytes"
"context"
"io"
- "net"
"net/http"
"os"
"path/filepath"
"strings"
- "sync"
"testing"
- "time"
"github.com/stretchr/testify/require"
)
}
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())
}
}
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))
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.
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
-}
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 {
--- /dev/null
+package main
+
+// stopCommand is the input line that interrupts the running turn.
+const stopCommand = "/stop"
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{
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")
})
socket := startTestDaemonWithBot(t, server, bot)
- terminal := startTestClient(t, socket)
+ terminal := terminalStartTestClient(t, socket)
terminal.WaitFor(t, "[connected to OneChat]")
telegramWaitForSent(t, telegram, "ok")
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")
// 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
"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
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
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
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)
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() {
return fmt.Errorf("decode event: %w", err)
}
- text := eventText(event)
+ text := terminalEventText(event)
if text == "" {
continue
}
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 + "]"
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":
}
}
-// 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 {
--- /dev/null
+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
+}