package coderd_test import ( "bytes" "database/sql" "encoding/json" "fmt" "net/http" "regexp" "strings" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/externalauth" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" ) func chatDeploymentValues(t testing.TB) *codersdk.DeploymentValues { t.Helper() values := coderdtest.DeploymentValues(t) values.Experiments = []string{string(codersdk.ExperimentAgents)} return values } func newChatClient(t testing.TB) *codersdk.Client { t.Helper() return coderdtest.New(t, &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), }) } func newChatClientWithDatabase(t testing.TB) (*codersdk.Client, database.Store) { t.Helper() return coderdtest.NewWithDatabase(t, &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), }) } func TestPostChats(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello from chats route tests", }, }, }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, chat.ID) require.Equal(t, user.UserID, chat.OwnerID) require.Equal(t, modelConfig.ID, chat.LastModelConfigID) require.Equal(t, "hello from chats route tests", chat.Title) require.Equal(t, codersdk.ChatStatusPending, chat.Status) require.NotZero(t, chat.CreatedAt) require.NotZero(t, chat.UpdatedAt) require.Nil(t, chat.WorkspaceID) require.NotNil(t, chat.RootChatID) require.Equal(t, chat.ID, *chat.RootChatID) chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) require.Equal(t, chat.ID, chatWithMessages.Chat.ID) foundUserMessage := false for _, message := range chatWithMessages.Messages { if message.Role != "user" { continue } for _, part := range message.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "hello from chats route tests" { foundUserMessage = true break } } } require.True(t, foundUserMessage) }) t.Run("HidesSystemPromptMessages", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "verify hidden system prompt", }, }, }) require.NoError(t, err) chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) for _, message := range chatWithMessages.Messages { require.NotEqual(t, "system", message.Role) } }) t.Run("WorkspaceNotAccessible", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: firstUser.OrganizationID, OwnerID: firstUser.UserID, }).WithAgent().Do() _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, WorkspaceID: &workspaceBuild.Workspace.ID, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal( t, "Workspace not found or you do not have access to this resource", sdkErr.Message, ) }) t.Run("WorkspaceNotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) workspaceID := uuid.New() _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, WorkspaceID: &workspaceID, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal( t, "Workspace not found or you do not have access to this resource", sdkErr.Message, ) }) t.Run("WorkspaceSelectsFirstAgent", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, }).WithAgent().Do() chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, WorkspaceID: &workspaceBuild.Workspace.ID, }) require.NoError(t, err) require.NotNil(t, chat.WorkspaceID) require.Equal(t, workspaceBuild.Workspace.ID, *chat.WorkspaceID) require.Equal(t, modelConfig.ID, chat.LastModelConfigID) }) t.Run("MissingDefaultModelConfig", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "No default chat model config is configured.", sdkErr.Message) }) t.Run("EmptyContent", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: nil, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Content is required.", sdkErr.Message) require.Equal(t, "Content cannot be empty.", sdkErr.Detail) }) t.Run("EmptyText", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: " ", }, }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid input part.", sdkErr.Message) require.Equal(t, "content[0].text cannot be empty.", sdkErr.Detail) }) t.Run("UnsupportedPartType", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartType("image"), Text: "hello", }, }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid input part.", sdkErr.Message) require.Equal(t, `content[0].type "image" is not supported.`, sdkErr.Detail) }) } func TestListChats(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) firstChatA, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "first owner chat", }, }, }) require.NoError(t, err) firstChatB, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "second owner chat", }, }, }) require.NoError(t, err) memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: member.ID, LastModelConfigID: modelConfig.ID, Title: "member chat only", }) require.NoError(t, err) chats, err := client.ListChats(ctx, nil) require.NoError(t, err) require.Len(t, chats, 2) chatIndexes := make(map[uuid.UUID]int, len(chats)) chatsByID := make(map[uuid.UUID]codersdk.Chat, len(chats)) for i, chat := range chats { chatIndexes[chat.ID] = i chatsByID[chat.ID] = chat require.Equal(t, firstUser.UserID, chat.OwnerID) require.Equal(t, modelConfig.ID, chat.LastModelConfigID) require.Equal(t, codersdk.ChatStatusPending, chat.Status) require.NotZero(t, chat.CreatedAt) require.NotZero(t, chat.UpdatedAt) require.Nil(t, chat.ParentChatID) require.Nil(t, chat.WorkspaceID) require.NotNil(t, chat.RootChatID) require.Equal(t, chat.ID, *chat.RootChatID) require.NotNil(t, chat.DiffStatus) require.Equal(t, chat.ID, chat.DiffStatus.ChatID) } require.Contains(t, chatsByID, firstChatA.ID) require.Contains(t, chatsByID, firstChatB.ID) require.NotContains(t, chatsByID, memberDBChat.ID) require.Equal(t, "first owner chat", chatsByID[firstChatA.ID].Title) require.Equal(t, "second owner chat", chatsByID[firstChatB.ID].Title) for i := 1; i < len(chats); i++ { require.False(t, chats[i-1].UpdatedAt.Before(chats[i].UpdatedAt)) } if firstChatA.UpdatedAt.After(firstChatB.UpdatedAt) { require.Less(t, chatIndexes[firstChatA.ID], chatIndexes[firstChatB.ID]) } if firstChatB.UpdatedAt.After(firstChatA.UpdatedAt) { require.Less(t, chatIndexes[firstChatB.ID], chatIndexes[firstChatA.ID]) } memberChats, err := memberClient.ListChats(ctx, nil) require.NoError(t, err) require.Len(t, memberChats, 1) require.Equal(t, memberDBChat.ID, memberChats[0].ID) require.Equal(t, member.ID, memberChats[0].OwnerID) require.Equal(t, "member chat only", memberChats[0].Title) require.NotNil(t, memberChats[0].RootChatID) require.Equal(t, memberChats[0].ID, *memberChats[0].RootChatID) require.NotNil(t, memberChats[0].DiffStatus) require.Equal(t, memberChats[0].ID, memberChats[0].DiffStatus.ChatID) }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) unauthenticatedClient := codersdk.New(client.URL) _, err := unauthenticatedClient.ListChats(ctx, nil) requireSDKError(t, err, http.StatusUnauthorized) }) } func TestListChatModels(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) models, err := client.ListChatModels(ctx) require.NoError(t, err) var openAIProvider *codersdk.ChatModelProvider for i := range models.Providers { if models.Providers[i].Provider == "openai" { openAIProvider = &models.Providers[i] break } } require.NotNil(t, openAIProvider) require.True(t, openAIProvider.Available) foundModel := false for _, model := range openAIProvider.Models { if model.Provider == "openai" && model.Model == "gpt-4o-mini" { foundModel = true break } } require.True(t, foundModel) }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) unauthenticatedClient := codersdk.New(client.URL) _, err := unauthenticatedClient.ListChatModels(ctx) requireSDKError(t, err, http.StatusUnauthorized) }) } func TestWatchChats(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) require.NoError(t, err) defer conn.Close(websocket.StatusNormalClosure, "done") type watchEvent struct { Type codersdk.ServerSentEventType `json:"type"` Data json.RawMessage `json:"data,omitempty"` } var event watchEvent err = wsjson.Read(ctx, conn, &event) require.NoError(t, err) require.Equal(t, codersdk.ServerSentEventTypePing, event.Type) require.True(t, len(event.Data) == 0 || string(event.Data) == "null") createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "watch route created event", }, }, }) require.NoError(t, err) for { var update watchEvent err = wsjson.Read(ctx, conn, &update) require.NoError(t, err) if update.Type == codersdk.ServerSentEventTypePing { continue } require.Equal(t, codersdk.ServerSentEventTypeData, update.Type) var payload coderdpubsub.ChatEvent err = json.Unmarshal(update.Data, &payload) require.NoError(t, err) if payload.Kind == coderdpubsub.ChatEventKindCreated && payload.Chat.ID == createdChat.ID { break } } }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) unauthenticatedClient := codersdk.New(client.URL) res, err := unauthenticatedClient.Request( ctx, http.MethodGet, "/api/experimental/chats/watch", nil, ) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusUnauthorized, res.StatusCode) }) } func TestListChatProviders(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) providers, err := client.ListChatProviders(ctx) require.NoError(t, err) var openAIProvider *codersdk.ChatProviderConfig for i := range providers { if providers[i].Provider == "openai" { openAIProvider = &providers[i] break } } require.NotNil(t, openAIProvider) require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, openAIProvider.Source) require.True(t, openAIProvider.Enabled) require.True(t, openAIProvider.HasAPIKey) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) _, err := memberClient.ListChatProviders(ctx) requireSDKError(t, err, http.StatusForbidden) }) } func TestCreateChatProvider(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", DisplayName: "OpenAI Primary", APIKey: "test-api-key", }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, provider.ID) require.Equal(t, "openai", provider.Provider) require.Equal(t, "OpenAI Primary", provider.DisplayName) require.True(t, provider.Enabled) require.True(t, provider.HasAPIKey) require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, provider.Source) }) t.Run("InvalidProvider", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "not-a-provider", APIKey: "test-api-key", }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid provider.", sdkErr.Message) }) t.Run("Conflict", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) _, err = client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "other-api-key", }) sdkErr := requireSDKError(t, err, http.StatusConflict) require.Equal(t, "Chat provider already exists.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) _, err := memberClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "member-key", }) requireSDKError(t, err, http.StatusForbidden) }) } func TestUpdateChatProvider(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) enabled := false baseURL := "https://example.com/v1" updated, err := client.UpdateChatProvider(ctx, provider.ID, codersdk.UpdateChatProviderConfigRequest{ DisplayName: "OpenAI Updated", Enabled: &enabled, BaseURL: &baseURL, }) require.NoError(t, err) require.Equal(t, provider.ID, updated.ID) require.Equal(t, "OpenAI Updated", updated.DisplayName) require.False(t, updated.Enabled) require.Equal(t, baseURL, updated.BaseURL) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.UpdateChatProvider(ctx, uuid.New(), codersdk.UpdateChatProviderConfigRequest{ DisplayName: "missing", }) requireSDKError(t, err, http.StatusNotFound) }) t.Run("InvalidProviderID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) res, err := client.Request( ctx, http.MethodPatch, "/api/experimental/chats/providers/not-a-uuid", codersdk.UpdateChatProviderConfigRequest{DisplayName: "ignored"}, ) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat provider ID.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) provider, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) _, err = memberClient.UpdateChatProvider(ctx, provider.ID, codersdk.UpdateChatProviderConfigRequest{ DisplayName: "member update", }) requireSDKError(t, err, http.StatusForbidden) }) } func TestDeleteChatProvider(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) err = client.DeleteChatProvider(ctx, provider.ID) require.NoError(t, err) providers, err := client.ListChatProviders(ctx) require.NoError(t, err) for _, listed := range providers { require.NotEqual(t, provider.ID, listed.ID) } }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) err := client.DeleteChatProvider(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) t.Run("InvalidProviderID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) res, err := client.Request( ctx, http.MethodDelete, "/api/experimental/chats/providers/not-a-uuid", nil, ) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat provider ID.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) provider, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) err = memberClient.DeleteChatProvider(ctx, provider.ID) requireSDKError(t, err, http.StatusForbidden) }) } func TestListChatModelConfigs(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) configs, err := client.ListChatModelConfigs(ctx) require.NoError(t, err) require.NotEmpty(t, configs) found := false for _, config := range configs { if config.ID == modelConfig.ID { found = true require.Equal(t, "openai", config.Provider) require.Equal(t, "gpt-4o-mini", config.Model) require.True(t, config.IsDefault) } } require.True(t, found) }) t.Run("SuccessForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) modelConfig := createChatModelConfig(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) // Non-admin users should see only enabled model configs. configs, err := memberClient.ListChatModelConfigs(ctx) require.NoError(t, err) require.NotEmpty(t, configs) found := false for _, config := range configs { if config.ID == modelConfig.ID { found = true require.Equal(t, "openai", config.Provider) require.Equal(t, "gpt-4o-mini", config.Model) } } require.True(t, found) }) } func TestCreateChatModelConfig(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) contextLimit := int64(4096) isDefault := true modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", ContextLimit: &contextLimit, IsDefault: &isDefault, }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, modelConfig.ID) require.Equal(t, "openai", modelConfig.Provider) require.Equal(t, "gpt-4o-mini", modelConfig.Model) require.EqualValues(t, 4096, modelConfig.ContextLimit) require.True(t, modelConfig.IsDefault) }) t.Run("MissingContextLimit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Context limit is required.", sdkErr.Message) }) t.Run("ProviderNotConfigured", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) contextLimit := int64(4096) _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Chat provider is not configured.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) _, err := adminClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) contextLimit := int64(4096) _, err = memberClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", ContextLimit: &contextLimit, }) requireSDKError(t, err, http.StatusForbidden) }) } func TestUpdateChatModelConfig(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) contextLimit := int64(8192) updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ DisplayName: "GPT-4o Mini Updated", ContextLimit: &contextLimit, }) require.NoError(t, err) require.Equal(t, modelConfig.ID, updated.ID) require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName) require.EqualValues(t, 8192, updated.ContextLimit) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.UpdateChatModelConfig(ctx, uuid.New(), codersdk.UpdateChatModelConfigRequest{ DisplayName: "missing", }) requireSDKError(t, err, http.StatusNotFound) }) t.Run("InvalidContextLimit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) contextLimit := int64(0) _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ ContextLimit: &contextLimit, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Context limit must be greater than zero.", sdkErr.Message) }) t.Run("InvalidModelConfigID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) res, err := client.Request( ctx, http.MethodPatch, "/api/experimental/chats/model-configs/not-a-uuid", codersdk.UpdateChatModelConfigRequest{DisplayName: "ignored"}, ) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) modelConfig := createChatModelConfig(t, adminClient) _, err := memberClient.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ DisplayName: "member update", }) requireSDKError(t, err, http.StatusForbidden) }) } func TestDeleteChatModelConfig(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) err := client.DeleteChatModelConfig(ctx, modelConfig.ID) require.NoError(t, err) configs, err := client.ListChatModelConfigs(ctx) require.NoError(t, err) for _, config := range configs { require.NotEqual(t, modelConfig.ID, config.ID) } }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) err := client.DeleteChatModelConfig(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) t.Run("InvalidModelConfigID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) res, err := client.Request( ctx, http.MethodDelete, "/api/experimental/chats/model-configs/not-a-uuid", nil, ) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat model config ID.", sdkErr.Message) }) t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) modelConfig := createChatModelConfig(t, adminClient) err := memberClient.DeleteChatModelConfig(ctx, modelConfig.ID) requireSDKError(t, err, http.StatusForbidden) }) } func TestGetChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "get chat route payload", }, }, }) require.NoError(t, err) chatWithMessages, err := client.GetChat(ctx, createdChat.ID) require.NoError(t, err) require.Equal(t, createdChat.ID, chatWithMessages.Chat.ID) require.Equal(t, firstUser.UserID, chatWithMessages.Chat.OwnerID) require.Equal(t, modelConfig.ID, chatWithMessages.Chat.LastModelConfigID) require.Equal(t, "get chat route payload", chatWithMessages.Chat.Title) require.NotZero(t, chatWithMessages.Chat.CreatedAt) require.NotZero(t, chatWithMessages.Chat.UpdatedAt) require.NotEmpty(t, chatWithMessages.Messages) require.Empty(t, chatWithMessages.QueuedMessages) foundUserMessage := false for _, message := range chatWithMessages.Messages { require.Equal(t, createdChat.ID, message.ChatID) require.NotEqual(t, "system", message.Role) for _, part := range message.Content { if message.Role == "user" && part.Type == codersdk.ChatMessagePartTypeText && part.Text == "get chat route payload" { foundUserMessage = true } } } require.True(t, foundUserMessage) }) t.Run("NotFoundForDifferentUser", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "private chat", }, }, }) require.NoError(t, err) otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) _, err = otherClient.GetChat(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) }) } func TestArchiveChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chatToArchive, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "archive me", }, }, }) require.NoError(t, err) chatToKeep, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "keep me", }, }, }) require.NoError(t, err) chatsBeforeArchive, err := client.ListChats(ctx, nil) require.NoError(t, err) require.Len(t, chatsBeforeArchive, 2) err = client.ArchiveChat(ctx, chatToArchive.ID) require.NoError(t, err) // Default (no filter) returns all chats including archived. allChats, err := client.ListChats(ctx, nil) require.NoError(t, err) require.Len(t, allChats, 2) // archived=false returns only non-archived chats. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(false), }) require.NoError(t, err) require.Len(t, activeChats, 1) require.Equal(t, chatToKeep.ID, activeChats[0].ID) require.False(t, activeChats[0].Archived) // archived=true returns only archived chats. archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(true), }) require.NoError(t, err) require.Len(t, archivedChats, 1) require.Equal(t, chatToArchive.ID, archivedChats[0].ID) require.True(t, archivedChats[0].Archived) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) err := client.ArchiveChat(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) t.Run("ArchivesChildren", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) // Create a parent chat via the API. parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "parent chat", }, }, }) require.NoError(t, err) // Insert child chats directly via the database. child1, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "child 1", ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true}, RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true}, }) require.NoError(t, err) child2, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "child 2", ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true}, RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true}, }) require.NoError(t, err) // Archive the parent via the API. err = client.ArchiveChat(ctx, parentChat.ID) require.NoError(t, err) // archived=false should exclude the entire archived family. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(false), }) require.NoError(t, err) for _, c := range activeChats { require.NotEqual(t, parentChat.ID, c.ID, "parent should not appear") require.NotEqual(t, child1.ID, c.ID, "child1 should not appear") require.NotEqual(t, child2.ID, c.ID, "child2 should not appear") } // Verify children are archived directly in the DB. dbChild1, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child1.ID) require.NoError(t, err) require.True(t, dbChild1.Archived, "child1 should be archived") dbChild2, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child2.ID) require.NoError(t, err) require.True(t, dbChild2.Archived, "child2 should be archived") }) } func TestUnarchiveChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "archive then unarchive me", }, }, }) require.NoError(t, err) // Archive the chat first. err = client.ArchiveChat(ctx, chat.ID) require.NoError(t, err) // Verify it's archived. archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(true), }) require.NoError(t, err) require.Len(t, archivedChats, 1) require.True(t, archivedChats[0].Archived) // Unarchive the chat. err = client.UnarchiveChat(ctx, chat.ID) require.NoError(t, err) // Verify it's no longer archived. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(false), }) require.NoError(t, err) require.Len(t, activeChats, 1) require.Equal(t, chat.ID, activeChats[0].ID) require.False(t, activeChats[0].Archived) // No archived chats remain. archivedChats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ Archived: ptr.Ref(true), }) require.NoError(t, err) require.Empty(t, archivedChats) }) t.Run("NotArchived", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "not archived", }, }, }) require.NoError(t, err) // Trying to unarchive a non-archived chat should fail. err = client.UnarchiveChat(ctx, chat.ID) requireSDKError(t, err, http.StatusBadRequest) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) err := client.UnarchiveChat(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) } func TestPostChatMessages(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "initial message for post route test", }, }, }) require.NoError(t, err) hasTextPart := func(parts []codersdk.ChatMessagePart, want string) bool { for _, part := range parts { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == want { return true } } return false } messageText := "post message route success " + uuid.NewString() created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: messageText, }, }, }) require.NoError(t, err) if created.Queued { require.Nil(t, created.Message) require.NotNil(t, created.QueuedMessage) require.Equal(t, chat.ID, created.QueuedMessage.ChatID) require.NotZero(t, created.QueuedMessage.ID) require.True(t, hasTextPart(created.QueuedMessage.Content, messageText)) require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, queued := range chatWithMessages.QueuedMessages { if queued.ID == created.QueuedMessage.ID && queued.ChatID == chat.ID && hasTextPart(queued.Content, messageText) { return true } } for _, message := range chatWithMessages.Messages { if message.Role == "user" && hasTextPart(message.Content, messageText) { return true } } return false }, testutil.WaitLong, testutil.IntervalFast) } else { require.Nil(t, created.QueuedMessage) require.NotNil(t, created.Message) require.Equal(t, chat.ID, created.Message.ChatID) require.Equal(t, "user", created.Message.Role) require.NotZero(t, created.Message.ID) require.True(t, hasTextPart(created.Message.Content, messageText)) require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, message := range chatWithMessages.Messages { if message.ID == created.Message.ID && message.Role == "user" && hasTextPart(message.Content, messageText) { return true } } return false }, testutil.WaitLong, testutil.IntervalFast) } }) t.Run("EmptyText", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "initial message for validation test", }, }, }) require.NoError(t, err) _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: " ", }, }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid input part.", sdkErr.Message) require.Equal(t, "content[0].text cannot be empty.", sdkErr.Detail) }) t.Run("ChatNotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) _, err := client.CreateChatMessage(ctx, uuid.New(), codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, }) requireSDKError(t, err, http.StatusNotFound) }) } func TestChatMessageWithFileReferences(t *testing.T) { t.Parallel() // createChat is a helper that creates a chat so we can post messages to it. createChatForTest := func(t *testing.T, client *codersdk.Client) codersdk.Chat { t.Helper() ctx := testutil.Context(t, testutil.WaitLong) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, Text: "initial message", }}, }) require.NoError(t, err) return chat } t.Run("FileReferenceOnly", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "main.go", StartLine: 10, EndLine: 15, Content: "func broken() {}", }}, }) require.NoError(t, err) // The file-reference is stored as a formatted text block. wantText := "[file-reference] main.go:10-15\n" + "```main.go\nfunc broken() {}\n```" var found bool require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, message := range chatWithMessages.Messages { if message.Role != "user" { continue } for _, part := range message.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { found = true return true } } } // The message may have been queued. if created.Queued && created.QueuedMessage != nil { for _, queued := range chatWithMessages.QueuedMessages { for _, part := range queued.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { found = true return true } } } } return false }, testutil.WaitLong, testutil.IntervalFast) require.True(t, found, "expected to find file-reference text in stored message") }) t.Run("FileReferenceSingleLine", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "lib/utils.ts", StartLine: 42, EndLine: 42, Content: "const x = 1;", }}, }) require.NoError(t, err) // Single-line range should use "42" not "42-42". wantText := "[file-reference] lib/utils.ts:42\n" + "```lib/utils.ts\nconst x = 1;\n```" require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, msg := range chatWithMessages.Messages { for _, part := range msg.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } if created.Queued && created.QueuedMessage != nil { for _, queued := range chatWithMessages.QueuedMessages { for _, part := range queued.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } } return false }, testutil.WaitLong, testutil.IntervalFast) }) t.Run("FileReferenceWithoutContent", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "README.md", StartLine: 1, EndLine: 1, // No code content — just a file reference. }}, }) require.NoError(t, err) // No fenced code block when content is empty. wantText := "[file-reference] README.md:1" require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, msg := range chatWithMessages.Messages { for _, part := range msg.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } if created.Queued && created.QueuedMessage != nil { for _, queued := range chatWithMessages.QueuedMessages { for _, part := range queued.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } } return false }, testutil.WaitLong, testutil.IntervalFast) }) t.Run("FileReferenceWithCode", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "server.go", StartLine: 5, EndLine: 8, Content: "func main() {\n\tfmt.Println()\n}", }}, }) require.NoError(t, err) wantText := "[file-reference] server.go:5-8\n" + "```server.go\nfunc main() {\n\tfmt.Println()\n}\n```" require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } for _, msg := range chatWithMessages.Messages { for _, part := range msg.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } if created.Queued && created.QueuedMessage != nil { for _, queued := range chatWithMessages.QueuedMessages { for _, part := range queued.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { return true } } } } return false }, testutil.WaitLong, testutil.IntervalFast) }) t.Run("InterleavedTextAndFileReferences", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "Please review these two issues:", }, { Type: codersdk.ChatInputPartTypeFileReference, FileName: "a.go", StartLine: 1, EndLine: 3, Content: "line1\nline2\nline3", }, { Type: codersdk.ChatInputPartTypeText, Text: "first issue", }, { Type: codersdk.ChatInputPartTypeText, Text: "and also:", }, { Type: codersdk.ChatInputPartTypeFileReference, FileName: "b.go", StartLine: 10, EndLine: 10, Content: "return nil", }, { Type: codersdk.ChatInputPartTypeText, Text: "second issue", }, }, }) require.NoError(t, err) // Verify that all six parts are stored in order. wantTexts := []string{ "Please review these two issues:", "[file-reference] a.go:1-3\n```a.go\nline1\nline2\nline3\n```", "first issue", "and also:", "[file-reference] b.go:10\n```b.go\nreturn nil\n```", "second issue", } require.Eventually(t, func() bool { chatWithMessages, getErr := client.GetChat(ctx, chat.ID) if getErr != nil { return false } // Check messages and queued messages for the // interleaved parts in order. checkParts := func(parts []codersdk.ChatMessagePart) bool { textParts := make([]string, 0, len(parts)) for _, part := range parts { if part.Type == codersdk.ChatMessagePartTypeText { textParts = append(textParts, part.Text) } } if len(textParts) != len(wantTexts) { return false } for i, want := range wantTexts { if textParts[i] != want { return false } } return true } for _, msg := range chatWithMessages.Messages { if msg.Role == "user" && checkParts(msg.Content) { return true } } if created.Queued && created.QueuedMessage != nil { for _, queued := range chatWithMessages.QueuedMessages { if checkParts(queued.Content) { return true } } } return false }, testutil.WaitLong, testutil.IntervalFast) }) t.Run("EmptyFileName", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat := createChatForTest(t, client) _, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "", StartLine: 1, EndLine: 1, }}, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid input part.", sdkErr.Message) require.Equal(t, "content[0].file_name cannot be empty for file-reference.", sdkErr.Detail) }) t.Run("CreateChatWithFileReference", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // File references should also work in the initial CreateChat call. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeFileReference, FileName: "bug.py", StartLine: 7, EndLine: 7, Content: "x = None", }}, }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, chat.ID) // Title is derived from the text parts. For file-references // the formatted text becomes the title source. require.NotEmpty(t, chat.Title) }) } func TestChatMessageWithFiles(t *testing.T) { t.Parallel() t.Run("FileOnly", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(pngData)) require.NoError(t, err) // Create a chat with text first. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "initial message", }, }, }) require.NoError(t, err) // Send a file-only message (no text). resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID, }, }, }) require.NoError(t, err) // Verify the message was accepted. if resp.Queued { require.NotNil(t, resp.QueuedMessage) } else { require.NotNil(t, resp.Message) require.Equal(t, "user", resp.Message.Role) } }) t.Run("TextAndFile", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(pngData)) require.NoError(t, err) // Create a chat with text first. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "initial message", }, }, }) require.NoError(t, err) // Send a message with both text and file. resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "here is an image", }, { Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID, }, }, }) require.NoError(t, err) if resp.Queued { require.NotNil(t, resp.QueuedMessage) } else { require.NotNil(t, resp.Message) require.Equal(t, "user", resp.Message.Role) } // Verify file parts omit inline data in the API response. chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) for _, msg := range chatWithMessages.Messages { for _, part := range msg.Content { if part.Type == codersdk.ChatMessagePartTypeFile { require.True(t, part.FileID.Valid, "file part should have a valid file_id") require.Equal(t, uploadResp.ID, part.FileID.UUID) require.Nil(t, part.Data, "file data should not be sent when file_id is present") } } } }) t.Run("FileOnlyOnCreate", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(pngData)) require.NoError(t, err) // Create a new chat with only a file part. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID, }, }, }) require.NoError(t, err) // With no text, chatTitleFromMessage("") returns "New Chat". require.Equal(t, "New Chat", chat.Title) }) t.Run("InvalidFileID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // Create a chat with text first. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "initial message", }, }, }) require.NoError(t, err) // Send a message with a non-existent file ID. _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeFile, FileID: uuid.New(), }, }, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid input part.", sdkErr.Message) require.Contains(t, sdkErr.Detail, "does not exist") }) } func TestPatchChatMessage(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello before edit", }, }, }) require.NoError(t, err) chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) var userMessageID int64 for _, message := range chatWithMessages.Messages { if message.Role == "user" { userMessageID = message.ID break } } require.NotZero(t, userMessageID) edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello after edit", }, }, }) require.NoError(t, err) require.Equal(t, userMessageID, edited.ID) require.Equal(t, "user", edited.Role) foundEditedText := false for _, part := range edited.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "hello after edit" { foundEditedText = true } } require.True(t, foundEditedText) updatedChat, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) foundEditedInChat := false foundOriginalInChat := false for _, message := range updatedChat.Messages { if message.Role != "user" { continue } for _, part := range message.Content { if part.Type != codersdk.ChatMessagePartTypeText { continue } if part.Text == "hello after edit" { foundEditedInChat = true } if part.Text == "hello before edit" { foundOriginalInChat = true } } } require.True(t, foundEditedInChat) require.False(t, foundOriginalInChat) }) t.Run("PreservesFileID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) // Upload a file. pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(pngData)) require.NoError(t, err) // Create a chat with a text + file part. chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "before edit with file", }, { Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID, }, }, }) require.NoError(t, err) // Find the user message ID. chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) var userMessageID int64 for _, message := range chatWithMessages.Messages { if message.Role == "user" { userMessageID = message.ID break } } require.NotZero(t, userMessageID) // Edit the message: new text, same file_id. edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "after edit with file", }, { Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID, }, }, }) require.NoError(t, err) require.Equal(t, userMessageID, edited.ID) // Assert the edit response preserves the file_id. var foundText, foundFile bool for _, part := range edited.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "after edit with file" { foundText = true } if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid && part.FileID.UUID == uploadResp.ID { foundFile = true require.Nil(t, part.Data, "file data should not be sent when file_id is present") } } require.True(t, foundText, "edited message should contain updated text") require.True(t, foundFile, "edited message should preserve file_id") // GET the chat and verify the file_id persists. updatedChat, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) var foundTextInChat, foundFileInChat bool for _, message := range updatedChat.Messages { if message.Role != "user" { continue } for _, part := range message.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "after edit with file" { foundTextInChat = true } if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid && part.FileID.UUID == uploadResp.ID { foundFileInChat = true require.Nil(t, part.Data, "file data should not be sent when file_id is present") } } } require.True(t, foundTextInChat, "chat should contain edited text") require.True(t, foundFileInChat, "chat should preserve file_id after edit") }) t.Run("MessageNotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, }) require.NoError(t, err) _, err = client.EditChatMessage(ctx, chat.ID, 999999, codersdk.EditChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "edited", }, }, }) sdkErr := requireSDKError(t, err, http.StatusNotFound) require.Equal(t, "Chat message not found.", sdkErr.Message) }) t.Run("InvalidMessageID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "hello", }, }, }) require.NoError(t, err) res, err := client.Request( ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/%s/messages/not-an-int", chat.ID), codersdk.EditChatMessageRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "ignored", }, }, }, ) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat message ID.", sdkErr.Message) }) } func TestStreamChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) const initialMessage = "stream chat route initial message" chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: initialMessage, }, }, }) require.NoError(t, err) events, closer, err := client.StreamChat(ctx, chat.ID, nil) require.NoError(t, err) defer closer.Close() hasTextPart := func(parts []codersdk.ChatMessagePart, want string) bool { for _, part := range parts { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == want { return true } } return false } foundInitialUserMessage := false for !foundInitialUserMessage { select { case <-ctx.Done(): require.FailNow(t, "timed out waiting for expected stream chat event") case event, ok := <-events: require.True(t, ok, "stream closed before expected event") require.Equal(t, chat.ID, event.ChatID) require.NotEqual(t, codersdk.ChatStreamEventTypeError, event.Type) if event.Type == codersdk.ChatStreamEventTypeMessage && event.Message != nil && event.Message.Role == "user" && hasTextPart(event.Message.Content, initialMessage) { foundInitialUserMessage = true } } } }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) unauthenticatedClient := codersdk.New(client.URL) res, err := unauthenticatedClient.Request( ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/stream", uuid.New()), nil, ) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusUnauthorized, res.StatusCode) }) } func TestInterruptChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "interrupt route test", }) require.NoError(t, err) runningWorkerID := uuid.New() chat, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ ID: chat.ID, Status: database.ChatStatusRunning, WorkerID: uuid.NullUUID{UUID: runningWorkerID, Valid: true}, StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, }) require.NoError(t, err) require.Equal(t, database.ChatStatusRunning, chat.Status) require.True(t, chat.WorkerID.Valid) require.True(t, chat.StartedAt.Valid) require.True(t, chat.HeartbeatAt.Valid) interrupted, err := client.InterruptChat(ctx, chat.ID) require.NoError(t, err) require.Equal(t, chat.ID, interrupted.ID) require.Equal(t, codersdk.ChatStatusWaiting, interrupted.Status) persisted, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) require.Equal(t, database.ChatStatusWaiting, persisted.Status) require.False(t, persisted.WorkerID.Valid) require.False(t, persisted.StartedAt.Valid) require.False(t, persisted.HeartbeatAt.Valid) }) t.Run("ChatNotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _, err := client.InterruptChat(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) } func TestGetChatDiffStatus(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), ExternalAuthConfigs: []*externalauth.Config{ { ID: "gitlab-test", Type: "gitlab", Regex: regexp.MustCompile(`github\.com`), }, }, }) db := api.Database user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) noCachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "get diff status route no cache", }) require.NoError(t, err) noCachedStatus, err := client.GetChatDiffStatus(ctx, noCachedStatusChat.ID) require.NoError(t, err) require.Equal(t, noCachedStatusChat.ID, noCachedStatus.ChatID) require.Nil(t, noCachedStatus.URL) require.Nil(t, noCachedStatus.PullRequestState) require.False(t, noCachedStatus.ChangesRequested) require.Zero(t, noCachedStatus.Additions) require.Zero(t, noCachedStatus.Deletions) require.Zero(t, noCachedStatus.ChangedFiles) require.Nil(t, noCachedStatus.RefreshedAt) require.Nil(t, noCachedStatus.StaleAt) cachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "get diff status route cached", }) require.NoError(t, err) refreshedAt := time.Date(2026, time.January, 15, 12, 0, 0, 0, time.UTC) staleAt := time.Date(2026, time.January, 15, 13, 0, 0, 0, time.UTC) _, err = db.UpsertChatDiffStatusReference( dbauthz.AsSystemRestricted(ctx), database.UpsertChatDiffStatusReferenceParams{ ChatID: cachedStatusChat.ID, Url: sql.NullString{}, GitBranch: "feature/diff-status", GitRemoteOrigin: "git@github.com:coder/coder.git", StaleAt: staleAt, }, ) require.NoError(t, err) _, err = db.UpsertChatDiffStatus( dbauthz.AsSystemRestricted(ctx), database.UpsertChatDiffStatusParams{ ChatID: cachedStatusChat.ID, Url: sql.NullString{}, PullRequestState: sql.NullString{ String: " open ", Valid: true, }, ChangesRequested: true, Additions: 11, Deletions: 4, ChangedFiles: 3, RefreshedAt: refreshedAt, StaleAt: staleAt, }, ) require.NoError(t, err) cachedStatus, err := client.GetChatDiffStatus(ctx, cachedStatusChat.ID) require.NoError(t, err) require.Equal(t, cachedStatusChat.ID, cachedStatus.ChatID) require.NotNil(t, cachedStatus.URL) require.Equal(t, "https://github.com/coder/coder/tree/feature%2Fdiff-status", *cachedStatus.URL) require.NotNil(t, cachedStatus.PullRequestState) require.Equal(t, "open", *cachedStatus.PullRequestState) require.True(t, cachedStatus.ChangesRequested) require.EqualValues(t, 11, cachedStatus.Additions) require.EqualValues(t, 4, cachedStatus.Deletions) require.EqualValues(t, 3, cachedStatus.ChangedFiles) require.NotNil(t, cachedStatus.RefreshedAt) require.WithinDuration(t, refreshedAt, *cachedStatus.RefreshedAt, time.Second) require.NotNil(t, cachedStatus.StaleAt) require.WithinDuration(t, staleAt, *cachedStatus.StaleAt, time.Second) }) t.Run("NotFoundForDifferentUser", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "private chat", }, }, }) require.NoError(t, err) otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) _, err = otherClient.GetChatDiffStatus(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) }) } func TestGetChatDiffContents(t *testing.T) { t.Parallel() t.Run("SuccessWithCachedRepositoryReference", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ DeploymentValues: chatDeploymentValues(t), ExternalAuthConfigs: []*externalauth.Config{ { ID: "gitlab-test", Type: "gitlab", Regex: regexp.MustCompile(`gitlab\.example\.com`), }, }, }) db := api.Database user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "diff contents with cached repository reference", }) require.NoError(t, err) _, err = db.UpsertChatDiffStatusReference( dbauthz.AsSystemRestricted(ctx), database.UpsertChatDiffStatusReferenceParams{ ChatID: chat.ID, Url: sql.NullString{}, GitBranch: "feature/cached-diff", GitRemoteOrigin: "https://gitlab.example.com/acme/project.git", StaleAt: time.Now().UTC().Add(time.Hour), }, ) require.NoError(t, err) diffContents, err := client.GetChatDiffContents(ctx, chat.ID) require.NoError(t, err) require.Equal(t, chat.ID, diffContents.ChatID) require.NotNil(t, diffContents.Provider) require.Equal(t, "gitlab", *diffContents.Provider) require.NotNil(t, diffContents.RemoteOrigin) require.Equal(t, "https://gitlab.example.com/acme/project.git", *diffContents.RemoteOrigin) require.NotNil(t, diffContents.Branch) require.Equal(t, "feature/cached-diff", *diffContents.Branch) require.Nil(t, diffContents.PullRequestURL) require.Empty(t, diffContents.Diff) }) t.Run("SuccessWithoutCachedReference", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "diff contents test", }, }, }) require.NoError(t, err) diffContents, err := client.GetChatDiffContents(ctx, chat.ID) require.NoError(t, err) require.Equal(t, chat.ID, diffContents.ChatID) require.Nil(t, diffContents.Provider) require.Nil(t, diffContents.RemoteOrigin) require.Nil(t, diffContents.Branch) require.Nil(t, diffContents.PullRequestURL) require.Empty(t, diffContents.Diff) }) t.Run("NotFoundForDifferentUser", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, Text: "private chat", }, }, }) require.NoError(t, err) otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) _, err = otherClient.GetChatDiffContents(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) }) } func TestDeleteChatQueuedMessage(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "delete queued message route test", }) require.NoError(t, err) queuedMessage, err := db.InsertChatQueuedMessage( dbauthz.AsSystemRestricted(ctx), database.InsertChatQueuedMessageParams{ ChatID: chat.ID, Content: []byte(`"queued message for delete route"`), }, ) require.NoError(t, err) res, err := client.Request( ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/%s/queue/%d", chat.ID, queuedMessage.ID), nil, ) require.NoError(t, err) res.Body.Close() require.Equal(t, http.StatusNoContent, res.StatusCode) chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) for _, queued := range chatWithMessages.QueuedMessages { require.NotEqual(t, queuedMessage.ID, queued.ID) } queuedMessages, err := db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) for _, queued := range queuedMessages { require.NotEqual(t, queuedMessage.ID, queued.ID) } }) t.Run("InvalidQueuedMessageID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "delete queued invalid id", }) require.NoError(t, err) invalidRes, err := client.Request( ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int", chat.ID), nil, ) require.NoError(t, err) defer invalidRes.Body.Close() err = codersdk.ReadBodyAsError(invalidRes) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid queued message ID.", sdkErr.Message) require.Contains(t, sdkErr.Detail, "invalid syntax") }) } func TestPromoteChatQueuedMessage(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "promote queued message route test", }) require.NoError(t, err) const queuedText = "queued message for promote route" queuedMessage, err := db.InsertChatQueuedMessage( dbauthz.AsSystemRestricted(ctx), database.InsertChatQueuedMessageParams{ ChatID: chat.ID, Content: []byte(fmt.Sprintf("%q", queuedText)), }, ) require.NoError(t, err) promoteRes, err := client.Request( ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/queue/%d/promote", chat.ID, queuedMessage.ID), nil, ) require.NoError(t, err) defer promoteRes.Body.Close() require.Equal(t, http.StatusOK, promoteRes.StatusCode) var promoted codersdk.ChatMessage err = json.NewDecoder(promoteRes.Body).Decode(&promoted) require.NoError(t, err) require.NotZero(t, promoted.ID) require.Equal(t, chat.ID, promoted.ChatID) require.Equal(t, "user", promoted.Role) foundPromotedText := false for _, part := range promoted.Content { if part.Type == codersdk.ChatMessagePartTypeText && part.Text == queuedText { foundPromotedText = true break } } require.True(t, foundPromotedText) chatWithMessages, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) for _, queued := range chatWithMessages.QueuedMessages { require.NotEqual(t, queuedMessage.ID, queued.ID) } queuedMessages, err := db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) for _, queued := range queuedMessages { require.NotEqual(t, queuedMessage.ID, queued.ID) } }) t.Run("InvalidQueuedMessageID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) user := coderdtest.CreateFirstUser(t, client) modelConfig := createChatModelConfig(t, client) chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ OwnerID: user.UserID, LastModelConfigID: modelConfig.ID, Title: "promote queued invalid id", }) require.NoError(t, err) invalidRes, err := client.Request( ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/queue/not-an-int/promote", chat.ID), nil, ) require.NoError(t, err) defer invalidRes.Body.Close() err = codersdk.ReadBodyAsError(invalidRes) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid queued message ID.", sdkErr.Message) require.Contains(t, sdkErr.Detail, "invalid syntax") }) } func TestPostChatFile(t *testing.T) { t.Parallel() t.Run("Success/PNG", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) // Valid PNG header + padding. data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.NoError(t, err) require.NotEqual(t, uuid.Nil, resp.ID) }) t.Run("Success/JPEG", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) data := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 64)...) resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/jpeg", "test.jpg", bytes.NewReader(data)) require.NoError(t, err) require.NotEqual(t, uuid.Nil, resp.ID) }) t.Run("Success/WebP", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) // WebP: RIFF + 4-byte size + WEBP + padding. data := append([]byte("RIFF"), make([]byte, 4)...) data = append(data, []byte("WEBP")...) data = append(data, make([]byte, 64)...) resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/webp", "test.webp", bytes.NewReader(data)) require.NoError(t, err) require.NotEqual(t, uuid.Nil, resp.ID) }) t.Run("UnsupportedContentType", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "test.txt", bytes.NewReader([]byte("hello"))) requireSDKError(t, err, http.StatusBadRequest) }) t.Run("SVGBlocked", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/svg+xml", "test.svg", bytes.NewReader([]byte(""))) requireSDKError(t, err, http.StatusBadRequest) }) t.Run("ContentSniffingRejects", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) // Header says PNG but body is plain text. _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader([]byte("hello world"))) requireSDKError(t, err, http.StatusBadRequest) }) t.Run("TooLarge", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) // 10 MB + 1 byte, with valid PNG header to pass MIME check. data := make([]byte, 10<<20+1) copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.Error(t, err) }) t.Run("MissingOrganization", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/files", bytes.NewReader(data), func(r *http.Request) { r.Header.Set("Content-Type", "image/png") }) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Contains(t, sdkErr.Message, "Missing organization") }) t.Run("InvalidOrganization", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/files?organization=not-a-uuid", bytes.NewReader(data), func(r *http.Request) { r.Header.Set("Content-Type", "image/png") }) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Contains(t, sdkErr.Message, "Invalid organization ID") }) t.Run("WrongOrganization", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) _, err := client.UploadChatFile(ctx, uuid.New(), "image/png", "test.png", bytes.NewReader(data)) require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) // dbauthz returns 404 or 500 depending on how the org lookup // fails; 403 is also possible. Any non-success code is valid. require.GreaterOrEqual(t, sdkErr.StatusCode(), http.StatusBadRequest, "expected error status, got %d", sdkErr.StatusCode()) }) t.Run("Unauthenticated", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) unauthed := codersdk.New(client.URL) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) _, err := unauthed.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) requireSDKError(t, err, http.StatusUnauthorized) }) } func TestGetChatFile(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.NoError(t, err) got, contentType, err := client.GetChatFile(ctx, uploaded.ID) require.NoError(t, err) require.Equal(t, "image/png", contentType) require.Equal(t, data, got) }) t.Run("CacheHeaders", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.NoError(t, err) res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, "private, max-age=31536000, immutable", res.Header.Get("Cache-Control")) require.Contains(t, res.Header.Get("Content-Disposition"), "inline") require.Contains(t, res.Header.Get("Content-Disposition"), "test.png") }) t.Run("LongFilename", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) longName := strings.Repeat("a", 300) + ".png" data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", longName, bytes.NewReader(data)) require.NoError(t, err) res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/files/%s", uploaded.ID), nil) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) // Filename should be truncated to maxChatFileName (255) bytes. cd := res.Header.Get("Content-Disposition") require.Contains(t, cd, "inline") require.Contains(t, cd, strings.Repeat("a", 255)) require.NotContains(t, cd, strings.Repeat("a", 256)) }) t.Run("NotFound", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) coderdtest.CreateFirstUser(t, client) _, _, err := client.GetChatFile(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) t.Run("InvalidUUID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) coderdtest.CreateFirstUser(t, client) res, err := client.Request(ctx, http.MethodGet, "/api/experimental/chats/files/not-a-uuid", nil) require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) requireSDKError(t, err, http.StatusBadRequest) }) t.Run("OtherUserForbidden", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.NoError(t, err) otherClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) _, _, err = otherClient.GetChatFile(ctx, uploaded.ID) requireSDKError(t, err, http.StatusNotFound) }) } func createChatModelConfig(t *testing.T, client *codersdk.Client) codersdk.ChatModelConfig { t.Helper() ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ Provider: "openai", APIKey: "test-api-key", }) require.NoError(t, err) contextLimit := int64(4096) isDefault := true modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", ContextLimit: &contextLimit, IsDefault: &isDefault, }) require.NoError(t, err) return modelConfig } //nolint:tparallel,paralleltest // Subtests share a single coderdtest instance. func TestChatSystemPrompt(t *testing.T) { t.Parallel() adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) resp, err := adminClient.GetChatSystemPrompt(ctx) require.NoError(t, err) require.Equal(t, "", resp.SystemPrompt) }) t.Run("AdminCanSet", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "You are a helpful coding assistant.", }) require.NoError(t, err) resp, err := adminClient.GetChatSystemPrompt(ctx) require.NoError(t, err) require.Equal(t, "You are a helpful coding assistant.", resp.SystemPrompt) }) t.Run("AdminCanUnset", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Unset by sending an empty string. err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "", }) require.NoError(t, err) resp, err := adminClient.GetChatSystemPrompt(ctx) require.NoError(t, err) require.Equal(t, "", resp.SystemPrompt) }) t.Run("NonAdminFails", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: "This should fail.", }) requireSDKError(t, err, http.StatusNotFound) }) t.Run("UnauthenticatedFails", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) anonClient := codersdk.New(adminClient.URL) _, err := anonClient.GetChatSystemPrompt(ctx) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) }) t.Run("TooLong", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) tooLong := strings.Repeat("a", 131073) err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{ SystemPrompt: tooLong, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message) }) } func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error { t.Helper() var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, expectedStatus, sdkErr.StatusCode()) return sdkErr }