Files
coder/cli/exp_agents_e2e_helpers_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

153 lines
3.7 KiB
Go

package cli_test
import (
"context"
"os"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func expAgentsPtr[T any](v T) *T {
return &v
}
func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: values,
})
firstUser := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai",
APIKey: "test-api-key",
})
require.NoError(t, err)
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai",
Model: "gpt-4o-mini",
ContextLimit: expAgentsPtr(int64(4096)),
IsDefault: expAgentsPtr(true),
})
require.NoError(t, err)
return client, expClient, firstUser.OrganizationID
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, orgID uuid.UUID, seed string) codersdk.Chat {
t.Helper()
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: orgID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: seed,
},
},
})
require.NoError(t, err)
return chat
}
type expAgentsSession struct {
t *testing.T
pty *ptytest.PTY
errCh <-chan error
}
func (s *expAgentsSession) expect(ctx context.Context, text string) {
s.t.Helper()
s.pty.ExpectMatchContext(ctx, text)
}
func (s *expAgentsSession) wait(ctx context.Context) error {
s.t.Helper()
return testutil.RequireReceive(ctx, s.t, s.errCh)
}
//nolint:unused // Kept as a small PTY helper for future multi-character input.
func (s *expAgentsSession) write(text string) {
s.t.Helper()
s.pty.WriteLine(text)
}
func (s *expAgentsSession) writeRune(r rune) {
s.t.Helper()
_, err := s.pty.Input().Write([]byte(string(r)))
require.NoError(s.t, err)
}
func (s *expAgentsSession) enter() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\r"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) esc() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\x1b"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) ctrlC() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte{3})
require.NoError(s.t, err)
}
func (s *expAgentsSession) quit() {
s.t.Helper()
s.writeRune('q')
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *expAgentsSession {
t.Helper()
// Reading to / writing from the PTY is flaky on non-linux systems.
if runtime.GOOS != "linux" {
t.Skip("skipping on non-linux")
}
fullArgs := append([]string{"exp", "agents"}, args...)
inv, root := clitest.New(t, fullArgs...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
tty, err := os.OpenFile(pty.Name(), os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() {
_ = tty.Close()
})
inv.Stdin = tty
inv.Stdout = tty
inv.Stderr = tty
errCh := make(chan error, 1)
tGo(t, func() {
errCh <- inv.WithContext(ctx).Run()
})
return &expAgentsSession{t: t, pty: pty, errCh: errCh}
}