Files
coder/cli/exp_agents_stream_test.go
T
Michael Suchacz de30488b20 feat(cli): add experimental agents TUI (#24150)
> This PR was authored by Mux on behalf of Mike.

Adds `coder exp agents`, an interactive terminal UI for managing Coder
AI agent chats. Built with bubbletea/lipgloss/glamour, the TUI provides
parity with the web dashboard for chat management, model selection, and
real-time tool execution visibility.

## What it does

- **Chat list view**: tree-based navigation with nested subagent
expansion, search filtering, windowed scrolling, and pagination.
- **Active chat view**: viewport-based transcript with markdown
rendering, WebSocket streaming, and a text input composer for sending
messages.
- **Model picker overlay**: cached model catalog with fuzzy selection.
- **Diff drawer overlay**: git changes inspection with unified diff
rendering.
- **Tool call rendering**: humanized argument summaries, consecutive
duplicate collapsing, and status indicators.

## Key implementation details

- Session lifecycle uses a monotonic `chatGeneration` counter so async
responses from stale sessions are dropped on chat switch.
- Draft mode guards prevent duplicate chat creation on double-Enter.
- Error and loading states render inline without collapsing the TUI
chrome.
- Glamour renderer access is mutex-protected (not thread-safe).
- Intentional WebSocket close is distinguished from dropped connections
to prevent spurious reconnects.

## Testing

~220 unit tests covering rendering, state transitions, keyboard
dispatch, and edge cases. 4-scenario PTY-based E2E suite covers boot,
navigation, search, and direct chat open.

14 new files, ~7,400 lines added.
2026-04-17 12:16:06 +02:00

132 lines
4.3 KiB
Go

package cli //nolint:testpackage // Tests unexported chat stream helpers.
import (
"bytes"
"fmt"
"io"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
type chatWatchWriters struct{ stdout, stderr io.Writer }
func (w chatWatchWriters) Write(p []byte) (int, error) { return w.stdout.Write(p) }
func (w chatWatchWriters) Stderr() io.Writer {
if w.stderr != nil {
return w.stderr
}
return w.stdout
}
func consumeChatStream(eventCh <-chan codersdk.ChatStreamEvent, out io.Writer) error {
errOut := out
if writer, ok := out.(interface{ Stderr() io.Writer }); ok {
errOut = writer.Stderr()
}
printedInline := false
flush := func() error {
if !printedInline {
return nil
}
printedInline = false
_, err := fmt.Fprintln(out)
return err
}
printLine := func(dst io.Writer, format string, args ...any) error {
if err := flush(); err != nil {
return err
}
_, err := fmt.Fprintf(dst, format, args...)
return err
}
for event := range eventCh {
var err error
switch event.Type {
case codersdk.ChatStreamEventTypeMessagePart:
if part := event.MessagePart; part != nil &&
part.Part.Type == codersdk.ChatMessagePartTypeText && part.Part.Text != "" {
printedInline = true
_, err = fmt.Fprint(out, part.Part.Text)
}
case codersdk.ChatStreamEventTypeMessage:
if message := event.Message; message != nil && !printedInline {
for _, part := range message.Content {
if part.Type != codersdk.ChatMessagePartTypeText || part.Text == "" {
continue
}
printedInline = true
if _, err = fmt.Fprint(out, part.Text); err != nil {
break
}
}
}
if err == nil {
err = flush()
}
case codersdk.ChatStreamEventTypeStatus:
if event.Status == nil {
err = flush()
break
}
err = printLine(out, "[Status: %s]\n", event.Status.Status)
case codersdk.ChatStreamEventTypeError:
if event.Error == nil {
err = flush()
break
}
err = printLine(errOut, "[Error: %s]\n", event.Error.Message)
case codersdk.ChatStreamEventTypeRetry:
if event.Retry == nil {
err = flush()
break
}
err = printLine(out, "[Retry attempt %d after error: %s]\n", event.Retry.Attempt, event.Retry.Error)
case codersdk.ChatStreamEventTypeQueueUpdate:
default:
err = printLine(out, "[Event: %s]\n", event.Type)
}
if err != nil {
return xerrors.Errorf("render chat stream event: %w", err)
}
}
if err := flush(); err != nil {
return xerrors.Errorf("flush chat stream output: %w", err)
}
return nil
}
func TestConsumeChatStreamText(t *testing.T) {
t.Parallel()
events := make(chan codersdk.ChatStreamEvent, 7)
for _, event := range []codersdk.ChatStreamEvent{
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "Hello"}}},
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, Text: "ignored"}}},
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: " world"}}},
{Type: codersdk.ChatStreamEventTypeMessage, Message: &codersdk.ChatMessage{ID: 1, ChatID: uuid.New(), Role: codersdk.ChatMessageRoleAssistant, Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "Hello world"}}}},
{Type: codersdk.ChatStreamEventTypeStatus, Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}},
{Type: codersdk.ChatStreamEventTypeRetry, Retry: &codersdk.ChatStreamRetry{Attempt: 2, Error: "rate limited"}},
{Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatStreamError{Message: "boom"}},
} {
events <- event
}
close(events)
var stdout bytes.Buffer
var stderr bytes.Buffer
err := consumeChatStream(events, chatWatchWriters{stdout: &stdout, stderr: &stderr})
require.NoError(t, err)
require.Equal(t, "Hello world\n[Status: running]\n[Retry attempt 2 after error: rate limited]\n", stdout.String())
require.Equal(t, "[Error: boom]\n", stderr.String())
}