mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
7904bed947
The Ctrl+D diff drawer in `coder exp agents` only rendered PR-backed
diffs returned by `/api/experimental/chats/{id}/diff`. Local working
tree changes in a chat's workspace returned an empty diff, so the
drawer showed "No diff contents" with no file summary.
Centralise diff loading behind a single `fetchChatDiffContents` helper
that first hits `/diff`, then falls back to the chat git watcher
WebSocket (`/stream/git`) when the remote diff is empty. Aggregate the
agent's `WorkspaceAgentRepoChanges` into a `ChatDiffContents` value so
the drawer can derive the file summary and styled body from the local
unified diff. Missing workspaces, missing agents, and watcher timeouts
are treated as graceful fallbacks that render the empty-diff
placeholder instead of a hard error.
> Mux is opening this PR on Mike's behalf.
3332 lines
124 KiB
Go
3332 lines
124 KiB
Go
package cli //nolint:testpackage // Tests unexported chat TUI reducers.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
func TestExpAgents(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("ResolveModel", func(t *testing.T) {
|
|
t.Parallel()
|
|
catalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "openai",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: "openai:gpt-4o",
|
|
Provider: "openai",
|
|
Model: "gpt-4o",
|
|
DisplayName: "GPT-4o",
|
|
}},
|
|
}},
|
|
}
|
|
|
|
client := newTestExperimentalClient(t, func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(rw).Encode(catalog)
|
|
})
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{name: "ExactID", input: "openai:gpt-4o", want: "openai:gpt-4o"},
|
|
{name: "ProviderModel", input: "openai/gpt-4o", want: "openai:gpt-4o"},
|
|
{name: "DisplayName", input: "GPT-4o", want: "openai:gpt-4o"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resolved, err := resolveModel(context.Background(), client, tt.input)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resolved)
|
|
require.Equal(t, tt.want, *resolved)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("TopLevelModelRouting", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
overlay tuiOverlay
|
|
}{
|
|
{"ModelPicker", overlayModelPicker},
|
|
{"DiffDrawer", overlayDiffDrawer},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run("EscFromOverlayClosesIt/"+tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = tt.overlay
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
})
|
|
}
|
|
|
|
t.Run("AdditionalOverlayCloseKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
overlay tuiOverlay
|
|
key tea.KeyMsg
|
|
}{
|
|
{name: "ModelPicker/KeyEscape", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyEscape}},
|
|
{name: "ModelPicker/CtrlOpenBracket", overlay: overlayModelPicker, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}},
|
|
{name: "DiffDrawer/KeyEscape", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyEscape}},
|
|
{name: "DiffDrawer/CtrlOpenBracket", overlay: overlayDiffDrawer, key: tea.KeyMsg{Type: tea.KeyCtrlOpenBracket}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = tt.overlay
|
|
|
|
updatedModel, cmd := model.Update(tt.key)
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("EscFromChatViewReturnsToListAndRefreshes", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = overlayNone
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.True(t, updated.list.loading)
|
|
require.NotNil(t, cmd)
|
|
})
|
|
|
|
t.Run("EscFromChatViewAdvancesGeneration", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = overlayNone
|
|
model.chatGeneration = 4
|
|
model.chat.chatGeneration = 4
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, uint64(5), updated.chatGeneration)
|
|
require.Equal(t, uint64(5), updated.chat.chatGeneration)
|
|
require.True(t, updated.chat.matchesGeneration(updated.chatGeneration))
|
|
require.NotNil(t, cmd)
|
|
})
|
|
|
|
t.Run("EscFromChatViewRejectsLateChatLoadMessages", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = overlayNone
|
|
model.chatGeneration = 4
|
|
model.chat.chatGeneration = 4
|
|
model.chat.chat = &codersdk.Chat{ID: uuid.New(), Title: "current chat"}
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.NotNil(t, cmd)
|
|
|
|
staleChat := codersdk.Chat{ID: uuid.New(), Title: "stale chat"}
|
|
updatedModel, cmd = updated.Update(chatOpenedMsg{generation: 4, chatID: staleChat.ID, chat: staleChat})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, "current chat", updated.chat.chat.Title)
|
|
|
|
staleMessages := []codersdk.ChatMessage{testMessage(
|
|
1,
|
|
codersdk.ChatMessageRoleUser,
|
|
codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale"},
|
|
)}
|
|
updatedModel, cmd = updated.Update(chatHistoryMsg{generation: 4, chatID: staleChat.ID, messages: staleMessages})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Empty(t, updated.chat.messages)
|
|
})
|
|
|
|
t.Run("EscFromSearchClearsFilterAndRestoresListNavigation", func(t *testing.T) {
|
|
t.Parallel()
|
|
chats := []codersdk.Chat{
|
|
{ID: uuid.New(), Title: "alpha", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
|
{ID: uuid.New(), Title: "beta", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
|
{ID: uuid.New(), Title: "gamma", Status: codersdk.ChatStatusCompleted, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
|
}
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
model.currentView = viewList
|
|
model.list.loading = false
|
|
model.list.chats = chats
|
|
|
|
updatedModel, cmd = model.Update(keyRunes("/"))
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.True(t, updated.list.searching)
|
|
|
|
updatedModel, cmd = updated.Update(keyRunes("b"))
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, "b", updated.list.search.Value())
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.False(t, updated.quitting)
|
|
require.False(t, updated.list.searching)
|
|
require.Empty(t, updated.list.search.Value())
|
|
|
|
updatedModel, cmd = updated.Update(keyRunes("j"))
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, 1, updated.list.cursor)
|
|
|
|
updatedModel, cmd = updated.Update(keyRunes("k"))
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, 0, updated.list.cursor)
|
|
})
|
|
|
|
for name, view := range map[string]tuiView{
|
|
"List": viewList,
|
|
"Chat": viewChat,
|
|
} {
|
|
t.Run("CtrlCQuitsFromAnyState/"+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = view
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.True(t, updated.quitting)
|
|
_, ok := mustMsg(t, cmd).(tea.QuitMsg)
|
|
require.True(t, ok)
|
|
})
|
|
}
|
|
|
|
t.Run("OpenChatSwitchesView", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
msg tea.Msg
|
|
draft bool
|
|
wantLoading bool
|
|
wantBatchLen int
|
|
}{
|
|
{name: "SelectedChat", msg: openSelectedChatMsg{chatID: uuid.New()}, wantLoading: true, wantBatchLen: 3},
|
|
{name: "DraftChat", msg: openDraftChatMsg{}, draft: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.width, model.height = 100, 40
|
|
updatedModel, cmd := model.Update(tt.msg)
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, 40, updated.chat.height)
|
|
require.Equal(t, 34, updated.chat.viewport.Height)
|
|
if tt.draft {
|
|
require.True(t, updated.chat.draft)
|
|
require.False(t, updated.chat.loading)
|
|
require.True(t, updated.chat.metadataResolved)
|
|
require.True(t, updated.chat.historyResolved)
|
|
require.Nil(t, cmd)
|
|
return
|
|
}
|
|
require.Equal(t, tt.wantLoading, updated.chat.loading)
|
|
require.Len(t, mustBatchMsg(t, cmd), tt.wantBatchLen)
|
|
})
|
|
}
|
|
})
|
|
t.Run("EscFromChatViewRestoresListHeaderAndPadsTerminal", func(t *testing.T) {
|
|
t.Parallel()
|
|
assertReturnToList := func(t testing.TB, model expChatsTUIModel) {
|
|
t.Helper()
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
firstLine, _, _ := strings.Cut(plainText(updated.View()), "\n")
|
|
require.Equal(t, "Coder Chats", firstLine)
|
|
require.Equal(t, updated.height, countRenderedLines(plainText(updated.View())))
|
|
}
|
|
|
|
t.Run("SelectedChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
model.list.loading = false
|
|
model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)}
|
|
chatID := uuid.New()
|
|
|
|
updatedModel, cmd = model.Update(openSelectedChatMsg{chatID: chatID})
|
|
model, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
openedChat := testChat(codersdk.ChatStatusCompleted)
|
|
openedChat.ID = chatID
|
|
openedChat.Title = "Existing chat"
|
|
updatedModel, cmd = model.Update(chatOpenedMsg{generation: model.chat.chatGeneration, chatID: chatID, chat: openedChat})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.Contains(t, plainText(model.View()), "Existing chat")
|
|
|
|
assertReturnToList(t, model)
|
|
})
|
|
|
|
t.Run("DraftChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
model.list.loading = false
|
|
model.list.chats = []codersdk.Chat{testChat(codersdk.ChatStatusCompleted)}
|
|
|
|
updatedModel, cmd = model.Update(openDraftChatMsg{})
|
|
model, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Contains(t, plainText(model.View()), "New Chat (draft)")
|
|
|
|
assertReturnToList(t, model)
|
|
})
|
|
})
|
|
t.Run("ChatViewOmitsListHeaderAndLoadingSpinner", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
model.currentView = viewChat
|
|
model.list.loading = true
|
|
model.chat.loading = false
|
|
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
chat.Title = "Existing chat"
|
|
model.chat.chat = &chat
|
|
model.chat.chatStatus = chat.Status
|
|
model.chat.messages = []codersdk.ChatMessage{
|
|
testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeText,
|
|
Text: "assistant reply",
|
|
}),
|
|
}
|
|
model.chat.rebuildBlocks()
|
|
|
|
view := plainText(model.View())
|
|
firstLine, _, _ := strings.Cut(view, "\n")
|
|
require.Contains(t, firstLine, "Existing chat")
|
|
require.NotContains(t, view, "Coder Chats")
|
|
require.NotContains(t, view, "Loading chats")
|
|
})
|
|
|
|
t.Run("ReopensModelPickerAfterClosing", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
catalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "provider",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: "provider:model-a",
|
|
Provider: "provider",
|
|
Model: "model-a",
|
|
DisplayName: "Model A",
|
|
}},
|
|
}},
|
|
}
|
|
model.catalog = &catalog
|
|
model.chat.modelPickerFlat = catalog.Providers[0].Models
|
|
updatedModel, cmd := model.Update(toggleModelPickerMsg{})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
})
|
|
|
|
t.Run("ModelPickerBehavior", func(t *testing.T) {
|
|
t.Parallel()
|
|
twoModelCatalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "openai",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{
|
|
{ID: "openai:gpt-4o", Provider: "openai", Model: "gpt-4o", DisplayName: "GPT-4o"},
|
|
{ID: "openai:gpt-4.1", Provider: "openai", Model: "gpt-4.1", DisplayName: "GPT-4.1"},
|
|
},
|
|
}},
|
|
}
|
|
|
|
t.Run("CancelClosesOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
})
|
|
|
|
t.Run("EscClosesPickerWithoutLeavingChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
model.chat.draft = true
|
|
model.chat.composerFocused = true
|
|
model.chat.composer.SetValue("keep draft")
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
// ClearScreen cmd is expected
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, "keep draft", updated.chat.composer.Value())
|
|
})
|
|
|
|
t.Run("AdditionalCloseKeysClosePickerWithoutLeavingChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
key tea.KeyMsg
|
|
}{
|
|
{name: "CtrlP", key: tea.KeyMsg{Type: tea.KeyCtrlP}},
|
|
{name: "Q", key: keyRunes("q")},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
model.chat.draft = true
|
|
model.chat.composerFocused = true
|
|
model.chat.composer.SetValue("keep draft")
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tt.key)
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
// ClearScreen cmd is expected
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, "keep draft", updated.chat.composer.Value())
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("EnterSelectsModelWithoutSendingDraft", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
model.chat.draft = true
|
|
model.chat.composerFocused = true
|
|
model.chat.composer.SetValue("keep draft")
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, 1, updated.chat.modelPickerCursor)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
// ClearScreen cmd is expected
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.NotNil(t, updated.chat.modelOverride)
|
|
require.NotNil(t, updated.modelOverride)
|
|
require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride)
|
|
require.Equal(t, "openai:gpt-4.1", *updated.modelOverride)
|
|
require.Equal(t, "keep draft", updated.chat.composer.Value())
|
|
require.False(t, updated.chat.creatingChat)
|
|
})
|
|
|
|
t.Run("LoadErrorClosesOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
|
|
updatedModel, cmd := model.Update(toggleModelPickerMsg{})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
require.NotNil(t, cmd)
|
|
require.Contains(t, plainText(updated.View()), "Loading models...")
|
|
|
|
updatedModel, cmd = updated.Update(modelsListedMsg{err: xerrors.New("model discovery failed")})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.NotContains(t, plainText(updated.View()), "Loading models...")
|
|
})
|
|
|
|
t.Run("ScrollAndSelectModel", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
model.height = 24
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: twoModelCatalog})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
|
|
for range 4 {
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
}
|
|
require.Equal(t, 1, updated.chat.modelPickerCursor)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.NotNil(t, updated.chat.modelOverride)
|
|
require.NotNil(t, updated.modelOverride)
|
|
require.Equal(t, "openai:gpt-4.1", *updated.chat.modelOverride)
|
|
require.Equal(t, "openai:gpt-4.1", *updated.modelOverride)
|
|
})
|
|
})
|
|
|
|
t.Run("DiffDrawerLoadingState", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat = &chat
|
|
|
|
updatedModel, cmd := model.Update(toggleDiffDrawerMsg{})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayDiffDrawer, updated.overlay)
|
|
require.NotNil(t, cmd)
|
|
require.Contains(t, plainText(updated.View()), "Loading diff")
|
|
})
|
|
|
|
t.Run("DiffDrawerErrorState", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat = &chat
|
|
|
|
updatedModel, cmd := model.Update(toggleDiffDrawerMsg{})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(diffContentsMsg{err: xerrors.New("connection refused")})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Contains(t, plainText(updated.View()), "connection refused")
|
|
})
|
|
|
|
t.Run("DiffDrawerMemoizesSummary", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.width = 80
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat = &chat
|
|
generation := model.chat.chatGeneration
|
|
|
|
// A successful diffContentsMsg pre-renders the summary
|
|
// and the lipgloss-styled body so View() redraws do not
|
|
// re-parse or re-style the full diff on every keypress
|
|
// (see chatViewModel.diffSummary and diffStyledBody).
|
|
diff := codersdk.ChatDiffContents{
|
|
ChatID: chat.ID,
|
|
Diff: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new",
|
|
}
|
|
updatedModel, cmd := model.Update(diffContentsMsg{generation: generation, chatID: chat.ID, diff: diff})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.NotNil(t, updated.chat.diffContents)
|
|
require.Equal(t, "1 file changed:\n modified a.txt (+1 -1)", updated.chat.diffSummary)
|
|
require.NotEmpty(t, updated.chat.diffStyledBody)
|
|
// The cached styled body still contains the diff text
|
|
// verbatim: lipgloss wraps lines in escape codes without
|
|
// replacing them, so every original line of the input
|
|
// diff must survive the round-trip.
|
|
require.Contains(t, plainText(updated.chat.diffStyledBody), "diff --git a/a.txt b/a.txt")
|
|
require.Contains(t, plainText(updated.chat.diffStyledBody), "+new")
|
|
|
|
// setChat clears both caches so a new chat does not
|
|
// inherit stale render output from the previous session.
|
|
(&updated.chat).setChat(testChat(codersdk.ChatStatusCompleted))
|
|
require.Empty(t, updated.chat.diffSummary)
|
|
require.Empty(t, updated.chat.diffStyledBody)
|
|
require.Nil(t, updated.chat.diffContents)
|
|
})
|
|
|
|
t.Run("OverlayDismissedOnViewSwitch", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = overlayModelPicker
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.True(t, updated.list.loading)
|
|
require.NotNil(t, cmd)
|
|
})
|
|
|
|
t.Run("OverlaysMutuallyExclusive", func(t *testing.T) {
|
|
t.Parallel()
|
|
catalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "provider",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: uuid.New().String(),
|
|
Provider: "provider",
|
|
Model: "model-a",
|
|
DisplayName: "Model A",
|
|
}},
|
|
}},
|
|
}
|
|
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.overlay = overlayModelPicker
|
|
model.catalog = &catalog
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat = &chat
|
|
|
|
updatedModel, cmd := model.Update(toggleDiffDrawerMsg{})
|
|
updated, _ := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayDiffDrawer, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
})
|
|
|
|
t.Run("OverlayBlocksViewKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
catalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "provider",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: uuid.New().String(),
|
|
Provider: "provider",
|
|
Model: "model-a",
|
|
DisplayName: "Model A",
|
|
}},
|
|
}},
|
|
}
|
|
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
model.catalog = &catalog
|
|
model.chat.modelPickerFlat = catalog.Providers[0].Models
|
|
|
|
updatedModel, cmd := model.Update(toggleModelPickerMsg{})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(keyRunes("n"))
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
require.False(t, updated.chat.draft)
|
|
})
|
|
|
|
t.Run("RapidViewSwitching", func(t *testing.T) {
|
|
t.Parallel()
|
|
firstChatID := uuid.New()
|
|
secondChatID := uuid.New()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.width = 100
|
|
model.height = 40
|
|
|
|
updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: firstChatID})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.True(t, updated.chat.loading)
|
|
require.Nil(t, updated.chat.chat)
|
|
require.Empty(t, updated.chat.messages)
|
|
require.Len(t, mustBatchMsg(t, cmd), 3)
|
|
|
|
firstChat := testChat(codersdk.ChatStatusCompleted)
|
|
firstChat.ID = firstChatID
|
|
updated.chat.chat = &firstChat
|
|
updated.chat.loading = false
|
|
updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "from chat A"})}
|
|
updated.chat.composer.SetValue("stale draft")
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.True(t, updated.list.loading)
|
|
|
|
updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: secondChatID})
|
|
updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.True(t, updated.chat.loading)
|
|
require.Nil(t, updated.chat.chat)
|
|
require.Empty(t, updated.chat.messages)
|
|
require.Empty(t, updated.chat.composer.Value())
|
|
require.False(t, updated.chat.draft)
|
|
require.Len(t, mustBatchMsg(t, cmd), 3)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.True(t, updated.list.loading)
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/MessageReceiving", func(t *testing.T) {
|
|
t.Parallel()
|
|
setup := func(metadataResolved, historyResolved bool) chatViewModel {
|
|
model := newTestChatViewModel(nil)
|
|
model.loading, model.metadataResolved, model.historyResolved = true, metadataResolved, historyResolved
|
|
return model
|
|
}
|
|
t.Run("ChatOpenedSuccessAndError", func(t *testing.T) {
|
|
t.Parallel()
|
|
diffStatus := &codersdk.ChatDiffStatus{ChatID: uuid.New()}
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
chat.DiffStatus = diffStatus
|
|
chat.PlanMode = codersdk.ChatPlanModePlan
|
|
updated, cmd := setup(false, true).Update(chatOpenedMsg{chat: chat})
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, chat.ID, updated.chat.ID)
|
|
require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus)
|
|
require.Equal(t, diffStatus, updated.diffStatus)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode)
|
|
require.False(t, updated.loading)
|
|
require.Nil(t, updated.err)
|
|
updated, cmd = setup(false, true).Update(chatOpenedMsg{err: xerrors.New("open failed")})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, "open failed", updated.err.Error())
|
|
require.False(t, updated.loading)
|
|
})
|
|
t.Run("ChatHistorySuccessAndError", func(t *testing.T) {
|
|
t.Parallel()
|
|
usageA := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(10)}
|
|
usageB := &codersdk.ChatMessageUsage{TotalTokens: int64Ref(20)}
|
|
second := testMessage(2, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "second"})
|
|
second.Usage = usageA
|
|
third := testMessage(3, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: "third"})
|
|
third.Usage = usageB
|
|
messages := []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "first"}), second, third}
|
|
updated, cmd := setup(true, false).Update(chatHistoryMsg{messages: messages})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, messages, updated.messages)
|
|
require.Len(t, updated.blocks, 3)
|
|
require.Equal(t, usageB, updated.lastUsage)
|
|
require.False(t, updated.loading)
|
|
updated, cmd = setup(true, false).Update(chatHistoryMsg{err: xerrors.New("history failed")})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, "history failed", updated.err.Error())
|
|
require.False(t, updated.loading)
|
|
})
|
|
t.Run("OpenHistoryBothSucceedOutOfOrder", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := setup(false, false)
|
|
model, _ = model.Update(chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}})
|
|
require.True(t, model.loading)
|
|
require.Nil(t, model.err)
|
|
model.streaming = true
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model, _ = model.Update(chatOpenedMsg{chat: chat})
|
|
require.False(t, model.loading)
|
|
require.Nil(t, model.err)
|
|
require.Len(t, model.messages, 1)
|
|
})
|
|
t.Run("OpenHistoryBothFail", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := setup(false, false)
|
|
model, _ = model.Update(chatOpenedMsg{err: xerrors.New("open err")})
|
|
require.True(t, model.loading)
|
|
model, _ = model.Update(chatHistoryMsg{err: xerrors.New("history err")})
|
|
require.False(t, model.loading)
|
|
require.Equal(t, "open err", model.err.Error())
|
|
})
|
|
t.Run("StaleAsyncMessagesAreDropped", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.setChat(chat)
|
|
model.chatGeneration = 1
|
|
model.loading = false
|
|
|
|
before := model
|
|
updated, cmd := model.Update(chatOpenedMsg{
|
|
generation: 0,
|
|
chatID: uuid.New(),
|
|
chat: testChat(codersdk.ChatStatusRunning),
|
|
})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, before.chat, updated.chat)
|
|
require.Equal(t, before.loading, updated.loading)
|
|
require.Equal(t, before.messages, updated.messages)
|
|
require.Equal(t, before.err, updated.err)
|
|
})
|
|
|
|
t.Run("StaleSessionMessagesAreDroppedByGeneration", func(t *testing.T) {
|
|
t.Parallel()
|
|
type staleGenerationCase struct {
|
|
name string
|
|
msg tea.Msg
|
|
draft bool
|
|
}
|
|
type staleGenerationSnapshot struct {
|
|
loading bool
|
|
err error
|
|
chat *codersdk.Chat
|
|
pendingComposerText string
|
|
composerValue string
|
|
messages []codersdk.ChatMessage
|
|
draft bool
|
|
creatingChat bool
|
|
interrupting bool
|
|
queuedMessages []codersdk.ChatQueuedMessage
|
|
}
|
|
|
|
startingState := func(draft bool) chatViewModel {
|
|
model := newTestChatViewModel(nil)
|
|
model.chatGeneration = 2
|
|
model.loading = false
|
|
model.pendingComposerText = "pending"
|
|
if draft {
|
|
model.draft = true
|
|
model.composer.SetValue("draft text")
|
|
return model
|
|
}
|
|
model.creatingChat = true
|
|
model.interrupting = true
|
|
model.setChat(testChat(codersdk.ChatStatusCompleted))
|
|
model.composer.SetValue("current")
|
|
return model
|
|
}
|
|
snapshot := func(model chatViewModel) staleGenerationSnapshot {
|
|
return staleGenerationSnapshot{
|
|
loading: model.loading,
|
|
err: model.err,
|
|
chat: model.chat,
|
|
pendingComposerText: model.pendingComposerText,
|
|
composerValue: model.composer.Value(),
|
|
messages: model.messages,
|
|
draft: model.draft,
|
|
creatingChat: model.creatingChat,
|
|
interrupting: model.interrupting,
|
|
queuedMessages: model.queuedMessages,
|
|
}
|
|
}
|
|
|
|
tests := []staleGenerationCase{
|
|
{name: "WriteSide/chatCreatedMsg", msg: chatCreatedMsg{generation: 1, chat: testChat(codersdk.ChatStatusRunning)}},
|
|
{name: "WriteSide/messageSentMsg", msg: messageSentMsg{generation: 1, resp: codersdk.CreateChatMessageResponse{}}},
|
|
{name: "WriteSide/chatInterruptedMsg", msg: chatInterruptedMsg{generation: 1, chat: testChat(codersdk.ChatStatusCompleted)}},
|
|
{name: "Draft/chatOpenedMsg", msg: chatOpenedMsg{generation: 1, chatID: uuid.New(), chat: testChat(codersdk.ChatStatusCompleted)}, draft: true},
|
|
{name: "Draft/chatHistoryMsg", msg: chatHistoryMsg{generation: 1, chatID: uuid.New(), messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hi"})}}, draft: true},
|
|
{name: "Draft/chatStreamEventMsg", msg: chatStreamEventMsg{generation: 1, chatID: uuid.New(), event: testTextPartEvent("stale")}, draft: true},
|
|
{name: "Draft/diffContentsMsg", msg: diffContentsMsg{generation: 1, chatID: uuid.New()}, draft: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := startingState(tt.draft)
|
|
before := snapshot(model)
|
|
updated, cmd := model.Update(tt.msg)
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, before, snapshot(updated))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("ErrorThenRetrySucceeds", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
errMsg tea.Msg
|
|
retryMsg tea.Msg
|
|
needsClient bool
|
|
composerText string
|
|
wantBlocks int
|
|
wantRetryCmd bool
|
|
}{
|
|
{name: "ChatOpened", errMsg: chatOpenedMsg{err: xerrors.New("open failed")}, retryMsg: chatOpenedMsg{chat: testChat(codersdk.ChatStatusRunning)}},
|
|
{name: "History", errMsg: chatHistoryMsg{err: xerrors.New("history failed")}, retryMsg: chatHistoryMsg{messages: []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "recovered"})}}, wantBlocks: 1},
|
|
{name: "Send", needsClient: true, composerText: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}, wantRetryCmd: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
if tt.needsClient {
|
|
model = newTestChatViewModel(failingExperimentalClient())
|
|
model.loading = false
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model.composer.SetValue(tt.composerText)
|
|
}
|
|
updated, cmd := model.Update(tt.errMsg)
|
|
require.Nil(t, cmd)
|
|
require.Error(t, updated.err)
|
|
if tt.retryMsg != nil {
|
|
updated, cmd = updated.Update(tt.retryMsg)
|
|
require.Nil(t, updated.err)
|
|
switch retryMsg := tt.retryMsg.(type) {
|
|
case chatOpenedMsg:
|
|
require.NotNil(t, cmd)
|
|
require.NotNil(t, updated.chat)
|
|
require.Equal(t, retryMsg.chat.ID, updated.chat.ID)
|
|
case chatHistoryMsg:
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, retryMsg.messages, updated.messages)
|
|
require.Len(t, updated.blocks, tt.wantBlocks)
|
|
}
|
|
}
|
|
if !tt.wantRetryCmd {
|
|
return
|
|
}
|
|
require.Equal(t, tt.composerText, updated.composer.Value())
|
|
require.Contains(t, updated.View(), "send failed")
|
|
updated.composer.SetValue("retry me")
|
|
retried, retryCmd := updated.sendMessage()
|
|
require.NotNil(t, retryCmd)
|
|
require.True(t, retried.autoFollow)
|
|
require.Empty(t, retried.composer.Value())
|
|
_, ok := mustMsg(t, retryCmd).(messageSentMsg)
|
|
require.True(t, ok)
|
|
})
|
|
}
|
|
})
|
|
t.Run("ChatHistoryEdgeCases", func(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
messages []codersdk.ChatMessage
|
|
wantNil bool
|
|
}{
|
|
{name: "NilMessages", wantNil: true},
|
|
{name: "EmptyMessages", messages: []codersdk.ChatMessage{}, wantNil: false},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.messages = []codersdk.ChatMessage{
|
|
testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}),
|
|
}
|
|
model.rebuildBlocks()
|
|
require.Len(t, model.blocks, 1)
|
|
|
|
var updated chatViewModel
|
|
require.NotPanics(t, func() {
|
|
updated, _ = model.Update(chatHistoryMsg{messages: tt.messages})
|
|
})
|
|
require.Equal(t, tt.wantNil, updated.messages == nil)
|
|
if !tt.wantNil {
|
|
require.Empty(t, updated.messages)
|
|
}
|
|
require.Empty(t, updated.blocks)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/StreamEvents", func(t *testing.T) {
|
|
t.Parallel()
|
|
applyStream := func(model chatViewModel, event codersdk.ChatStreamEvent) (chatViewModel, tea.Cmd) {
|
|
return model.Update(chatStreamEventMsg{event: event})
|
|
}
|
|
messageEvent := func(message codersdk.ChatMessage) codersdk.ChatStreamEvent {
|
|
return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message}
|
|
}
|
|
usage := &codersdk.ChatMessageUsage{OutputTokens: int64Ref(7)}
|
|
finalMessage := testMessage(9, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "final"})
|
|
finalMessage.Usage = usage
|
|
for _, tt := range []struct {
|
|
name string
|
|
seedEvents []codersdk.ChatStreamEvent
|
|
reconnecting bool
|
|
event codersdk.ChatStreamEvent
|
|
wantMessages int
|
|
wantAccumulatorText string
|
|
wantAccumulatorArgs string
|
|
wantBlockKind chatBlockKind
|
|
wantBlockText string
|
|
wantBlockArgs string
|
|
wantUsage *codersdk.ChatMessageUsage
|
|
}{
|
|
{
|
|
name: "MessagePartTextAppendsAndRebuildsBlocks",
|
|
seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("hel")},
|
|
event: testTextPartEvent("lo"),
|
|
wantAccumulatorText: "hello",
|
|
wantBlockText: "hello",
|
|
},
|
|
{
|
|
name: "MessagePartToolCallDeltaAccumulatesArgs",
|
|
seedEvents: []codersdk.ChatStreamEvent{testToolCallDeltaEvent("tc-1", "search", `{"q":"hel`)},
|
|
event: testToolCallDeltaEvent("tc-1", "search", `lo"}`),
|
|
wantAccumulatorArgs: `{"q":"hello"}`,
|
|
wantBlockKind: blockToolCall,
|
|
wantBlockArgs: `{"q":"hello"}`,
|
|
},
|
|
{
|
|
name: "MessageFinalizesAndResetsAccumulator",
|
|
seedEvents: []codersdk.ChatStreamEvent{testTextPartEvent("partial")},
|
|
reconnecting: true,
|
|
event: messageEvent(finalMessage),
|
|
wantMessages: 1,
|
|
wantBlockText: "final",
|
|
wantUsage: usage,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.reconnecting = tt.reconnecting
|
|
for _, event := range tt.seedEvents {
|
|
model, _ = applyStream(model, event)
|
|
}
|
|
var cmd tea.Cmd
|
|
model, cmd = applyStream(model, tt.event)
|
|
require.Nil(t, cmd)
|
|
assertStreamCase(t, model, tt.wantMessages, tt.wantAccumulatorText, tt.wantAccumulatorArgs, tt.wantBlockKind, tt.wantBlockText, tt.wantBlockArgs, tt.wantUsage)
|
|
})
|
|
}
|
|
t.Run("StatusEventRouting", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
chat := testChat(codersdk.ChatStatusWaiting)
|
|
model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status
|
|
updated, cmd := model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeStatus,
|
|
ChatID: chat.ID,
|
|
Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning},
|
|
}})
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, codersdk.ChatStatusRunning, updated.chatStatus)
|
|
require.Equal(t, codersdk.ChatStatusRunning, updated.chat.Status)
|
|
chat.Status = codersdk.ChatStatusWaiting
|
|
model.chatStatus = codersdk.ChatStatusWaiting
|
|
updated, cmd = model.Update(chatStreamEventMsg{event: codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeStatus,
|
|
ChatID: uuid.New(),
|
|
Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning},
|
|
}})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, updated.chat.Status)
|
|
})
|
|
t.Run("ErrorSetsErr", func(t *testing.T) {
|
|
t.Parallel()
|
|
updated, cmd := applyStream(newTestChatViewModel(nil), codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeError,
|
|
Error: &codersdk.ChatStreamError{Message: "stream blew up"},
|
|
})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, "stream error: stream blew up", updated.err.Error())
|
|
})
|
|
queuedMessages := []codersdk.ChatQueuedMessage{
|
|
testQueuedMessage(1, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued text"}),
|
|
}
|
|
existingMessages := []codersdk.ChatMessage{
|
|
testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing"}),
|
|
}
|
|
for _, tt := range []struct {
|
|
name string
|
|
messages []codersdk.ChatMessage
|
|
event codersdk.ChatStreamEvent
|
|
wantMessages []codersdk.ChatMessage
|
|
wantQueuedMessages []codersdk.ChatQueuedMessage
|
|
wantBlockText string
|
|
}{
|
|
{
|
|
name: "QueueUpdateReplacesQueuedMessages",
|
|
event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeQueueUpdate, QueuedMessages: queuedMessages},
|
|
wantQueuedMessages: queuedMessages,
|
|
wantBlockText: "queued text",
|
|
},
|
|
{
|
|
name: "StreamEventWithNilPartIsIgnored",
|
|
messages: existingMessages,
|
|
event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart},
|
|
wantMessages: existingMessages,
|
|
wantBlockText: "existing",
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.messages = tt.messages
|
|
model.rebuildBlocks()
|
|
updated, cmd := applyStream(model, tt.event)
|
|
require.Nil(t, cmd)
|
|
model = updated
|
|
require.Equal(t, tt.wantMessages, model.messages)
|
|
require.Equal(t, tt.wantQueuedMessages, model.queuedMessages)
|
|
require.Len(t, model.blocks, 1)
|
|
require.Equal(t, tt.wantBlockText, model.blocks[0].text)
|
|
require.False(t, model.accumulator.isPending())
|
|
})
|
|
}
|
|
t.Run("StreamEventErrorShowsInView", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12})
|
|
model.loading = false
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model.messages = []codersdk.ChatMessage{
|
|
testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}),
|
|
}
|
|
model.rebuildBlocks()
|
|
updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("websocket closed")})
|
|
view := plainText(updated.View())
|
|
require.Contains(t, view, chat.Title)
|
|
require.Contains(t, view, "existing response")
|
|
require.Contains(t, view, "websocket closed")
|
|
require.Contains(t, view, "Type a message")
|
|
require.Contains(t, view, "esc: back")
|
|
})
|
|
t.Run("LoadingViewKeepsChatChrome", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.loading = true
|
|
model.metadataResolved = false
|
|
model.historyResolved = false
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 120, Height: 12})
|
|
view := plainText(model.View())
|
|
require.Contains(t, view, "New Chat (draft)")
|
|
require.Contains(t, view, "Loading chat...")
|
|
require.Contains(t, view, "Type a message")
|
|
require.Contains(t, view, "esc: back")
|
|
})
|
|
t.Run("MultipleStreamErrorsOnlyShowLatest", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 12})
|
|
model.loading = false
|
|
updated := mustChatViewUpdate(t, model, chatStreamEventMsg{err: xerrors.New("first error")})
|
|
updated = mustChatViewUpdate(t, updated, chatStreamEventMsg{err: xerrors.New("second error")})
|
|
view := updated.View()
|
|
require.Contains(t, view, "second error")
|
|
require.NotContains(t, view, "first error")
|
|
})
|
|
t.Run("StreamAccumulatorFinalToolCallUpsertsExistingPart", func(t *testing.T) {
|
|
t.Parallel()
|
|
newToolCallDelta := func(toolCallID, toolName, argsDelta string) codersdk.ChatStreamMessagePart {
|
|
return codersdk.ChatStreamMessagePart{
|
|
Role: codersdk.ChatMessageRoleAssistant,
|
|
Part: codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
ArgsDelta: argsDelta,
|
|
},
|
|
}
|
|
}
|
|
newFinalToolCall := func(toolCallID, toolName, args string) codersdk.ChatStreamMessagePart {
|
|
return codersdk.ChatStreamMessagePart{
|
|
Role: codersdk.ChatMessageRoleAssistant,
|
|
Part: codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Args: json.RawMessage(args),
|
|
},
|
|
}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
seed []codersdk.ChatStreamMessagePart
|
|
final codersdk.ChatStreamMessagePart
|
|
want []codersdk.ChatMessagePart
|
|
}{
|
|
{
|
|
name: "ReplaceExistingToolCall",
|
|
seed: []codersdk.ChatStreamMessagePart{
|
|
newToolCallDelta("tc-1", "search", `{"q":"hel`),
|
|
},
|
|
final: newFinalToolCall("tc-1", "search", `{"q":"hello"}`),
|
|
want: []codersdk.ChatMessagePart{{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "tc-1",
|
|
ToolName: "search",
|
|
Args: json.RawMessage(`{"q":"hello"}`),
|
|
}},
|
|
},
|
|
{
|
|
name: "AppendNewToolCallID",
|
|
seed: []codersdk.ChatStreamMessagePart{
|
|
newToolCallDelta("tc-1", "search", `{"q":"hel`),
|
|
},
|
|
final: newFinalToolCall("tc-2", "lookup", `{"id":"42"}`),
|
|
want: []codersdk.ChatMessagePart{
|
|
{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "tc-1",
|
|
ToolName: "search",
|
|
Args: json.RawMessage(`{"q":"hel`),
|
|
},
|
|
{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: "tc-2",
|
|
ToolName: "lookup",
|
|
Args: json.RawMessage(`{"id":"42"}`),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var accumulator streamAccumulator
|
|
for _, delta := range tt.seed {
|
|
accumulator.applyDelta(delta)
|
|
}
|
|
accumulator.applyDelta(tt.final)
|
|
require.True(t, accumulator.pending)
|
|
require.Equal(t, codersdk.ChatMessageRoleAssistant, accumulator.role)
|
|
require.Equal(t, tt.want, accumulator.parts)
|
|
})
|
|
}
|
|
})
|
|
t.Run("MessageDeduplication", func(t *testing.T) {
|
|
t.Parallel()
|
|
toolRoundTripParts := []codersdk.ChatMessagePart{
|
|
{Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: "tool-1", ToolName: "search", Args: json.RawMessage(`{"q":"hello"}`)},
|
|
{Type: codersdk.ChatMessagePartTypeToolResult, ToolCallID: "tool-1", ToolName: "search", Result: json.RawMessage(`{"ok":true}`)},
|
|
}
|
|
model := newTestChatViewModel(nil)
|
|
model.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleAssistant, toolRoundTripParts...)}
|
|
model.accumulator = streamAccumulator{pending: true, role: codersdk.ChatMessageRoleAssistant, parts: toolRoundTripParts}
|
|
model.rebuildBlocks()
|
|
require.Len(t, model.messages, 1)
|
|
require.Len(t, model.blocks, 1)
|
|
require.True(t, model.accumulator.isPending())
|
|
require.Equal(t, blockToolResult, model.blocks[0].kind)
|
|
require.Equal(t, "tool-1", model.blocks[0].toolID)
|
|
})
|
|
t.Run("StaleStreamEventsAreDroppedByGeneration", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model.setChat(chat)
|
|
model.chatGeneration = 1
|
|
model.streaming = true
|
|
staleMsg := chatStreamEventMsg{
|
|
chatID: uuid.New(),
|
|
event: testTextPartEvent("should be ignored"),
|
|
}
|
|
updated, cmd := model.Update(staleMsg)
|
|
require.Nil(t, cmd)
|
|
require.Empty(t, updated.accumulator.parts)
|
|
require.Equal(t, model.chatStatus, updated.chatStatus)
|
|
require.Equal(t, model.blocks, updated.blocks)
|
|
})
|
|
t.Run("IntentionalCloseSkipsReconnect", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model.setChat(chat)
|
|
model.streaming = true
|
|
model.stopStream()
|
|
require.True(t, model.intentionalClose)
|
|
eofMsg := chatStreamEventMsg{
|
|
chatID: chat.ID,
|
|
err: io.EOF,
|
|
}
|
|
updated, cmd := model.Update(eofMsg)
|
|
require.Nil(t, cmd)
|
|
require.False(t, updated.streaming)
|
|
require.False(t, updated.reconnecting)
|
|
require.False(t, updated.intentionalClose)
|
|
require.NoError(t, updated.err)
|
|
})
|
|
t.Run("EOFStopsStreamingAndAttemptsReconnectWhenInterruptible", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(failingExperimentalClient())
|
|
chat := testChat(codersdk.ChatStatusPending)
|
|
model.setChat(chat)
|
|
model.streaming = true
|
|
updated, cmd := model.Update(chatStreamEventMsg{chatID: chat.ID, err: io.EOF})
|
|
require.NotNil(t, cmd)
|
|
require.False(t, updated.streaming)
|
|
require.True(t, updated.reconnecting)
|
|
})
|
|
t.Run("MessageEventsDeduplicateByID", func(t *testing.T) {
|
|
t.Parallel()
|
|
message := testMessage(11, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hello"})
|
|
model, _ := applyStream(newTestChatViewModel(nil), messageEvent(message))
|
|
model, cmd := applyStream(model, messageEvent(message))
|
|
require.Nil(t, cmd)
|
|
require.Len(t, model.messages, 1)
|
|
})
|
|
})
|
|
t.Run("ChatView/Sending", func(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("DeliveredMessageIsAddedAndBlocksRebuilt", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
message := testMessage(21, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"})
|
|
|
|
updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}})
|
|
require.Nil(t, cmd)
|
|
require.Len(t, updated.messages, 1)
|
|
require.Len(t, updated.blocks, 1)
|
|
require.Equal(t, "delivered", updated.blocks[0].text)
|
|
})
|
|
|
|
t.Run("DisconnectedSendRestartsStream", func(t *testing.T) {
|
|
t.Parallel()
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
message := testMessage(22, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "sent"})
|
|
streamQueryCh := make(chan string, 1)
|
|
streamErrCh := make(chan error, 1)
|
|
client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) {
|
|
wantPath := fmt.Sprintf("/api/experimental/chats/%s/stream", chat.ID)
|
|
if req.URL.Path != wantPath {
|
|
select {
|
|
case streamErrCh <- xerrors.Errorf("stream path %q, want %q", req.URL.Path, wantPath):
|
|
default:
|
|
}
|
|
rw.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
conn, err := websocket.Accept(rw, req, nil)
|
|
if err != nil {
|
|
select {
|
|
case streamErrCh <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
|
|
select {
|
|
case streamQueryCh <- req.URL.RawQuery:
|
|
default:
|
|
}
|
|
})
|
|
|
|
model := newTestChatViewModel(client)
|
|
model.setChat(chat)
|
|
|
|
updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}})
|
|
defer updated.stopStream()
|
|
require.NotNil(t, cmd)
|
|
require.True(t, updated.streaming)
|
|
require.NotNil(t, updated.streamCloser)
|
|
require.NotNil(t, updated.streamEventCh)
|
|
require.Len(t, updated.messages, 1)
|
|
|
|
select {
|
|
case err := <-streamErrCh:
|
|
require.NoError(t, err)
|
|
case query := <-streamQueryCh:
|
|
require.Equal(t, fmt.Sprintf("after_id=%d", message.ID), query)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for restarted chat stream connection")
|
|
}
|
|
})
|
|
|
|
t.Run("ActiveStreamDoesNotReconnectOnSend", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
message := testMessage(24, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "delivered"})
|
|
model.setChat(chat)
|
|
model.streaming = true
|
|
|
|
updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{Message: &message}})
|
|
require.Nil(t, cmd)
|
|
require.True(t, updated.streaming)
|
|
require.Len(t, updated.messages, 1)
|
|
})
|
|
|
|
t.Run("QueuedResponseUpdatesQueuedMessages", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
queued := testQueuedMessage(22, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "queued"})
|
|
|
|
updated, cmd := model.Update(messageSentMsg{resp: codersdk.CreateChatMessageResponse{
|
|
Queued: true,
|
|
QueuedMessage: &queued,
|
|
}})
|
|
require.Nil(t, cmd)
|
|
require.Len(t, updated.queuedMessages, 1)
|
|
require.Len(t, updated.blocks, 1)
|
|
require.Equal(t, "queued", updated.blocks[0].text)
|
|
})
|
|
|
|
t.Run("SendCreateErrorHandling", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name, composerText, wantComposer string
|
|
draft, setChat, useSend, typeNewInput, wantRetry bool
|
|
errMsg tea.Msg
|
|
}{
|
|
{name: "send preserves existing composer text", composerText: "keep me", wantComposer: "keep me", errMsg: messageSentMsg{err: xerrors.New("send failed")}},
|
|
{name: "send restores pending text", composerText: "my message", wantComposer: "my message", setChat: true, useSend: true, errMsg: messageSentMsg{err: xerrors.New("network error")}},
|
|
{name: "create restores pending text", composerText: "first message", wantComposer: "first message", draft: true, useSend: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}},
|
|
{name: "create error allows retry", composerText: "keep draft", wantComposer: "keep draft", draft: true, wantRetry: true, errMsg: chatCreatedMsg{err: xerrors.New("create failed")}},
|
|
{name: "messageSent error does not overwrite newer input", composerText: "original", wantComposer: "new input", setChat: true, useSend: true, typeNewInput: true, errMsg: messageSentMsg{err: xerrors.New("fail")}},
|
|
{name: "chatCreated error does not overwrite newer input", composerText: "original", wantComposer: "new input", draft: true, useSend: true, typeNewInput: true, errMsg: chatCreatedMsg{err: xerrors.New("fail")}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var client *codersdk.ExperimentalClient
|
|
if tt.wantRetry {
|
|
client = failingExperimentalClient()
|
|
}
|
|
model := newTestChatViewModel(client)
|
|
model.loading = false
|
|
if tt.draft {
|
|
model.draft = true
|
|
}
|
|
if tt.setChat {
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.setChat(chat)
|
|
}
|
|
model.composer.SetValue(tt.composerText)
|
|
if tt.useSend {
|
|
model, _ = model.sendMessage()
|
|
require.Empty(t, model.composer.Value())
|
|
require.Equal(t, tt.composerText, model.pendingComposerText)
|
|
if tt.draft {
|
|
require.True(t, model.creatingChat)
|
|
}
|
|
}
|
|
if tt.typeNewInput {
|
|
model.composer.SetValue("new input")
|
|
}
|
|
updated, cmd := model.Update(tt.errMsg)
|
|
require.Nil(t, cmd)
|
|
model = updated
|
|
require.Error(t, model.err)
|
|
switch msg := tt.errMsg.(type) {
|
|
case messageSentMsg:
|
|
require.Equal(t, msg.err.Error(), model.err.Error())
|
|
case chatCreatedMsg:
|
|
require.Equal(t, msg.err.Error(), model.err.Error())
|
|
}
|
|
require.Equal(t, tt.wantComposer, model.composer.Value())
|
|
if tt.wantRetry {
|
|
require.True(t, model.draft)
|
|
require.Contains(t, model.View(), "create failed")
|
|
model.composer.SetValue("retry draft")
|
|
retried, retryCmd := model.sendMessage()
|
|
require.NotNil(t, retryCmd)
|
|
require.True(t, retried.draft)
|
|
require.Empty(t, retried.composer.Value())
|
|
_, ok := mustMsg(t, retryCmd).(chatCreatedMsg)
|
|
require.True(t, ok)
|
|
}
|
|
if tt.draft && tt.useSend {
|
|
require.False(t, model.creatingChat)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("DuplicateDraftCreateIsIgnored", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.draft = true
|
|
model.loading = false
|
|
model.creatingChat = true
|
|
model.composer.SetValue("hello")
|
|
|
|
updated, cmd := model.sendMessage()
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, "hello", updated.composer.Value())
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/ModelOverrideMapsCanonicalModelID", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
draft bool
|
|
}{
|
|
{name: "DraftCreateReturnsChatCreatedMsg", draft: true},
|
|
{name: "SendMessageReturnsMessageSentMsg", draft: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
modelConfigID := uuid.New()
|
|
organizationID := uuid.New()
|
|
modelOverride := "provider:model"
|
|
createdChat := testChat(codersdk.ChatStatusWaiting)
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
var createReq *codersdk.CreateChatRequest
|
|
var messageReq *codersdk.CreateChatMessageRequest
|
|
client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case req.Method == http.MethodGet && req.URL.Path == "/api/experimental/chats/model-configs":
|
|
require.NoError(t, json.NewEncoder(rw).Encode([]codersdk.ChatModelConfig{{ID: modelConfigID, Provider: "provider", Model: "model"}}))
|
|
case req.Method == http.MethodPost && req.URL.Path == "/api/experimental/chats":
|
|
createReq = new(codersdk.CreateChatRequest)
|
|
require.NoError(t, json.NewDecoder(req.Body).Decode(createReq))
|
|
rw.WriteHeader(http.StatusCreated)
|
|
require.NoError(t, json.NewEncoder(rw).Encode(createdChat))
|
|
case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID):
|
|
messageReq = new(codersdk.CreateChatMessageRequest)
|
|
require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq))
|
|
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{}))
|
|
default:
|
|
t.Fatalf("unexpected %s %s", req.Method, req.URL.Path)
|
|
}
|
|
})
|
|
model := newTestChatViewModel(client)
|
|
if tt.draft {
|
|
model.draft = true
|
|
} else {
|
|
model.setChat(chat)
|
|
}
|
|
model.loading = false
|
|
model.modelOverride = &modelOverride
|
|
model.organizationID = organizationID
|
|
model.planMode = codersdk.ChatPlanModePlan
|
|
model.composer.SetValue("hello")
|
|
updated, cmd := model.sendMessage()
|
|
require.NotNil(t, cmd)
|
|
require.Empty(t, updated.composer.Value())
|
|
if tt.draft {
|
|
msg, ok := mustMsg(t, cmd).(chatCreatedMsg)
|
|
require.True(t, ok)
|
|
require.NoError(t, msg.err)
|
|
require.NotNil(t, createReq)
|
|
require.Equal(t, organizationID, createReq.OrganizationID)
|
|
require.NotNil(t, createReq.ModelConfigID)
|
|
require.Equal(t, modelConfigID, *createReq.ModelConfigID)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, createReq.PlanMode)
|
|
require.Equal(t, createdChat.ID, msg.chat.ID)
|
|
return
|
|
}
|
|
msg, ok := mustMsg(t, cmd).(messageSentMsg)
|
|
require.True(t, ok)
|
|
require.NoError(t, msg.err)
|
|
require.NotNil(t, messageReq)
|
|
require.NotNil(t, messageReq.ModelConfigID)
|
|
require.Equal(t, modelConfigID, *messageReq.ModelConfigID)
|
|
require.NotNil(t, messageReq.PlanMode)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, *messageReq.PlanMode)
|
|
})
|
|
}
|
|
})
|
|
t.Run("ChatView/SendMessageExplicitlyClearsPlanMode", func(t *testing.T) {
|
|
t.Parallel()
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
var messageReq *codersdk.CreateChatMessageRequest
|
|
client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case req.Method == http.MethodPost && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s/messages", chat.ID):
|
|
messageReq = new(codersdk.CreateChatMessageRequest)
|
|
require.NoError(t, json.NewDecoder(req.Body).Decode(messageReq))
|
|
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.CreateChatMessageResponse{}))
|
|
default:
|
|
t.Fatalf("unexpected %s %s", req.Method, req.URL.Path)
|
|
}
|
|
})
|
|
model := newTestChatViewModel(client)
|
|
model.setChat(chat)
|
|
model.loading = false
|
|
model.composer.SetValue("hello")
|
|
|
|
updated, cmd := model.sendMessage()
|
|
require.NotNil(t, cmd)
|
|
require.Empty(t, updated.composer.Value())
|
|
|
|
msg, ok := mustMsg(t, cmd).(messageSentMsg)
|
|
require.True(t, ok)
|
|
require.NoError(t, msg.err)
|
|
require.NotNil(t, messageReq)
|
|
require.NotNil(t, messageReq.PlanMode)
|
|
require.Empty(t, *messageReq.PlanMode)
|
|
})
|
|
|
|
t.Run("ChatView/ChatCreatedPromotesDraft", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.draft = true
|
|
model.streaming = true
|
|
chat := testChat(codersdk.ChatStatusWaiting)
|
|
|
|
updated, cmd := model.Update(chatCreatedMsg{chat: chat})
|
|
require.Nil(t, cmd)
|
|
require.NotNil(t, updated.chat)
|
|
require.Equal(t, chat.ID, updated.chat.ID)
|
|
require.False(t, updated.draft)
|
|
require.Equal(t, codersdk.ChatStatusWaiting, updated.chatStatus)
|
|
require.Nil(t, updated.err)
|
|
})
|
|
|
|
t.Run("ChatView/Interrupts", func(t *testing.T) {
|
|
t.Parallel()
|
|
newInterruptModel := func(status codersdk.ChatStatus) chatViewModel {
|
|
model := newTestChatViewModel(failingExperimentalClient())
|
|
model.setChat(testChat(status))
|
|
return model
|
|
}
|
|
|
|
t.Run("InterruptedChatClearsInterruptingAndUpdatesStatus", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.interrupting = true
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
|
|
updated, cmd := model.Update(chatInterruptedMsg{chat: chat})
|
|
require.Nil(t, cmd)
|
|
require.False(t, updated.interrupting)
|
|
require.Equal(t, chat.ID, updated.chat.ID)
|
|
require.Equal(t, codersdk.ChatStatusCompleted, updated.chatStatus)
|
|
})
|
|
|
|
t.Run("InterruptedChatErrorClearsInterruptingAndSetsErr", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.interrupting = true
|
|
|
|
updated, cmd := model.Update(chatInterruptedMsg{err: xerrors.New("interrupt failed")})
|
|
require.Nil(t, cmd)
|
|
require.False(t, updated.interrupting)
|
|
require.Equal(t, "interrupt failed", updated.err.Error())
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
chatStatus codersdk.ChatStatus
|
|
alreadyInterrupting bool
|
|
expectedInterrupting bool
|
|
}{
|
|
{name: "DoubleInterrupt", chatStatus: codersdk.ChatStatusRunning, alreadyInterrupting: true, expectedInterrupting: true},
|
|
{name: "IdleChat", chatStatus: codersdk.ChatStatusCompleted},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run("CtrlXNoOpCases/"+tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newInterruptModel(tt.chatStatus)
|
|
model.interrupting = tt.alreadyInterrupting
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, tt.expectedInterrupting, updated.interrupting)
|
|
})
|
|
}
|
|
|
|
t.Run("CtrlXInterruptsRunningChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newInterruptModel(codersdk.ChatStatusRunning)
|
|
require.True(t, model.composerFocused)
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlX})
|
|
require.NotNil(t, cmd)
|
|
require.True(t, updated.interrupting)
|
|
require.True(t, updated.composerFocused)
|
|
})
|
|
|
|
t.Run("TabKeepsFocusSwitchBehaviorWhileRunningChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newInterruptModel(codersdk.ChatStatusRunning)
|
|
require.True(t, model.composerFocused)
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
require.Nil(t, cmd)
|
|
require.False(t, updated.interrupting)
|
|
require.False(t, updated.composerFocused)
|
|
})
|
|
|
|
t.Run("ViewShowsCtrlXInterruptHelp", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12})
|
|
model.setChat(testChat(codersdk.ChatStatusRunning))
|
|
model.loading = false
|
|
|
|
view := plainText(model.View())
|
|
require.Contains(t, view, "ctrl+x: interrupt")
|
|
require.NotContains(t, view, "ctrl+i: interrupt")
|
|
})
|
|
|
|
t.Run("ViewShowsPlanModeBadge", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12})
|
|
model.loading = false
|
|
execView := model.View()
|
|
require.Contains(t, plainText(execView), "mode: exec")
|
|
require.Contains(t, execView, model.styles.modeBadgeExec.Render("exec"))
|
|
|
|
model.planMode = codersdk.ChatPlanModePlan
|
|
planView := model.View()
|
|
view := plainText(planView)
|
|
require.Contains(t, view, "mode: plan")
|
|
require.Contains(t, planView, model.styles.modeBadgePlan.Render("plan"))
|
|
require.Contains(t, view, "shift+tab: switch mode")
|
|
})
|
|
|
|
t.Run("PlanModeUpdateErrorRollsBackLocalModeAndShowsBanner", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
current codersdk.ChatPlanMode
|
|
want codersdk.ChatPlanMode
|
|
}{
|
|
{name: "BackToCode", current: codersdk.ChatPlanModePlan, want: ""},
|
|
{name: "BackToPlan", current: "", want: codersdk.ChatPlanModePlan},
|
|
} {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model, _ = model.Update(tea.WindowSizeMsg{Width: 140, Height: 12})
|
|
model.setChat(testChat(codersdk.ChatStatusCompleted))
|
|
model.planMode = tt.current
|
|
|
|
updated, cmd := model.Update(chatPlanModeUpdatedMsg{err: xerrors.New("update failed")})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, tt.want, updated.planMode)
|
|
require.EqualError(t, updated.err, "update failed")
|
|
require.Contains(t, plainText(updated.View()), "update failed")
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/Keyboard", func(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("KeyboardShortcutRouting", func(t *testing.T) {
|
|
t.Parallel()
|
|
isToggleModelPicker := func(msg tea.Msg) bool { _, ok := msg.(toggleModelPickerMsg); return ok }
|
|
isToggleDiffDrawer := func(msg tea.Msg) bool { _, ok := msg.(toggleDiffDrawerMsg); return ok }
|
|
tests := []struct {
|
|
name string
|
|
key tea.KeyType
|
|
composerFocused bool
|
|
composerValue string
|
|
assert func(tea.Msg) bool
|
|
}{
|
|
{name: "CtrlP/Focused", key: tea.KeyCtrlP, composerFocused: true, composerValue: "draft", assert: isToggleModelPicker},
|
|
{name: "CtrlP/Unfocused", key: tea.KeyCtrlP, assert: isToggleModelPicker},
|
|
{name: "CtrlD/Focused", key: tea.KeyCtrlD, composerFocused: true, composerValue: "draft", assert: isToggleDiffDrawer},
|
|
{name: "CtrlD/Unfocused", key: tea.KeyCtrlD, assert: isToggleDiffDrawer},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.composerFocused = tt.composerFocused
|
|
model.composer.SetValue(tt.composerValue)
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tt.key})
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, tt.composerFocused, updated.composerFocused)
|
|
require.Equal(t, tt.composerValue, updated.composer.Value())
|
|
require.True(t, tt.assert(mustMsg(t, cmd)))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("ShiftTabTogglesPlanMode", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.composer.SetValue("draft")
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode)
|
|
require.Equal(t, "draft", updated.composer.Value())
|
|
|
|
updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
|
require.Nil(t, cmd)
|
|
require.Empty(t, updated.planMode)
|
|
require.Equal(t, "draft", updated.composer.Value())
|
|
})
|
|
|
|
t.Run("TabOnlySwitchesComposerFocus", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.planMode = codersdk.ChatPlanModePlan
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode)
|
|
require.False(t, updated.composerFocused)
|
|
})
|
|
|
|
t.Run("ShiftTabDraftChatDefersPlanModePersistence", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.draft = true
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
|
require.Nil(t, cmd)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode)
|
|
})
|
|
|
|
t.Run("ShiftTabExistingChatUpdatesPlanModeImmediately", func(t *testing.T) {
|
|
t.Parallel()
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
var requests []codersdk.UpdateChatRequest
|
|
client := newTestExperimentalClient(t, func(rw http.ResponseWriter, req *http.Request) {
|
|
switch {
|
|
case req.Method == http.MethodPatch && req.URL.Path == fmt.Sprintf("/api/experimental/chats/%s", chat.ID):
|
|
var updateReq codersdk.UpdateChatRequest
|
|
require.NoError(t, json.NewDecoder(req.Body).Decode(&updateReq))
|
|
requests = append(requests, updateReq)
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
t.Fatalf("unexpected %s %s", req.Method, req.URL.Path)
|
|
}
|
|
})
|
|
model := newTestChatViewModel(client)
|
|
model.setChat(chat)
|
|
|
|
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, updated.planMode)
|
|
|
|
msg, ok := mustMsg(t, cmd).(chatPlanModeUpdatedMsg)
|
|
require.True(t, ok)
|
|
require.NoError(t, msg.err)
|
|
require.Len(t, requests, 1)
|
|
require.NotNil(t, requests[0].PlanMode)
|
|
require.Equal(t, codersdk.ChatPlanModePlan, *requests[0].PlanMode)
|
|
|
|
updated, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
|
require.NotNil(t, cmd)
|
|
require.Empty(t, updated.planMode)
|
|
|
|
msg, ok = mustMsg(t, cmd).(chatPlanModeUpdatedMsg)
|
|
require.True(t, ok)
|
|
require.NoError(t, msg.err)
|
|
require.Len(t, requests, 2)
|
|
require.NotNil(t, requests[1].PlanMode)
|
|
require.Empty(t, *requests[1].PlanMode)
|
|
})
|
|
|
|
t.Run("CtrlPFromListViewDoesNotOpenModelPicker", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewList
|
|
model.list.loading = false
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/ViewportScrolling", func(t *testing.T) {
|
|
t.Parallel()
|
|
applyWindowSize := func(t *testing.T, model expChatsTUIModel, width int, height int) expChatsTUIModel {
|
|
t.Helper()
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: width, Height: height})
|
|
return mustTUIModel(t, updatedModel, cmd)
|
|
}
|
|
scrollableModel := func(t *testing.T, keys ...tea.KeyType) chatViewModel {
|
|
t.Helper()
|
|
model := newTestChatViewModel(nil)
|
|
model.loading = false
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 20})
|
|
model.messages = overflowingMessages(24)
|
|
model.rebuildBlocks()
|
|
model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: tea.KeyTab})
|
|
require.False(t, model.composerFocused)
|
|
require.True(t, model.autoFollow)
|
|
require.True(t, model.viewport.AtBottom())
|
|
require.Greater(t, model.viewport.YOffset, 0)
|
|
for _, key := range keys {
|
|
model = mustChatViewUpdate(t, model, tea.KeyMsg{Type: key})
|
|
}
|
|
return model
|
|
}
|
|
streamMessage := func(id int64) chatStreamEventMsg {
|
|
message := testMessage(
|
|
id,
|
|
codersdk.ChatMessageRoleAssistant,
|
|
codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: strings.Repeat("new content ", 24)},
|
|
)
|
|
return chatStreamEventMsg{event: codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessage, Message: &message}}
|
|
}
|
|
updateView := func(model chatViewModel, msg tea.Msg) chatViewModel {
|
|
updated, _ := model.Update(msg)
|
|
return updated
|
|
}
|
|
t.Run("ViewportHeights", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
height int
|
|
viewChat bool
|
|
messageCount int
|
|
wantChatHeight int
|
|
wantViewportHeight int
|
|
}{
|
|
{"Standard", 40, false, 0, 39, 33},
|
|
{"MinimumZero", 5, false, 0, -1, 0},
|
|
{"ViewFitsTerminal", 40, true, 24, -1, -1},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := applyWindowSize(t, newTestTUIModel(), 80, tt.height)
|
|
if tt.viewChat {
|
|
model.currentView = viewChat
|
|
model.chat.loading = false
|
|
model.chat, _ = model.chat.Update(model.childWindowSizeMsg())
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat, model.chat.chatStatus = &chat, chat.Status
|
|
model.chat.messages = overflowingMessages(tt.messageCount)
|
|
model.chat.rebuildBlocks()
|
|
require.LessOrEqual(t, strings.Count(model.View(), "\n")+1, tt.height)
|
|
return
|
|
}
|
|
if tt.wantChatHeight >= 0 {
|
|
require.Equal(t, tt.wantChatHeight, model.chat.height)
|
|
}
|
|
if tt.wantViewportHeight >= 0 {
|
|
require.Equal(t, tt.wantViewportHeight, model.chat.viewport.Height)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
t.Run("WrappedComposerFitsTerminal", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := applyWindowSize(t, newTestTUIModel(), 40, 18)
|
|
model.currentView = viewChat
|
|
model.chat.loading = false
|
|
model.chat, _ = model.chat.Update(model.childWindowSizeMsg())
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.chat = &chat
|
|
model.chat.chatStatus = chat.Status
|
|
model.chat.messages = overflowingMessages(18)
|
|
model.chat.rebuildBlocks()
|
|
initialViewportHeight := model.chat.viewport.Height
|
|
model.chat.composer.SetValue(strings.Repeat("wrapped input ", 14))
|
|
model.chat.recalcViewportHeight()
|
|
model.chat.syncViewportContent()
|
|
view := plainText(model.View())
|
|
lines := strings.Split(view, "\n")
|
|
require.LessOrEqual(t, model.chat.viewport.Height, initialViewportHeight)
|
|
require.LessOrEqual(t, len(lines), 18)
|
|
require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1]))
|
|
})
|
|
t.Run("ViewShowsSingleStatusBarAndComposerDivider", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.loading = false
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 60, Height: 14})
|
|
chat := testChat(codersdk.ChatStatusWaiting)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model.messages = []codersdk.ChatMessage{
|
|
testMessage(1, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "existing response"}),
|
|
}
|
|
model.rebuildBlocks()
|
|
view := plainText(model.View())
|
|
require.NotContains(t, view, "Status: waiting")
|
|
require.Equal(t, 1, strings.Count(view, "waiting"))
|
|
lines := strings.Split(view, "\n")
|
|
composerLine := -1
|
|
for i, line := range lines {
|
|
if strings.Contains(line, "> ") {
|
|
composerLine = i
|
|
break
|
|
}
|
|
}
|
|
require.Greater(t, composerLine, 1)
|
|
require.Contains(t, lines[composerLine-1], "────")
|
|
})
|
|
t.Run("ScrollNavigation", func(t *testing.T) {
|
|
t.Parallel()
|
|
type yOffsetCheck int
|
|
const (
|
|
ySkip yOffsetCheck = iota
|
|
yLess
|
|
yGreater
|
|
yEqual
|
|
yHalfUp
|
|
yHalfDown
|
|
)
|
|
const skip = -1
|
|
tests := []struct {
|
|
name string
|
|
preKeys []tea.KeyType
|
|
key tea.KeyType
|
|
yCheck yOffsetCheck
|
|
wantAutoFollow int
|
|
wantBeforeBottom int
|
|
wantAfterBottom int
|
|
wantBeforeYOffset int
|
|
wantAfterYOffset int
|
|
}{
|
|
{"ScrollUpDecreasesYOffset", nil, tea.KeyUp, yLess, 0, skip, skip, skip, skip},
|
|
{"ScrollDownIncreasesYOffset", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, skip, skip, skip, skip, skip},
|
|
{"ScrollUpAtTopIsNoOp", []tea.KeyType{tea.KeyHome}, tea.KeyUp, yEqual, skip, skip, skip, 0, skip},
|
|
{"ScrollDownAtBottomReEnablesAutoFollow", []tea.KeyType{tea.KeyUp}, tea.KeyDown, yGreater, 1, 0, 1, skip, skip},
|
|
{"PageUpScrollsHalfViewport", nil, tea.KeyPgUp, yHalfUp, 0, skip, skip, skip, skip},
|
|
{"PageDownScrollsHalfViewport", []tea.KeyType{tea.KeyPgUp}, tea.KeyPgDown, yHalfDown, skip, skip, skip, skip, skip},
|
|
{"HomeJumpsToTop", nil, tea.KeyHome, ySkip, 0, skip, skip, skip, 0},
|
|
{"EndJumpsToBottomAndEnablesAutoFollow", []tea.KeyType{tea.KeyHome}, tea.KeyEnd, ySkip, 1, 0, 1, skip, skip},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
before := scrollableModel(t, tt.preKeys...)
|
|
after := mustChatViewUpdate(t, before, tea.KeyMsg{Type: tt.key})
|
|
assertScrollNavigationCase(t, before, after, tt.wantBeforeYOffset, tt.wantAfterYOffset, tt.wantAutoFollow, tt.wantBeforeBottom, tt.wantAfterBottom, int(tt.yCheck))
|
|
})
|
|
}
|
|
})
|
|
t.Run("AutoFollowOnContentUpdates", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
preKeys []tea.KeyType
|
|
messageID int64
|
|
wantAutoFollow bool
|
|
wantAtBottom bool
|
|
wantPreserveYOffset bool
|
|
}{
|
|
{"SetContentPreservesScrollPosition", []tea.KeyType{tea.KeyUp}, 1001, false, false, true},
|
|
{"NewMessageAutoFollowsWhenAtBottom", nil, 1002, true, true, false},
|
|
{"NewMessageDoesNotAutoFollowWhenScrolledUp", []tea.KeyType{tea.KeyUp}, 1003, false, false, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
before := scrollableModel(t, tt.preKeys...)
|
|
after := updateView(before, streamMessage(tt.messageID))
|
|
require.Equal(t, tt.wantAutoFollow, after.autoFollow)
|
|
require.Equal(t, tt.wantAtBottom, after.viewport.AtBottom())
|
|
if tt.wantPreserveYOffset {
|
|
require.Equal(t, before.viewport.YOffset, after.viewport.YOffset)
|
|
return
|
|
}
|
|
require.GreaterOrEqual(t, after.viewport.YOffset, before.viewport.YOffset)
|
|
})
|
|
}
|
|
})
|
|
t.Run("StreamingAutoFollows", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10})
|
|
model = updateView(model, chatHistoryMsg{messages: overflowingMessages(10)})
|
|
before := model.viewport.YOffset
|
|
model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("hello world ", 20))})
|
|
model = updateView(model, chatStreamEventMsg{event: testTextPartEvent(strings.Repeat("more text ", 20))})
|
|
require.True(t, model.autoFollow)
|
|
require.True(t, model.viewport.AtBottom())
|
|
require.GreaterOrEqual(t, model.viewport.YOffset, before)
|
|
})
|
|
})
|
|
t.Run("ChatView/StatePersistence", func(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("ComposerTextSurvivesOverlayToggle", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.chat.loading = false
|
|
catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "provider",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{ID: uuid.New().String(), Provider: "provider", Model: "model-a", DisplayName: "Model A"}},
|
|
}}}
|
|
model.catalog = &catalog
|
|
model.chat.modelPickerFlat = catalog.Providers[0].Models
|
|
model.chat.composer.SetValue("keep this draft")
|
|
updatedModel, cmd := model.Update(toggleModelPickerMsg{})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, "keep this draft", model.chat.composer.Value())
|
|
updatedModel, cmd = model.Update(toggleModelPickerMsg{})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, "keep this draft", model.chat.composer.Value())
|
|
})
|
|
|
|
t.Run("ComposerTextSurvivesFocusSwitch", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.composer.SetValue("keep this draft")
|
|
|
|
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
require.False(t, updated.composerFocused)
|
|
require.Equal(t, "keep this draft", updated.composer.Value())
|
|
|
|
updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
require.True(t, updated.composerFocused)
|
|
require.Equal(t, "keep this draft", updated.composer.Value())
|
|
})
|
|
|
|
t.Run("ViewportScrollSurvivesOverlayToggle", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.chat.loading = false
|
|
updatedModel, cmd := model.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat.setChat(chat)
|
|
model.chat.messages = overflowingMessages(10)
|
|
diff := codersdk.ChatDiffContents{ChatID: chat.ID, Diff: "diff --git a/file b/file"}
|
|
model.chat.diffContents = &diff
|
|
model.chat.rebuildBlocks()
|
|
model.chat.composerFocused = false
|
|
(&model.chat).syncViewportContent()
|
|
model.chat.viewport.GotoBottom()
|
|
updatedModel, cmd = model.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.False(t, model.chat.viewport.AtBottom())
|
|
yBefore := model.chat.viewport.YOffset
|
|
updatedModel, cmd = model.Update(toggleDiffDrawerMsg{})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayDiffDrawer, model.overlay)
|
|
require.Equal(t, yBefore, model.chat.viewport.YOffset)
|
|
|
|
updatedModel, cmd = model.Update(toggleDiffDrawerMsg{})
|
|
model = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, model.overlay)
|
|
require.Equal(t, yBefore, model.chat.viewport.YOffset)
|
|
})
|
|
|
|
t.Run("SelectedModelSurvivesPickerReopen", func(t *testing.T) {
|
|
t.Parallel()
|
|
firstModelID := "provider:model-a"
|
|
secondModelID := "provider:model-b"
|
|
catalog := codersdk.ChatModelsResponse{
|
|
Providers: []codersdk.ChatModelProvider{{
|
|
Provider: "provider",
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{
|
|
{
|
|
ID: firstModelID,
|
|
Provider: "provider",
|
|
Model: "model-a",
|
|
DisplayName: "Model A",
|
|
},
|
|
{
|
|
ID: secondModelID,
|
|
Provider: "provider",
|
|
Model: "model-b",
|
|
DisplayName: "Model B",
|
|
},
|
|
},
|
|
}},
|
|
}
|
|
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
|
|
updatedModel, cmd := model.Update(modelsListedMsg{catalog: catalog})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, 1, updated.chat.modelPickerCursor)
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.NotNil(t, updated.chat.modelOverride)
|
|
require.NotNil(t, updated.modelOverride)
|
|
require.Equal(t, secondModelID, *updated.chat.modelOverride)
|
|
require.Equal(t, secondModelID, *updated.modelOverride)
|
|
|
|
updatedModel, cmd = updated.Update(toggleModelPickerMsg{})
|
|
updated = mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayModelPicker, updated.overlay)
|
|
require.Equal(t, 1, updated.chat.modelPickerCursor)
|
|
require.NotNil(t, updated.chat.modelOverride)
|
|
require.Equal(t, secondModelID, *updated.chat.modelOverride)
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/ChatLifecycle", func(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("StreamingChatSwitchBackToList", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.currentView = viewChat
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model.chat.chat = &chat
|
|
model.chat.chatStatus = codersdk.ChatStatusRunning
|
|
model.chat.streaming = true
|
|
model.chat.streamCloser = io.NopCloser(strings.NewReader("stream"))
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.True(t, updated.list.loading)
|
|
require.False(t, updated.chat.streaming)
|
|
require.Nil(t, updated.chat.streamCloser)
|
|
require.NotNil(t, cmd)
|
|
})
|
|
|
|
t.Run("ReOpenSameChatAfterEsc", func(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := uuid.New()
|
|
model := newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
model.width = 100
|
|
model.height = 40
|
|
|
|
updatedModel, cmd := model.Update(openSelectedChatMsg{chatID: chatID})
|
|
updated, cmd := mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.Len(t, mustBatchMsg(t, cmd), 3)
|
|
|
|
openedChat := testChat(codersdk.ChatStatusCompleted)
|
|
openedChat.ID = chatID
|
|
updated.chat.chat = &openedChat
|
|
updated.chat.loading = false
|
|
updated.chat.messages = []codersdk.ChatMessage{testMessage(1, codersdk.ChatMessageRoleUser, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "stale message"})}
|
|
updated.chat.composer.SetValue("stale draft")
|
|
|
|
updatedModel, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated, _ = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewList, updated.currentView)
|
|
require.True(t, updated.list.loading)
|
|
|
|
updatedModel, cmd = updated.Update(openSelectedChatMsg{chatID: chatID})
|
|
updated, cmd = mustTUIModelWithCmd(t, updatedModel, cmd)
|
|
require.Equal(t, viewChat, updated.currentView)
|
|
require.True(t, updated.chat.loading)
|
|
require.Nil(t, updated.chat.chat)
|
|
require.Empty(t, updated.chat.messages)
|
|
require.Empty(t, updated.chat.composer.Value())
|
|
require.Len(t, mustBatchMsg(t, cmd), 3)
|
|
})
|
|
})
|
|
|
|
t.Run("ChatView/TranscriptSync", func(t *testing.T) {
|
|
t.Parallel()
|
|
newTranscriptModel := func() chatViewModel {
|
|
model := newTestChatViewModel(nil)
|
|
model.width = 80
|
|
model.blocks = []chatBlock{
|
|
{kind: blockText, role: codersdk.ChatMessageRoleUser, text: "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi"},
|
|
{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"},
|
|
}
|
|
model.selectedBlock = 0
|
|
model.composerFocused = false
|
|
return model
|
|
}
|
|
|
|
t.Run("TranscriptRefreshRules", func(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
mutate func(m *chatViewModel)
|
|
expectNew bool
|
|
}{
|
|
{"RepeatedViewNoRefresh", func(m *chatViewModel) {}, false},
|
|
{"BlockChange", func(m *chatViewModel) {
|
|
m.blocks = append(m.blocks, chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "new block"})
|
|
}, true},
|
|
{"SelectionChange", func(m *chatViewModel) { m.selectedBlock = 1 }, true},
|
|
{"WidthChange", func(m *chatViewModel) { m.width = 60 }, true},
|
|
{"ComposerFocusChange", func(m *chatViewModel) { m.composerFocused = true }, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTranscriptModel()
|
|
(&model).syncViewportContent()
|
|
firstTranscript := model.lastTranscript
|
|
require.NotEmpty(t, firstTranscript)
|
|
|
|
tt.mutate(&model)
|
|
(&model).syncViewportContent()
|
|
if tt.expectNew {
|
|
require.NotEqual(t, firstTranscript, model.lastTranscript)
|
|
} else {
|
|
require.Equal(t, firstTranscript, model.lastTranscript)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("SpinnerTickOnlyRefreshesWhenVisible", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newTestChatViewModel(nil)
|
|
model = mustChatViewUpdate(t, model, tea.WindowSizeMsg{Width: 80, Height: 10})
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model.messages = overflowingMessages(18)
|
|
model.rebuildBlocks()
|
|
|
|
visibleTranscript := model.lastTranscript
|
|
updated, cmd := model.Update(model.spinner.Tick())
|
|
require.NotNil(t, cmd)
|
|
require.NotEqual(t, visibleTranscript, updated.lastTranscript)
|
|
|
|
updated.viewport.LineUp(3)
|
|
updated.autoFollow = false
|
|
require.False(t, updated.viewport.AtBottom())
|
|
|
|
hiddenTranscript := updated.lastTranscript
|
|
hiddenYOffset := updated.viewport.YOffset
|
|
updated, cmd = updated.Update(updated.spinner.Tick())
|
|
require.NotNil(t, cmd)
|
|
require.Equal(t, hiddenTranscript, updated.lastTranscript)
|
|
require.Equal(t, hiddenYOffset, updated.viewport.YOffset)
|
|
})
|
|
|
|
t.Run("AskUserQuestion", func(t *testing.T) {
|
|
t.Parallel()
|
|
mustAskArgs := func(t testing.TB, questions ...parsedAskQuestion) string {
|
|
t.Helper()
|
|
payloadQuestions := make([]map[string]any, 0, len(questions))
|
|
for _, question := range questions {
|
|
options := make([]map[string]string, 0, len(question.Options))
|
|
for _, option := range question.Options {
|
|
options = append(options, map[string]string{
|
|
"label": option.Label,
|
|
"value": option.Value,
|
|
})
|
|
}
|
|
payloadQuestions = append(payloadQuestions, map[string]any{
|
|
"header": question.Header,
|
|
"question": question.Question,
|
|
"options": options,
|
|
})
|
|
}
|
|
output, err := json.Marshal(map[string]any{"questions": payloadQuestions})
|
|
require.NoError(t, err)
|
|
return string(output)
|
|
}
|
|
askToolCall := func(t testing.TB, toolCallID string, questions ...parsedAskQuestion) codersdk.ChatStreamToolCall {
|
|
t.Helper()
|
|
return codersdk.ChatStreamToolCall{
|
|
ToolCallID: toolCallID,
|
|
ToolName: "ask_user_question",
|
|
Args: mustAskArgs(t, questions...),
|
|
}
|
|
}
|
|
message := func(parts ...codersdk.ChatMessagePart) codersdk.ChatMessage {
|
|
return codersdk.ChatMessage{Content: parts}
|
|
}
|
|
toolCallPart := func(toolCallID, toolName, args string) codersdk.ChatMessagePart {
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Args: rawJSON(args),
|
|
}
|
|
}
|
|
toolResultPart := func(toolCallID, toolName, result string) codersdk.ChatMessagePart {
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolResult,
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Result: rawJSON(result),
|
|
}
|
|
}
|
|
firstQuestion := parsedAskQuestion{
|
|
Header: "Review plan",
|
|
Question: "What should happen next?",
|
|
Options: []parsedAskOption{
|
|
{Label: "Approve", Value: "approve"},
|
|
{Label: "Reject", Value: "reject"},
|
|
},
|
|
}
|
|
secondQuestion := parsedAskQuestion{
|
|
Header: "Reason",
|
|
Question: "Why?",
|
|
Options: []parsedAskOption{
|
|
{Label: "Speed", Value: "speed"},
|
|
{Label: "Quality", Value: "quality"},
|
|
},
|
|
}
|
|
|
|
t.Run("ParseToolCall", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ValidJSONWithOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state, err := parseAskUserQuestionToolCall(askToolCall(t, "tool-1", firstQuestion, secondQuestion))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "tool-1", state.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions)
|
|
require.Empty(t, state.Answers)
|
|
require.Zero(t, state.CurrentIndex)
|
|
require.Zero(t, state.OptionCursor)
|
|
})
|
|
|
|
t.Run("EmptyOrMissingQuestionsReturnsError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
args string
|
|
}{
|
|
{name: "MissingQuestions", args: `{}`},
|
|
{name: "EmptyQuestions", args: `{"questions":[]}`},
|
|
} {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{
|
|
ToolCallID: "tool-1",
|
|
ToolName: "ask_user_question",
|
|
Args: tt.args,
|
|
})
|
|
require.Nil(t, state)
|
|
require.ErrorContains(t, err, "at least one question")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("MalformedJSONReturnsError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state, err := parseAskUserQuestionToolCall(codersdk.ChatStreamToolCall{
|
|
ToolCallID: "tool-1",
|
|
ToolName: "ask_user_question",
|
|
Args: `{"questions":[`,
|
|
})
|
|
require.Nil(t, state)
|
|
require.ErrorContains(t, err, "parse ask_user_question args")
|
|
})
|
|
})
|
|
|
|
t.Run("BuildToolResult", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("AnswersMarshalToJSON", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
output, err := buildAskUserQuestionToolResult(&askUserQuestionState{
|
|
Answers: []askQuestionAnswer{{
|
|
Header: firstQuestion.Header,
|
|
Question: firstQuestion.Question,
|
|
Answer: "approve",
|
|
OptionLabel: "Approve",
|
|
Freeform: false,
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"answers":[{"header":"Review plan","question":"What should happen next?","answer":"approve","option_label":"Approve","freeform":false}]}`, string(output))
|
|
})
|
|
|
|
t.Run("NoAnswersUsesEmptyArray", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
output, err := buildAskUserQuestionToolResult(&askUserQuestionState{})
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"answers":[]}`, string(output))
|
|
})
|
|
})
|
|
|
|
t.Run("FindPending", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("NoMessagesReturnsNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state, err := findPendingAskUserQuestion(nil)
|
|
require.NoError(t, err)
|
|
require.Nil(t, state)
|
|
})
|
|
|
|
t.Run("ServerToolResultStillReturnsPendingState", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []codersdk.ChatMessage{
|
|
message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))),
|
|
message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))),
|
|
}
|
|
state, err := findPendingAskUserQuestion(messages)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, state)
|
|
require.Equal(t, "tool-1", state.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion}, state.Questions)
|
|
})
|
|
|
|
t.Run("UserAnsweredToolCallReturnsNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []codersdk.ChatMessage{
|
|
message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))),
|
|
message(toolResultPart("tool-1", "ask_user_question", `{"answers":[{"answer":"approve"}]}`)),
|
|
}
|
|
state, err := findPendingAskUserQuestion(messages)
|
|
require.NoError(t, err)
|
|
require.Nil(t, state)
|
|
})
|
|
|
|
t.Run("UnmatchedToolCallReturnsParsedState", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []codersdk.ChatMessage{
|
|
message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion))),
|
|
message(codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"}),
|
|
}
|
|
state, err := findPendingAskUserQuestion(messages)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, state)
|
|
require.Equal(t, "tool-1", state.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, state.Questions)
|
|
})
|
|
|
|
t.Run("NonAskUserQuestionToolCallReturnsNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []codersdk.ChatMessage{
|
|
message(toolCallPart("tool-1", "search_docs", `{"query":"overlay"}`)),
|
|
}
|
|
state, err := findPendingAskUserQuestion(messages)
|
|
require.NoError(t, err)
|
|
require.Nil(t, state)
|
|
})
|
|
})
|
|
|
|
t.Run("HandleStreamEventActionRequired", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("AskUserQuestionShowsOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newTestChatViewModel(nil)
|
|
updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeActionRequired,
|
|
ActionRequired: &codersdk.ChatStreamActionRequired{
|
|
ToolCalls: []codersdk.ChatStreamToolCall{askToolCall(t, "tool-1", firstQuestion)},
|
|
},
|
|
})
|
|
require.NotNil(t, updated.pendingAskUserQuestion)
|
|
require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions)
|
|
showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg)
|
|
require.True(t, ok)
|
|
require.Same(t, updated.pendingAskUserQuestion, showMsg.state)
|
|
})
|
|
|
|
t.Run("NonAskUserQuestionToolCallIsIgnored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newTestChatViewModel(nil)
|
|
updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeActionRequired,
|
|
ActionRequired: &codersdk.ChatStreamActionRequired{
|
|
ToolCalls: []codersdk.ChatStreamToolCall{{
|
|
ToolCallID: "tool-1",
|
|
ToolName: "search_docs",
|
|
Args: `{"query":"overlay"}`,
|
|
}},
|
|
},
|
|
})
|
|
require.Nil(t, updated.pendingAskUserQuestion)
|
|
require.Nil(t, cmd)
|
|
})
|
|
|
|
t.Run("MalformedArgsReturnErrorEvent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newTestChatViewModel(nil)
|
|
model.activeChatID = uuid.New()
|
|
model.chatGeneration = 7
|
|
updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeActionRequired,
|
|
ActionRequired: &codersdk.ChatStreamActionRequired{
|
|
ToolCalls: []codersdk.ChatStreamToolCall{{
|
|
ToolCallID: "tool-1",
|
|
ToolName: "ask_user_question",
|
|
Args: `{"questions":[`,
|
|
}},
|
|
},
|
|
})
|
|
require.Nil(t, updated.pendingAskUserQuestion)
|
|
streamMsg, ok := mustMsg(t, cmd).(chatStreamEventMsg)
|
|
require.True(t, ok)
|
|
require.Equal(t, uint64(7), streamMsg.generation)
|
|
require.Equal(t, model.activeChatID, streamMsg.chatID)
|
|
require.Equal(t, codersdk.ChatStreamEventTypeError, streamMsg.event.Type)
|
|
require.NotNil(t, streamMsg.event.Error)
|
|
require.Contains(t, streamMsg.event.Error.Message, "failed to parse ask_user_question")
|
|
|
|
updated = mustChatViewUpdate(t, updated, streamMsg)
|
|
require.EqualError(t, updated.err, "stream error: "+streamMsg.event.Error.Message)
|
|
})
|
|
})
|
|
|
|
t.Run("HandleStreamEventStatusRequiresAction", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RecoversFromMessages", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model := newTestChatViewModel(nil)
|
|
model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status
|
|
model.messages = []codersdk.ChatMessage{
|
|
message(toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))),
|
|
message(toolResultPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion))),
|
|
}
|
|
|
|
updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeStatus,
|
|
ChatID: chat.ID,
|
|
Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction},
|
|
})
|
|
require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus)
|
|
require.NotNil(t, updated.pendingAskUserQuestion)
|
|
require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion}, updated.pendingAskUserQuestion.Questions)
|
|
showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg)
|
|
require.True(t, ok)
|
|
require.Same(t, updated.pendingAskUserQuestion, showMsg.state)
|
|
})
|
|
|
|
t.Run("RecoversFromAccumulatorBeforeFinalMessage", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := testChat(codersdk.ChatStatusRunning)
|
|
model := newTestChatViewModel(nil)
|
|
model.chat, model.activeChatID, model.chatStatus = &chat, chat.ID, chat.Status
|
|
model.accumulator.parts = []codersdk.ChatMessagePart{
|
|
toolCallPart("tool-1", "ask_user_question", mustAskArgs(t, firstQuestion, secondQuestion)),
|
|
}
|
|
model.accumulator.pending = true
|
|
|
|
updated, cmd := model.handleStreamEvent(codersdk.ChatStreamEvent{
|
|
Type: codersdk.ChatStreamEventTypeStatus,
|
|
ChatID: chat.ID,
|
|
Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRequiresAction},
|
|
})
|
|
require.Equal(t, codersdk.ChatStatusRequiresAction, updated.chatStatus)
|
|
require.NotNil(t, updated.pendingAskUserQuestion)
|
|
require.Equal(t, "tool-1", updated.pendingAskUserQuestion.ToolCallID)
|
|
require.Equal(t, []parsedAskQuestion{firstQuestion, secondQuestion}, updated.pendingAskUserQuestion.Questions)
|
|
showMsg, ok := mustMsg(t, cmd).(showAskUserQuestionMsg)
|
|
require.True(t, ok)
|
|
require.Same(t, updated.pendingAskUserQuestion, showMsg.state)
|
|
})
|
|
})
|
|
|
|
t.Run("OverlayLifecycle", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newOverlayState := func() *askUserQuestionState {
|
|
return newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
}
|
|
|
|
t.Run("ShowOpensOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
|
|
updatedModel, cmd := model.Update(showAskUserQuestionMsg{state: state})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayAskUserQuestion, updated.overlay)
|
|
require.Same(t, state, updated.chat.pendingAskUserQuestion)
|
|
})
|
|
|
|
t.Run("HideClosesOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
updatedModel, cmd := model.Update(hideAskUserQuestionMsg{})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.Same(t, state, updated.chat.pendingAskUserQuestion)
|
|
})
|
|
|
|
t.Run("EscapeDoesNotCloseOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayAskUserQuestion, updated.overlay)
|
|
require.Same(t, state, updated.chat.pendingAskUserQuestion)
|
|
})
|
|
|
|
t.Run("SuccessfulSubmitClearsOverlay", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
model.chat.activeChatID = uuid.New()
|
|
model.chat.chatGeneration = 11
|
|
|
|
updatedModel, cmd := model.Update(toolResultsSubmittedMsg{
|
|
generation: 11,
|
|
chatID: model.chat.activeChatID,
|
|
})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayNone, updated.overlay)
|
|
require.Nil(t, updated.chat.pendingAskUserQuestion)
|
|
})
|
|
|
|
t.Run("SubmitErrorKeepsOverlayOpen", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
state.Submitting = true
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
model.chat.activeChatID = uuid.New()
|
|
model.chat.chatGeneration = 11
|
|
|
|
updatedModel, cmd := model.Update(toolResultsSubmittedMsg{
|
|
generation: 11,
|
|
chatID: model.chat.activeChatID,
|
|
err: xerrors.New("submit failed"),
|
|
})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayAskUserQuestion, updated.overlay)
|
|
require.NotNil(t, updated.chat.pendingAskUserQuestion)
|
|
require.False(t, updated.chat.pendingAskUserQuestion.Submitting)
|
|
require.EqualError(t, updated.chat.pendingAskUserQuestion.Error, "submit failed")
|
|
})
|
|
|
|
t.Run("StaleSubmitIsIgnored", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newOverlayState()
|
|
state.Submitting = true
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
model.chat.activeChatID = uuid.New()
|
|
model.chat.chatGeneration = 11
|
|
|
|
updatedModel, cmd := model.Update(toolResultsSubmittedMsg{
|
|
generation: 10,
|
|
chatID: model.chat.activeChatID,
|
|
})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayAskUserQuestion, updated.overlay)
|
|
require.Same(t, state, updated.chat.pendingAskUserQuestion)
|
|
require.True(t, updated.chat.pendingAskUserQuestion.Submitting)
|
|
require.NoError(t, updated.chat.pendingAskUserQuestion.Error)
|
|
})
|
|
})
|
|
|
|
t.Run("KeyHandling", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("UpAndDownNavigateOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
model := newTestTUIModel()
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyDown}))
|
|
require.Equal(t, 1, state.OptionCursor)
|
|
require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp}))
|
|
require.Zero(t, state.OptionCursor)
|
|
require.Nil(t, model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyUp}))
|
|
require.Equal(t, len(firstQuestion.Options), state.OptionCursor)
|
|
})
|
|
|
|
t.Run("EnterOnOptionRecordsAnswerAndAdvances", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion})
|
|
state.OptionCursor = 1
|
|
model := newTestTUIModel()
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter})
|
|
require.Nil(t, cmd)
|
|
require.Len(t, state.Answers, 1)
|
|
require.Equal(t, askQuestionAnswer{
|
|
Header: firstQuestion.Header,
|
|
Question: firstQuestion.Question,
|
|
Answer: "reject",
|
|
OptionLabel: "Reject",
|
|
Freeform: false,
|
|
}, state.Answers[0])
|
|
require.Equal(t, 1, state.CurrentIndex)
|
|
require.Zero(t, state.OptionCursor)
|
|
})
|
|
|
|
t.Run("EnterOnOtherEntersFreeformMode", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
state.OptionCursor = len(firstQuestion.Options)
|
|
model := newTestTUIModel()
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
cmd := model.handleAskUserQuestionKey(tea.KeyMsg{Type: tea.KeyEnter})
|
|
require.Nil(t, cmd)
|
|
require.True(t, state.OtherMode)
|
|
require.Empty(t, state.OtherInput.Value())
|
|
})
|
|
|
|
t.Run("EscapeInFreeformModeExitsOnlyInput", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
state.OtherMode = true
|
|
state.OtherInput.Focus()
|
|
state.OtherInput.SetValue("typed answer")
|
|
model := newTestTUIModel()
|
|
model.currentView = viewChat
|
|
model.overlay = overlayAskUserQuestion
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
updatedModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
updated := mustTUIModel(t, updatedModel, cmd)
|
|
require.Equal(t, overlayAskUserQuestion, updated.overlay)
|
|
require.NotNil(t, updated.chat.pendingAskUserQuestion)
|
|
require.False(t, updated.chat.pendingAskUserQuestion.OtherMode)
|
|
require.Equal(t, "typed answer", updated.chat.pendingAskUserQuestion.OtherInput.Value())
|
|
})
|
|
|
|
t.Run("LeftOrHMovesBackToPreviousQuestion", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion})
|
|
state.CurrentIndex = 1
|
|
state.OptionCursor = 1
|
|
state.Error = xerrors.New("temporary error")
|
|
state.Answers = []askQuestionAnswer{{
|
|
Header: firstQuestion.Header,
|
|
Question: firstQuestion.Question,
|
|
Answer: "approve",
|
|
OptionLabel: "Approve",
|
|
Freeform: false,
|
|
}}
|
|
model := newTestTUIModel()
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
cmd := model.handleAskUserQuestionKey(keyRunes("h"))
|
|
require.Nil(t, cmd)
|
|
require.Zero(t, state.CurrentIndex)
|
|
require.Zero(t, state.OptionCursor)
|
|
require.False(t, state.OtherMode)
|
|
require.Nil(t, state.Error)
|
|
require.Empty(t, state.Answers)
|
|
})
|
|
})
|
|
|
|
t.Run("RecordAskAnswer", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := newExpChatsTUIModel(context.Background(), failingExperimentalClient(), nil, nil, nil, uuid.Nil)
|
|
model.chat.activeChatID = uuid.New()
|
|
model.chat.chatGeneration = 4
|
|
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
state.OtherMode = true
|
|
state.OtherInput.Focus()
|
|
state.OtherInput.SetValue("custom answer")
|
|
model.chat.pendingAskUserQuestion = state
|
|
|
|
cmd := model.recordAskAnswer("custom answer", "", true)
|
|
require.NotNil(t, cmd)
|
|
require.True(t, state.Submitting)
|
|
require.Len(t, state.Answers, 1)
|
|
require.Equal(t, askQuestionAnswer{
|
|
Header: firstQuestion.Header,
|
|
Question: firstQuestion.Question,
|
|
Answer: "custom answer",
|
|
Freeform: true,
|
|
}, state.Answers[0])
|
|
require.False(t, state.OtherMode)
|
|
require.Empty(t, state.OtherInput.Value())
|
|
})
|
|
|
|
t.Run("ComposerBlocksEnterWhileQuestionPending", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
baseline := newTestChatViewModel(failingExperimentalClient())
|
|
baseline.draft = true
|
|
baseline.loading = false
|
|
baseline.composer.SetValue("send this")
|
|
|
|
updated, cmd := baseline.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
require.NotNil(t, cmd)
|
|
require.True(t, updated.creatingChat)
|
|
require.Empty(t, updated.composer.Value())
|
|
|
|
blocked := newTestChatViewModel(failingExperimentalClient())
|
|
blocked.draft = true
|
|
blocked.loading = false
|
|
blocked.composer.SetValue("send this")
|
|
blocked.pendingAskUserQuestion = newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
|
|
|
|
updated, cmd = blocked.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
require.Nil(t, cmd)
|
|
require.False(t, updated.creatingChat)
|
|
require.Equal(t, "send this", updated.composer.Value())
|
|
require.Empty(t, updated.pendingComposerText)
|
|
})
|
|
})
|
|
|
|
t.Run("ChatList", func(t *testing.T) {
|
|
t.Parallel()
|
|
newChat := func(status codersdk.ChatStatus, title string, parent *uuid.UUID) codersdk.Chat {
|
|
chat := testChat(status)
|
|
chat.Title, chat.ParentChatID = title, parent
|
|
return chat
|
|
}
|
|
newList := func(chats ...codersdk.Chat) chatListModel {
|
|
model := newReadyChatListModel()
|
|
model.chats = chats
|
|
return model
|
|
}
|
|
mustUpdate := func(t testing.TB, model chatListModel, msg tea.Msg) chatListModel {
|
|
t.Helper()
|
|
updated, cmd := model.Update(msg)
|
|
require.Nil(t, cmd)
|
|
return updated
|
|
}
|
|
requireRows := func(t testing.TB, rows []chatDisplayRow, wantChats []codersdk.Chat, wantDepths ...int) {
|
|
t.Helper()
|
|
require.Len(t, rows, len(wantChats))
|
|
for i, want := range wantChats {
|
|
require.Equal(t, want.ID, rows[i].chat.ID)
|
|
require.Equal(t, wantDepths[i], rows[i].depth)
|
|
}
|
|
}
|
|
t.Run("ChatsListedUpdatesState", func(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tt := range []struct {
|
|
name string
|
|
msg chatsListedMsg
|
|
wantChats int
|
|
wantErr string
|
|
}{
|
|
{name: "StoresChats", msg: chatsListedMsg{chats: []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusCompleted)}}, wantChats: 2},
|
|
{name: "StoresErr", msg: chatsListedMsg{err: xerrors.New("list failed")}, wantErr: "list failed"},
|
|
} {
|
|
updated, cmd := newChatListModel(newTUIStyles()).Update(tt.msg)
|
|
require.Nilf(t, cmd, tt.name)
|
|
require.Falsef(t, updated.loading, tt.name)
|
|
require.Lenf(t, updated.chats, tt.wantChats, tt.name)
|
|
if tt.wantErr == "" {
|
|
require.NoErrorf(t, updated.err, tt.name)
|
|
continue
|
|
}
|
|
require.EqualErrorf(t, updated.err, tt.wantErr, tt.name)
|
|
}
|
|
})
|
|
t.Run("ParentExpansionAndCollapse", func(t *testing.T) {
|
|
t.Parallel()
|
|
parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil)
|
|
childA := newChat(codersdk.ChatStatusWaiting, "Subagent A", &parent.ID)
|
|
childB := newChat(codersdk.ChatStatusPending, "Subagent B", &parent.ID)
|
|
root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil)
|
|
model := newList(parent, childA, childB, root)
|
|
requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0)
|
|
output := plainText(model.View())
|
|
require.Contains(t, output, "▶ Parent chat")
|
|
require.Contains(t, output, "(2 subagents)")
|
|
require.NotContains(t, output, childA.Title)
|
|
require.NotContains(t, output, childB.Title)
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight})
|
|
require.True(t, model.expanded[parent.ID])
|
|
requireRows(t, model.displayRows(), []codersdk.Chat{parent, childA, childB, root}, 0, 1, 1, 0)
|
|
model = mustUpdate(t, model, keyRunes("x"))
|
|
require.False(t, model.expanded[parent.ID])
|
|
requireRows(t, model.displayRows(), []codersdk.Chat{parent, root}, 0, 0)
|
|
model = mustUpdate(t, model, keyRunes("x"))
|
|
require.True(t, model.expanded[parent.ID])
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft})
|
|
require.False(t, model.expanded[parent.ID])
|
|
require.Zero(t, model.cursor)
|
|
})
|
|
t.Run("NestedExpansionNavigationAndOpenSelectedChat", func(t *testing.T) {
|
|
t.Parallel()
|
|
parent := newChat(codersdk.ChatStatusRunning, "Parent chat", nil)
|
|
child := newChat(codersdk.ChatStatusWaiting, "Child subagent", &parent.ID)
|
|
grandchild := newChat(codersdk.ChatStatusPending, "Grandchild subagent", &child.ID)
|
|
root := newChat(codersdk.ChatStatusCompleted, "Standalone chat", nil)
|
|
model := newList(parent, child, grandchild, root)
|
|
model.width, model.height = 100, 10
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight})
|
|
require.True(t, model.expanded[parent.ID])
|
|
requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, root}, 0, 1, 0)
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
|
selected := model.selectedChat()
|
|
require.NotNil(t, selected)
|
|
require.Equal(t, child.ID, selected.ID)
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyRight})
|
|
require.True(t, model.expanded[child.ID])
|
|
requireRows(t, model.displayRows(), []codersdk.Chat{parent, child, grandchild, root}, 0, 1, 2, 0)
|
|
require.Contains(t, plainText(model.View()), "Grandchild subagent")
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
|
selected = model.selectedChat()
|
|
require.NotNil(t, selected)
|
|
require.Equal(t, grandchild.ID, selected.ID)
|
|
model, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
openMsg, ok := mustMsg(t, cmd).(openSelectedChatMsg)
|
|
require.True(t, ok)
|
|
require.Equal(t, grandchild.ID, openMsg.chatID)
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft})
|
|
require.False(t, model.expanded[child.ID])
|
|
require.Equal(t, child.ID, model.selectedChat().ID)
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyLeft})
|
|
require.False(t, model.expanded[parent.ID])
|
|
require.Equal(t, parent.ID, model.selectedChat().ID)
|
|
})
|
|
t.Run("SearchIncludesVisibleAncestorChain", func(t *testing.T) {
|
|
t.Parallel()
|
|
for _, depth := range []int{1, 2} {
|
|
model := newReadyChatListModel()
|
|
chain := make([]codersdk.Chat, 0, depth+1)
|
|
wantDepths := make([]int, 0, depth+1)
|
|
var parentID *uuid.UUID
|
|
for d := 0; d <= depth; d++ {
|
|
title := "Root chat"
|
|
if d > 0 {
|
|
title = fmt.Sprintf("Subagent depth %d", d)
|
|
}
|
|
if d == depth {
|
|
title += " needle"
|
|
}
|
|
chain = append(chain, newChat(codersdk.ChatStatusWaiting, title, parentID))
|
|
parentID = &chain[len(chain)-1].ID
|
|
wantDepths = append(wantDepths, d)
|
|
}
|
|
model.chats = append([]codersdk.Chat{}, chain...)
|
|
model.chats = append(model.chats, newChat(codersdk.ChatStatusCompleted, "Other root", nil))
|
|
model.search.SetValue("needle")
|
|
rows := model.displayRows()
|
|
requireRows(t, rows, chain, wantDepths...)
|
|
for i, row := range rows {
|
|
require.Equalf(t, i < depth, row.isExpanded, "depth=%d row=%d", depth, i)
|
|
}
|
|
}
|
|
})
|
|
t.Run("NavigationKeysMoveCursorWithinBounds", func(t *testing.T) {
|
|
t.Parallel()
|
|
chats := []codersdk.Chat{testChat(codersdk.ChatStatusWaiting), testChat(codersdk.ChatStatusPending), testChat(codersdk.ChatStatusCompleted)}
|
|
for _, tt := range []struct {
|
|
name string
|
|
key tea.KeyMsg
|
|
want int
|
|
}{
|
|
{name: "Up", key: tea.KeyMsg{Type: tea.KeyUp}, want: 0},
|
|
{name: "Down", key: tea.KeyMsg{Type: tea.KeyDown}, want: 2},
|
|
{name: "J", key: keyRunes("j"), want: 2},
|
|
{name: "K", key: keyRunes("k"), want: 0},
|
|
} {
|
|
model := newList(chats...)
|
|
model.cursor = 1
|
|
model = mustUpdate(t, model, tt.key)
|
|
require.Equalf(t, tt.want, model.cursor, tt.name)
|
|
model = mustUpdate(t, model, tt.key)
|
|
require.Equalf(t, tt.want, model.cursor, tt.name)
|
|
}
|
|
})
|
|
t.Run("ViewKeepsSelectedChatVisible", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := newReadyChatListModel()
|
|
model.width, model.height = 80, 8
|
|
for i := range 8 {
|
|
model.chats = append(model.chats, newChat(codersdk.ChatStatusWaiting, fmt.Sprintf("chat %02d", i), nil))
|
|
}
|
|
for range 6 {
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
|
}
|
|
require.Equal(t, 2, model.offset)
|
|
listView := plainText(model.View())
|
|
require.Contains(t, listView, "> chat 06")
|
|
require.NotContains(t, listView, "chat 00")
|
|
parent := newTestTUIModel()
|
|
parent.width, parent.height, parent.list = 80, 8, model
|
|
parentView := plainText(parent.View())
|
|
require.Contains(t, parentView, "Coder Chats")
|
|
require.Contains(t, parentView, "> chat 06")
|
|
for range 5 {
|
|
model = mustUpdate(t, model, tea.KeyMsg{Type: tea.KeyUp})
|
|
}
|
|
require.Equal(t, 1, model.offset)
|
|
require.Contains(t, plainText(model.View()), "> chat 01")
|
|
})
|
|
t.Run("EmptyListDisplaysNoChatsMessage", func(t *testing.T) {
|
|
t.Parallel()
|
|
updated, cmd := newChatListModel(newTUIStyles()).Update(chatsListedMsg{chats: []codersdk.Chat{}})
|
|
require.Nil(t, cmd)
|
|
require.Contains(t, plainText(updated.View()), "No chats yet")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestExpAgents_View_LongInputFitsTerminal(t *testing.T) {
|
|
t.Parallel()
|
|
model := newTestChatViewModel(nil)
|
|
model.width, model.height = 80, 24
|
|
model.setComposerWidth()
|
|
model.recalcViewportHeight()
|
|
model.syncViewportContent()
|
|
chat := testChat(codersdk.ChatStatusCompleted)
|
|
model.chat = &chat
|
|
model.chatStatus = chat.Status
|
|
model.messages = overflowingMessages(24)
|
|
model.rebuildBlocks()
|
|
|
|
defaultViewportHeight := model.viewport.Height
|
|
model.composer.SetValue(strings.Repeat("a", 250))
|
|
model.recalcViewportHeight()
|
|
model.syncViewportContent()
|
|
|
|
view := plainText(model.View())
|
|
lines := strings.Split(view, "\n")
|
|
|
|
require.LessOrEqual(t, len(lines), model.height)
|
|
require.LessOrEqual(t, model.viewport.Height, defaultViewportHeight)
|
|
require.NotEmpty(t, strings.TrimSpace(lines[len(lines)-1]))
|
|
}
|
|
|
|
func mustTUIModel(t testing.TB, model tea.Model, cmd tea.Cmd) expChatsTUIModel {
|
|
t.Helper()
|
|
updated, ok := model.(expChatsTUIModel)
|
|
require.True(t, ok)
|
|
require.Nil(t, cmd)
|
|
return updated
|
|
}
|
|
|
|
func mustTUIModelWithCmd(t testing.TB, model tea.Model, cmd tea.Cmd) (expChatsTUIModel, tea.Cmd) {
|
|
t.Helper()
|
|
updated, ok := model.(expChatsTUIModel)
|
|
require.True(t, ok)
|
|
return updated, cmd
|
|
}
|
|
|
|
func mustChatViewUpdate(t testing.TB, model chatViewModel, msg tea.Msg) chatViewModel {
|
|
t.Helper()
|
|
updated, cmd := model.Update(msg)
|
|
require.Nil(t, cmd)
|
|
return updated
|
|
}
|
|
|
|
func mustMsg(t testing.TB, cmd tea.Cmd) tea.Msg { t.Helper(); require.NotNil(t, cmd); return cmd() }
|
|
|
|
func mustBatchMsg(t testing.TB, cmd tea.Cmd) tea.BatchMsg {
|
|
t.Helper()
|
|
batch, ok := mustMsg(t, cmd).(tea.BatchMsg)
|
|
require.True(t, ok)
|
|
return batch
|
|
}
|
|
|
|
func assertStreamCase(t testing.TB, model chatViewModel, wantMessages int, wantAccumulatorText, wantAccumulatorArgs string, wantBlockKind chatBlockKind, wantBlockText, wantBlockArgs string, wantUsage *codersdk.ChatMessageUsage) {
|
|
t.Helper()
|
|
wantPending := wantAccumulatorText != "" || wantAccumulatorArgs != ""
|
|
require.Len(t, model.messages, wantMessages)
|
|
require.Equal(t, wantPending, model.accumulator.isPending())
|
|
if wantPending {
|
|
require.Len(t, model.accumulator.parts, 1)
|
|
if wantAccumulatorText != "" {
|
|
require.Equal(t, wantAccumulatorText, model.accumulator.parts[0].Text)
|
|
}
|
|
if wantAccumulatorArgs != "" {
|
|
require.Equal(t, wantAccumulatorArgs, string(model.accumulator.parts[0].Args))
|
|
}
|
|
} else {
|
|
require.Empty(t, model.accumulator.parts)
|
|
}
|
|
require.Len(t, model.blocks, 1)
|
|
require.Equal(t, wantBlockKind, model.blocks[0].kind)
|
|
if wantBlockText != "" {
|
|
require.Equal(t, wantBlockText, model.blocks[0].text)
|
|
}
|
|
if wantBlockArgs != "" {
|
|
require.Equal(t, wantBlockArgs, model.blocks[0].args)
|
|
}
|
|
require.Equal(t, wantUsage, model.lastUsage)
|
|
require.False(t, model.reconnecting)
|
|
}
|
|
|
|
func assertScrollNavigationCase(t testing.TB, before chatViewModel, after chatViewModel, wantBeforeYOffset int, wantAfterYOffset int, wantAutoFollow int, wantBeforeBottom int, wantAfterBottom int, yCheck int) {
|
|
t.Helper()
|
|
if wantAfterYOffset == 0 && wantBeforeYOffset == -1 {
|
|
require.NotZero(t, before.viewport.YOffset)
|
|
}
|
|
if wantBeforeYOffset != -1 {
|
|
require.Equal(t, wantBeforeYOffset, before.viewport.YOffset)
|
|
}
|
|
if wantAfterYOffset != -1 {
|
|
require.Equal(t, wantAfterYOffset, after.viewport.YOffset)
|
|
}
|
|
if wantAutoFollow != -1 {
|
|
require.Equal(t, wantAutoFollow == 1, after.autoFollow)
|
|
}
|
|
if wantBeforeBottom != -1 {
|
|
require.Equal(t, wantBeforeBottom == 1, before.viewport.AtBottom())
|
|
}
|
|
if wantAfterBottom != -1 {
|
|
require.Equal(t, wantAfterBottom == 1, after.viewport.AtBottom())
|
|
}
|
|
switch yCheck {
|
|
case 1:
|
|
require.Less(t, after.viewport.YOffset, before.viewport.YOffset)
|
|
case 2:
|
|
require.Greater(t, after.viewport.YOffset, before.viewport.YOffset)
|
|
case 3:
|
|
require.Equal(t, before.viewport.YOffset, after.viewport.YOffset)
|
|
case 4:
|
|
halfView := before.viewport.Height / 2
|
|
require.InDelta(t, float64(before.viewport.YOffset-halfView), float64(after.viewport.YOffset), 1)
|
|
case 5:
|
|
halfView := before.viewport.Height / 2
|
|
require.InDelta(t, float64(before.viewport.YOffset+halfView), float64(after.viewport.YOffset), 1)
|
|
}
|
|
}
|
|
|
|
// newTestChatViewModel creates a chatViewModel for reducer tests.
|
|
// The returned model has chatGeneration=0, so test messages with
|
|
// default generation=0 pass the generation guard.
|
|
func newTestChatViewModel(client *codersdk.ExperimentalClient) chatViewModel {
|
|
return newChatViewModel(context.Background(), client, nil, nil, uuid.Nil, newTUIStyles())
|
|
}
|
|
|
|
func newTestTUIModel() expChatsTUIModel {
|
|
return newExpChatsTUIModel(context.Background(), nil, nil, nil, nil, uuid.Nil)
|
|
}
|
|
|
|
func newReadyChatListModel() chatListModel {
|
|
model := newChatListModel(newTUIStyles())
|
|
model.loading = false
|
|
return model
|
|
}
|
|
|
|
func newTestExperimentalClient(t testing.TB, handler http.HandlerFunc) *codersdk.ExperimentalClient {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
serverURL, err := url.Parse(server.URL)
|
|
require.NoError(t, err)
|
|
return codersdk.NewExperimentalClient(codersdk.New(serverURL))
|
|
}
|
|
|
|
func overflowingMessages(count int) []codersdk.ChatMessage {
|
|
messages := make([]codersdk.ChatMessage, 0, count)
|
|
for i := 0; i < count; i++ {
|
|
role := codersdk.ChatMessageRoleUser
|
|
if i%2 == 1 {
|
|
role = codersdk.ChatMessageRoleAssistant
|
|
}
|
|
messages = append(messages, testMessage(int64(i+1), role, codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("message %d %s", i+1, strings.Repeat("content ", 18))}))
|
|
}
|
|
return messages
|
|
}
|
|
|
|
func testChat(status codersdk.ChatStatus) codersdk.Chat {
|
|
return codersdk.Chat{ID: uuid.New(), Title: "test chat", Status: status, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
}
|
|
|
|
func testMessage(id int64, role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage {
|
|
return codersdk.ChatMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Role: role, Content: parts}
|
|
}
|
|
|
|
func testQueuedMessage(id int64, parts ...codersdk.ChatMessagePart) codersdk.ChatQueuedMessage {
|
|
return codersdk.ChatQueuedMessage{ID: id, ChatID: uuid.New(), CreatedAt: time.Now(), Content: parts}
|
|
}
|
|
|
|
func testTextPartEvent(text string) codersdk.ChatStreamEvent {
|
|
return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{
|
|
Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: text},
|
|
}}
|
|
}
|
|
|
|
func testToolCallDeltaEvent(toolCallID, toolName, delta string) codersdk.ChatStreamEvent {
|
|
return codersdk.ChatStreamEvent{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{
|
|
Role: codersdk.ChatMessageRoleAssistant,
|
|
Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: toolCallID, ToolName: toolName, ArgsDelta: delta},
|
|
}}
|
|
}
|
|
|
|
func failingExperimentalClient() *codersdk.ExperimentalClient {
|
|
return codersdk.NewExperimentalClient(codersdk.New(&url.URL{}))
|
|
}
|
|
|
|
func keyRunes(value string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)} }
|
|
|
|
func int64Ref(v int64) *int64 {
|
|
return &v
|
|
}
|