mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
de30488b20
> 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.
153 lines
3.7 KiB
Go
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}
|
|
}
|