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 }