coderd/exp_chats_test.go
Rama main · réplica de lectura del repositorio original.
package coderd_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"
"cdr.dev/slog/v3/sloggers/slogtest"
aibclient "github.com/coder/coder/v2/aibridge/client"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/aibridgedtest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/x/agenthooks"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
func newChatTestOptions(
t testing.TB,
values *codersdk.DeploymentValues,
overrides ...func(*coderdtest.Options),
) *coderdtest.Options {
t.Helper()
// Enable experiment-gated chat endpoints in tests.
if len(values.Experiments) == 0 {
values.Experiments = serpent.StringArray{
string(codersdk.ExperimentChatVirtualDesktop),
string(codersdk.ExperimentAgentLifecycleHooks),
string(codersdk.ExperimentChatInlineMCPServers),
}
}
opts := &coderdtest.Options{
DeploymentValues: values,
}
for _, override := range overrides {
override(opts)
}
return opts
}
func newChatClient(t testing.TB, overrides ...func(*coderdtest.Options)) *codersdk.ExperimentalClient {
t.Helper()
opts := newChatTestOptions(t, coderdtest.DeploymentValues(t), overrides...)
client, _, api := coderdtest.NewWithAPI(t, opts)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
return codersdk.NewExperimentalClient(client)
}
func newChatClientWithAPI(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, *coderd.API) {
t.Helper()
opts := newChatTestOptions(t, coderdtest.DeploymentValues(t), overrides...)
client, _, api := coderdtest.NewWithAPI(t, opts)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
return codersdk.NewExperimentalClient(client), api
}
func newChatClientWithDeploymentValues(
t testing.TB,
values *codersdk.DeploymentValues,
) *codersdk.ExperimentalClient {
t.Helper()
opts := newChatTestOptions(t, values)
client, _, api := coderdtest.NewWithAPI(t, opts)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
return codersdk.NewExperimentalClient(client)
}
func newChatClientWithDatabase(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, database.Store) {
t.Helper()
opts := newChatTestOptions(t, coderdtest.DeploymentValues(t), overrides...)
client, _, api := coderdtest.NewWithAPI(t, opts)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
return codersdk.NewExperimentalClient(client), api.Database
}
func newChatClientWithAPIAndDatabase(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, database.Store, *coderd.API) {
t.Helper()
opts := newChatTestOptions(t, coderdtest.DeploymentValues(t), overrides...)
client, _, api := coderdtest.NewWithAPI(t, opts)
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
return codersdk.NewExperimentalClient(client), api.Database, api
}
func newChatClientWithoutAIBridge(t testing.TB, overrides ...func(*coderdtest.Options)) (*codersdk.ExperimentalClient, database.Store, *coderd.API) {
t.Helper()
opts := newChatTestOptions(t, coderdtest.DeploymentValues(t), overrides...)
client, _, api := coderdtest.NewWithAPI(t, opts)
return codersdk.NewExperimentalClient(client), api.Database, api
}
func insertTestChatQueuedMessage(
ctx context.Context,
t testing.TB,
db database.Store,
chatID uuid.UUID,
content json.RawMessage,
modelConfigID uuid.UUID,
) database.ChatQueuedMessage {
t.Helper()
return insertTestChatQueuedMessageWithReasoningEffort(ctx, t, db, chatID, content, modelConfigID, "")
}
func insertTestChatQueuedMessageWithReasoningEffort(
ctx context.Context,
t testing.TB,
db database.Store,
chatID uuid.UUID,
content json.RawMessage,
modelConfigID uuid.UUID,
reasoningEffort string,
) database.ChatQueuedMessage {
t.Helper()
queued, err := db.InsertChatQueuedMessage(
dbauthz.AsSystemRestricted(ctx),
database.InsertChatQueuedMessageParams{
ChatID: chatID,
Content: content,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
ReasoningEffort: database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffort(reasoningEffort), Valid: reasoningEffort != ""},
},
)
require.NoError(t, err)
return queued
}
// findUserMessage returns the first user-role message from a slice of chat
// messages, failing the test if none is found.
func findUserMessage(t testing.TB, messages []database.ChatMessage) database.ChatMessage {
t.Helper()
idx := slices.IndexFunc(messages, func(m database.ChatMessage) bool {
return m.Role == database.ChatMessageRoleUser
})
require.NotEqual(t, -1, idx, "expected to find a user message")
return messages[idx]
}
// failNextChatSystemPromptStore lets a test force single chat system prompt
// queries to fail. One-shot flags disarm when the forced failure fires, and
// armed counters fail every matching read until the armed count reaches zero.
// Flags and counters are shared across InTx wrappers.
type failNextChatSystemPromptStore struct {
database.Store
failNextGetChatIncludeDefaultSystemPrompt *atomic.Bool
failNextGetChatSystemPromptConfig *atomic.Bool
armedGetChatSystemPromptConfigFailures *atomic.Int64
getChatSystemPromptConfigCallsBeforeFailure *atomic.Int64
failNextUpsertChatIncludeDefaultSystemPrompt *atomic.Bool
failNextGetChatOrganizationSystemPrompt *atomic.Bool
}
func newFailNextChatSystemPromptStore(store database.Store) *failNextChatSystemPromptStore {
return &failNextChatSystemPromptStore{
Store: store,
failNextGetChatIncludeDefaultSystemPrompt: &atomic.Bool{},
failNextGetChatSystemPromptConfig: &atomic.Bool{},
armedGetChatSystemPromptConfigFailures: &atomic.Int64{},
getChatSystemPromptConfigCallsBeforeFailure: &atomic.Int64{},
failNextUpsertChatIncludeDefaultSystemPrompt: &atomic.Bool{},
failNextGetChatOrganizationSystemPrompt: &atomic.Bool{},
}
}
func (s *failNextChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextChatSystemPromptStore{
Store: tx,
failNextGetChatSystemPromptConfig: s.failNextGetChatSystemPromptConfig,
armedGetChatSystemPromptConfigFailures: s.armedGetChatSystemPromptConfigFailures,
getChatSystemPromptConfigCallsBeforeFailure: s.getChatSystemPromptConfigCallsBeforeFailure,
failNextUpsertChatIncludeDefaultSystemPrompt: s.failNextUpsertChatIncludeDefaultSystemPrompt,
failNextGetChatOrganizationSystemPrompt: s.failNextGetChatOrganizationSystemPrompt,
})
}, txOpts)
}
func (s *failNextChatSystemPromptStore) GetChatOrganizationSystemPrompt(ctx context.Context, organizationID uuid.UUID) (database.ChatOrganizationSystemPrompt, error) {
if s.failNextGetChatOrganizationSystemPrompt.CompareAndSwap(true, false) {
return database.ChatOrganizationSystemPrompt{}, stderrors.New("forced organization system prompt read failure")
}
return s.Store.GetChatOrganizationSystemPrompt(ctx, organizationID)
}
func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
if s.failNextGetChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) {
return false, stderrors.New("forced include-default read failure")
}
return s.Store.GetChatIncludeDefaultSystemPrompt(ctx)
}
func (s *failNextChatSystemPromptStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefault bool) error {
if s.failNextUpsertChatIncludeDefaultSystemPrompt.CompareAndSwap(true, false) {
return stderrors.New("forced include-default upsert failure")
}
return s.Store.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefault)
}
func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
if s.failNextGetChatSystemPromptConfig.CompareAndSwap(true, false) {
return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure")
}
// Armed failures fire on every read until the armed count is spent.
// Best-effort audit captures swallow these errors without retrying,
// which is why a one-shot flag is not enough: the failure would stay
// latent and fire on a later, unrelated read.
if s.armedGetChatSystemPromptConfigFailures != nil && s.armedGetChatSystemPromptConfigFailures.Load() > 0 {
// When a skip count is set, the first N reads succeed so failures
// can be aimed at a specific read, e.g. the New re-read after the
// Old capture.
if s.getChatSystemPromptConfigCallsBeforeFailure != nil && s.getChatSystemPromptConfigCallsBeforeFailure.Load() > 0 {
s.getChatSystemPromptConfigCallsBeforeFailure.Add(-1)
return s.Store.GetChatSystemPromptConfig(ctx)
}
s.armedGetChatSystemPromptConfigFailures.Add(-1)
return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure")
}
return s.Store.GetChatSystemPromptConfig(ctx)
}
// failNextUpsertChatSystemPromptStore lets a test force the system-prompt
// upsert to fail once, sharing its failure state across InTx wrappers.
type failNextUpsertChatSystemPromptStore struct {
database.Store
failNextUpsertChatSystemPrompt *atomic.Bool
}
func newFailNextUpsertChatSystemPromptStore(store database.Store) *failNextUpsertChatSystemPromptStore {
return &failNextUpsertChatSystemPromptStore{
Store: store,
failNextUpsertChatSystemPrompt: &atomic.Bool{},
}
}
func (s *failNextUpsertChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextUpsertChatSystemPromptStore{
Store: tx,
failNextUpsertChatSystemPrompt: s.failNextUpsertChatSystemPrompt,
})
}, txOpts)
}
func (s *failNextUpsertChatSystemPromptStore) UpsertChatSystemPrompt(ctx context.Context, prompt string) error {
if s.failNextUpsertChatSystemPrompt.CompareAndSwap(true, false) {
return stderrors.New("forced system prompt upsert failure")
}
return s.Store.UpsertChatSystemPrompt(ctx, prompt)
}
// failNextAcquireLockStore lets a test force the next advisory lock
// acquisition for one lock ID to fail with a deadline error, simulating a
// lock wait that timed out. The failure state is shared across InTx
// wrappers.
type failNextAcquireLockStore struct {
database.Store
lockID int64
failNextAcquireLock *atomic.Bool
}
func newFailNextAcquireLockStore(store database.Store, lockID int64) *failNextAcquireLockStore {
return &failNextAcquireLockStore{
Store: store,
lockID: lockID,
failNextAcquireLock: &atomic.Bool{},
}
}
func (s *failNextAcquireLockStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextAcquireLockStore{
Store: tx,
lockID: s.lockID,
failNextAcquireLock: s.failNextAcquireLock,
})
}, txOpts)
}
func (s *failNextAcquireLockStore) AcquireLock(ctx context.Context, id int64) error {
if id == s.lockID && s.failNextAcquireLock.CompareAndSwap(true, false) {
return context.DeadlineExceeded
}
return s.Store.AcquireLock(ctx, id)
}
type failNextTxStore struct {
database.Store
txID string
failNextTransaction *atomic.Bool
}
func newFailNextTxStore(store database.Store, txID string) *failNextTxStore {
return &failNextTxStore{
Store: store,
txID: txID,
failNextTransaction: &atomic.Bool{},
}
}
func (s *failNextTxStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
if txOpts != nil && txOpts.TxIdentifier == s.txID && s.failNextTransaction.CompareAndSwap(true, false) {
return stderrors.New("forced transaction failure for " + s.txID)
}
return s.Store.InTx(function, txOpts)
}
// failNextGetChatPlanModeInstructionsStore lets a test force the plan-mode
// instructions baseline read to fail once. Failure state is shared across InTx
// wrappers.
type failNextGetChatPlanModeInstructionsStore struct {
database.Store
failNextGetChatPlanModeInstructions *atomic.Bool
}
func newFailNextGetChatPlanModeInstructionsStore(store database.Store) *failNextGetChatPlanModeInstructionsStore {
return &failNextGetChatPlanModeInstructionsStore{
Store: store,
failNextGetChatPlanModeInstructions: &atomic.Bool{},
}
}
func (s *failNextGetChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextGetChatPlanModeInstructionsStore{
Store: tx,
failNextGetChatPlanModeInstructions: s.failNextGetChatPlanModeInstructions,
})
}, txOpts)
}
func (s *failNextGetChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
if s.failNextGetChatPlanModeInstructions.CompareAndSwap(true, false) {
return "", stderrors.New("forced plan mode instructions read failure")
}
return s.Store.GetChatPlanModeInstructions(ctx)
}
// failNextUpsertChatPlanModeInstructionsStore lets a test force the plan-mode
// instructions upsert to fail once, sharing its failure state across InTx
// wrappers.
type failNextUpsertChatPlanModeInstructionsStore struct {
database.Store
failNextUpsertChatPlanModeInstructions *atomic.Bool
}
func newFailNextUpsertChatPlanModeInstructionsStore(store database.Store) *failNextUpsertChatPlanModeInstructionsStore {
return &failNextUpsertChatPlanModeInstructionsStore{
Store: store,
failNextUpsertChatPlanModeInstructions: &atomic.Bool{},
}
}
func (s *failNextUpsertChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextUpsertChatPlanModeInstructionsStore{
Store: tx,
failNextUpsertChatPlanModeInstructions: s.failNextUpsertChatPlanModeInstructions,
})
}, txOpts)
}
func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstructions(ctx context.Context, instructions string) error {
if s.failNextUpsertChatPlanModeInstructions.CompareAndSwap(true, false) {
return stderrors.New("forced plan mode instructions upsert failure")
}
return s.Store.UpsertChatPlanModeInstructions(ctx, instructions)
}
type chatModelConfigLockedReadMutation struct {
id uuid.UUID
model string
}
// chatModelConfigHookStore forces a specific in-transaction chat model config
// operation to behave abnormally. Each hook targets one ID and consumes
// itself on first match. The single-consume gate uses atomic.Pointer so a
// concurrent InTx wrapper cannot race with the arming goroutine.
type chatModelConfigHookStore struct {
database.Store
inTx bool
failNextUpdate *atomic.Pointer[uuid.UUID]
failNextDelete *atomic.Pointer[uuid.UUID]
failProviderReferenceLock *atomic.Pointer[uuid.UUID]
vanishAtLockedRead *atomic.Pointer[uuid.UUID]
mutateAtLockedRead *atomic.Pointer[chatModelConfigLockedReadMutation]
}
func newChatModelConfigHookStore(store database.Store) *chatModelConfigHookStore {
return &chatModelConfigHookStore{
Store: store,
failNextUpdate: &atomic.Pointer[uuid.UUID]{},
failNextDelete: &atomic.Pointer[uuid.UUID]{},
failProviderReferenceLock: &atomic.Pointer[uuid.UUID]{},
vanishAtLockedRead: &atomic.Pointer[uuid.UUID]{},
mutateAtLockedRead: &atomic.Pointer[chatModelConfigLockedReadMutation]{},
}
}
func newChatClientWithModelConfigHookStore(t testing.TB) (*codersdk.ExperimentalClient, *chatModelConfigHookStore) {
t.Helper()
rawDB, pubsub := dbtestutil.NewDB(t)
store := newChatModelConfigHookStore(rawDB)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = store
opts.Pubsub = pubsub
})
return client, store
}
func (s *chatModelConfigHookStore) armFailNextUpdate(id uuid.UUID) {
s.failNextUpdate.Store(&id)
}
func (s *chatModelConfigHookStore) armFailNextDelete(id uuid.UUID) {
s.failNextDelete.Store(&id)
}
func (s *chatModelConfigHookStore) armFailProviderReferenceLock(id uuid.UUID) {
s.failProviderReferenceLock.Store(&id)
}
func (s *chatModelConfigHookStore) armVanishAtLockedRead(id uuid.UUID) {
s.vanishAtLockedRead.Store(&id)
}
func (s *chatModelConfigHookStore) armMutateAtLockedRead(id uuid.UUID, model string) {
s.mutateAtLockedRead.Store(&chatModelConfigLockedReadMutation{id: id, model: model})
}
func (s *chatModelConfigHookStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&chatModelConfigHookStore{
Store: tx,
inTx: true,
failNextUpdate: s.failNextUpdate,
failNextDelete: s.failNextDelete,
failProviderReferenceLock: s.failProviderReferenceLock,
vanishAtLockedRead: s.vanishAtLockedRead,
mutateAtLockedRead: s.mutateAtLockedRead,
})
}, txOpts)
}
func consumeChatModelConfigHook(hook *atomic.Pointer[uuid.UUID], id uuid.UUID) bool {
target := hook.Load()
if target == nil || *target != id {
return false
}
return hook.CompareAndSwap(target, nil)
}
func (s *chatModelConfigHookStore) GetAIProviderByIDForReferenceLock(
ctx context.Context,
id uuid.UUID,
) (database.AIProvider, error) {
if consumeChatModelConfigHook(s.failProviderReferenceLock, id) {
return database.AIProvider{}, stderrors.New("forced provider reference lock failure")
}
return s.Store.GetAIProviderByIDForReferenceLock(ctx, id)
}
func (s *chatModelConfigHookStore) UpdateChatModelConfig(
ctx context.Context,
arg database.UpdateChatModelConfigParams,
) (database.ChatModelConfig, error) {
if consumeChatModelConfigHook(s.failNextUpdate, arg.ID) {
return database.ChatModelConfig{}, sql.ErrNoRows
}
return s.Store.UpdateChatModelConfig(ctx, arg)
}
func (s *chatModelConfigHookStore) DeleteChatModelConfigByID(
ctx context.Context,
id uuid.UUID,
) (uuid.UUID, error) {
if consumeChatModelConfigHook(s.failNextDelete, id) {
return uuid.Nil, sql.ErrNoRows
}
return s.Store.DeleteChatModelConfigByID(ctx, id)
}
func (s *chatModelConfigHookStore) GetChatModelConfigByID(
ctx context.Context,
id uuid.UUID,
) (database.ChatModelConfig, error) {
if s.inTx && consumeChatModelConfigHook(s.vanishAtLockedRead, id) {
return database.ChatModelConfig{}, sql.ErrNoRows
}
if s.inTx {
mutation := s.mutateAtLockedRead.Load()
if mutation != nil && mutation.id == id && s.mutateAtLockedRead.CompareAndSwap(mutation, nil) {
row, err := s.Store.GetChatModelConfigByID(ctx, id)
if err != nil {
return row, err
}
row.Model = mutation.model
return row, nil
}
}
return s.Store.GetChatModelConfigByID(ctx, id)
}
type failNextGetChatSiteConfigValueStore struct {
database.Store
failNextGetChatSiteConfigValue *atomic.Bool
}
type failNextUpsertChatRetentionDaysStore struct {
database.Store
failNextUpsert *atomic.Bool
}
func newFailNextUpsertChatRetentionDaysStore(store database.Store) *failNextUpsertChatRetentionDaysStore {
return &failNextUpsertChatRetentionDaysStore{
Store: store,
failNextUpsert: &atomic.Bool{},
}
}
func (s *failNextUpsertChatRetentionDaysStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextUpsertChatRetentionDaysStore{
Store: tx,
failNextUpsert: s.failNextUpsert,
})
}, txOpts)
}
func (s *failNextUpsertChatRetentionDaysStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
if s.failNextUpsert.CompareAndSwap(true, false) {
return stderrors.New("forced chat retention days upsert failure")
}
return s.Store.UpsertChatRetentionDays(ctx, retentionDays)
}
func newFailNextGetChatSiteConfigValueStore(store database.Store) *failNextGetChatSiteConfigValueStore {
return &failNextGetChatSiteConfigValueStore{
Store: store,
failNextGetChatSiteConfigValue: &atomic.Bool{},
}
}
func (s *failNextGetChatSiteConfigValueStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return function(&failNextGetChatSiteConfigValueStore{
Store: tx,
failNextGetChatSiteConfigValue: s.failNextGetChatSiteConfigValue,
})
}, txOpts)
}
func (s *failNextGetChatSiteConfigValueStore) GetChatSiteConfigValue(
ctx context.Context,
configKey string,
) (database.GetChatSiteConfigValueRow, error) {
if s.failNextGetChatSiteConfigValue.CompareAndSwap(true, false) {
return database.GetChatSiteConfigValueRow{}, stderrors.New("forced chat site configuration read failure")
}
return s.Store.GetChatSiteConfigValue(ctx, configKey)
}
func insertAssistantMessage(
t *testing.T,
db database.Store,
chatID uuid.UUID,
modelConfigID uuid.UUID,
) {
t.Helper()
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("assistant"),
})
require.NoError(t, err)
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Content: assistantContent,
})
}
func TestPostChats(t *testing.T) {
t.Parallel()
t.Run("SuccessNonDefaultOrgUsesOrgDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
org := dbgen.Organization(t, db, database.Organization{IsDefault: false})
// The org's Everyone group shares the org ID (migration 000058).
dbgen.Group(t, db, database.Group{
ID: org.ID,
Name: database.EveryoneGroup,
OrganizationID: org.ID,
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
contextLimit := int64(4096)
modelConfig, err := client.CreateChatModel(ctx, org.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
require.True(t, modelConfig.IsDefault, "first config in the org must self-elect as default")
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: org.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello from a non-default org",
}},
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
})
t.Run("Rejects", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
newOrgMember := func(t *testing.T) (uuid.UUID, *codersdk.ExperimentalClient) {
t.Helper()
org := dbgen.Organization(t, db, database.Organization{})
orgMemberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID)
return org.ID, codersdk.NewExperimentalClient(orgMemberRaw)
}
crossOrgMCPServerID, thirdOrgMCPServerID := uuid.New(), uuid.New()
// Parallel cases share a deployment, so prepare must not mutate
// shared fixtures.
tests := []struct {
name string
prepare func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient
wantStatus int
wantMessage string
wantDetail string
}{
{
name: "NonDefaultOrgDoesNotUseDeploymentDefault",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
req.OrganizationID = orgID
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "No chat model is available in this organization.",
wantDetail: "Ask an organization administrator to configure and enable a chat model.",
},
{
name: "DisabledLocalDefaultDoesNotUseDefaultOrgDefault",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
Model: "disabled-local-default-" + uuid.NewString(),
OrganizationID: orgID,
IsDefault: true,
}, func(params *database.InsertChatModelConfigParams) {
params.Enabled = false
})
req.OrganizationID = orgID
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "No chat model is available in this organization.",
wantDetail: "Ask an organization administrator to configure and enable a chat model.",
},
{
name: "DefaultOrgExplicitModelRejectedForNonDefaultOrg",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
req.OrganizationID = orgID
req.ModelConfigID = ptr.Ref(defaultConfig.ID)
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid model_config_id: model config not found or disabled.",
},
{
name: "ThirdOrgExplicitModelRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
thirdOrg := dbgen.Organization(t, db, database.Organization{})
thirdConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
Model: "third-org-" + uuid.NewString(),
Enabled: true,
IsDefault: true,
OrganizationID: thirdOrg.ID,
})
req.OrganizationID = orgID
req.ModelConfigID = ptr.Ref(thirdConfig.ID)
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid model_config_id: model config not found or disabled.",
},
{
name: "MCPServerIDsCrossOrgRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
ID: crossOrgMCPServerID,
OrganizationID: firstUser.OrganizationID,
Enabled: true,
})
orgID, orgMember := newOrgMember(t)
dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
OrganizationID: orgID,
IsDefault: true,
})
req.OrganizationID = orgID
req.MCPServerIDs = []uuid.UUID{crossOrgMCPServerID}
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "One or more MCP server IDs are invalid or disabled.",
wantDetail: "Invalid IDs: " + crossOrgMCPServerID.String(),
},
{
name: "MCPServerIDsThirdOrgRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
thirdOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
ID: thirdOrgMCPServerID,
OrganizationID: thirdOrg.ID,
Enabled: true,
})
orgID, orgMember := newOrgMember(t)
dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
OrganizationID: orgID,
IsDefault: true,
})
req.OrganizationID = orgID
req.MCPServerIDs = []uuid.UUID{thirdOrgMCPServerID}
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "One or more MCP server IDs are invalid or disabled.",
wantDetail: "Invalid IDs: " + thirdOrgMCPServerID.String(),
},
{
name: "RejectsInvalidReasoningEffort",
prepare: func(_ *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
req.ReasoningEffort = ptr.Ref(" HIGH ")
return client
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid reasoning_effort value.",
wantDetail: `Invalid value " HIGH ", must be one of none, minimal, low, medium, high, xhigh, max`,
},
{
name: "DisabledModelConfigRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
disabledConfig := createDisabledChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"gpt-4o-create-disabled-"+uuid.NewString(),
)
req.ModelConfigID = ptr.Ref(disabledConfig.ID)
return client
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid model_config_id: model config not found or disabled.",
},
{
name: "ProviderDisabledModelConfigRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
providerDisabledConfig := createProviderDisabledChatModel(
t,
client,
"openai",
"gpt-4o-create-provider-disabled-"+uuid.NewString(),
)
req.ModelConfigID = ptr.Ref(providerDisabledConfig.ID)
return client
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid model_config_id: provider is not enabled for this model.",
},
{
name: "DeniedSameOrganizationExplicitModelIsRejected",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
Model: "private-explicit-" + uuid.NewString(),
Enabled: true,
OrganizationID: firstUser.OrganizationID,
GroupACL: database.ChatACL{},
})
req.ModelConfigID = ptr.Ref(privateConfig.ID)
return memberClient
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid model_config_id: model config not found or disabled.",
},
{
name: "WorkspaceNotAccessible",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
req.WorkspaceID = &workspaceBuild.Workspace.ID
return memberClient
},
wantStatus: http.StatusBadRequest,
wantMessage: "Workspace not found or you do not have access to this resource",
},
{
name: "WorkspaceAccessibleButNoSSH",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgAdminClientRaw, _ := coderdtest.CreateAnotherUser(
t,
client.Client,
firstUser.OrganizationID,
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID),
)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
req.WorkspaceID = &workspaceBuild.Workspace.ID
return codersdk.NewExperimentalClient(orgAdminClientRaw)
},
wantStatus: http.StatusBadRequest,
wantMessage: "Workspace not found or you do not have access to this resource",
},
{
name: "WorkspaceNotFound",
prepare: func(_ *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
req.WorkspaceID = ptr.Ref(uuid.New())
return client
},
wantStatus: http.StatusBadRequest,
wantMessage: "Workspace not found or you do not have access to this resource",
},
{
name: "EmptyText",
// No model exists in the new org, so this also checks that
// input is rejected before model resolution.
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
req.OrganizationID = orgID
req.Content[0].Text = " "
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid input part.",
wantDetail: "content[0].text cannot be empty.",
},
{
name: "UnsupportedPartType",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
orgID, orgMember := newOrgMember(t)
req.OrganizationID = orgID
req.Content[0].Type = codersdk.ChatInputPartType("image")
return orgMember
},
wantStatus: http.StatusBadRequest,
wantMessage: "Invalid input part.",
wantDetail: `content[0].type "image" is not supported.`,
},
{
name: "NilOrganizationID",
prepare: func(_ *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
req.OrganizationID = uuid.Nil
return memberClient
},
wantStatus: http.StatusBadRequest,
wantMessage: "organization_id is required.",
},
{
name: "NonMemberOrganization",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
req.OrganizationID = dbgen.Organization(t, db, database.Organization{}).ID
return memberClient
},
wantStatus: http.StatusForbidden,
wantMessage: "You are not a member of the specified organization.",
},
{
name: "CrossOrgWorkspaceMismatch",
prepare: func(t *testing.T, req *codersdk.CreateChatRequest) *codersdk.ExperimentalClient {
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
// The admin joins the second organization so the request
// passes the membership check and fails on the workspace.
secondOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: secondOrg.ID,
UserID: firstUser.UserID,
})
req.OrganizationID = secondOrg.ID
req.WorkspaceID = &workspaceBuild.Workspace.ID
return client
},
wantStatus: http.StatusBadRequest,
wantMessage: "Workspace does not belong to the specified organization.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
req := codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
}
chatClient := tt.prepare(t, &req)
_, err := chatClient.CreateChat(ctx, req)
sdkErr := requireSDKError(t, err, tt.wantStatus)
require.Equal(t, tt.wantMessage, sdkErr.Message)
require.Equal(t, tt.wantDetail, sdkErr.Detail)
})
}
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
PlanMode: codersdk.ChatPlanModePlan,
OrganizationID: firstUser.OrganizationID, 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, member.ID, chat.OwnerID)
require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
require.Equal(t, "hello from chats route tests", chat.Title)
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)
chatResult, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
messagesResult, err := memberClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Equal(t, chat.ID, chatResult.ID)
require.Equal(t, codersdk.ChatPlanModePlan, chatResult.PlanMode)
foundUserMessage := false
for _, message := range messagesResult.Messages {
if message.Role != codersdk.ChatMessageRoleUser {
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)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionCreate,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
ResourceTarget: chat.ID.String()[:8],
UserID: member.ID,
}))
})
t.Run("MCPServerIDsDuplicatesNormalized", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
coderdtest.CreateFirstUser(t, client.Client)
deploymentDefault := createChatModel(t, client)
secondOrg := dbgen.Organization(t, db, database.Organization{})
_ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: deploymentDefault.AIProviderID, Valid: true},
OrganizationID: secondOrg.ID,
IsDefault: true,
})
orgConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: secondOrg.ID,
Enabled: true,
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "chat with a duplicated MCP server ID",
},
},
MCPServerIDs: []uuid.UUID{orgConfig.ID, orgConfig.ID},
})
require.NoError(t, err)
require.Equal(t, []uuid.UUID{orgConfig.ID}, chat.MCPServerIDs)
_, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "update to a duplicated MCP server ID",
},
},
MCPServerIDs: &[]uuid.UUID{orgConfig.ID, orgConfig.ID},
})
require.NoError(t, err)
storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.Equal(t, []uuid.UUID{orgConfig.ID}, storedChat.MCPServerIDs)
})
t.Run("MCPServerIDsDisabledConfigRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
enabledCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
Enabled: true,
})
disabledCfg, err := client.UpdateMCPServerConfig(ctx, enabledCfg.OrganizationID, enabledCfg.ID, codersdk.UpdateMCPServerConfigRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "chat with a disabled MCP server",
},
},
MCPServerIDs: []uuid.UUID{disabledCfg.ID},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message)
require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "chat without MCP servers",
},
},
})
require.NoError(t, err)
_, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "selecting the disabled config",
},
},
MCPServerIDs: &[]uuid.UUID{disabledCfg.ID},
})
sdkErr = requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message)
require.Equal(t, "Invalid IDs: "+disabledCfg.ID.String(), sdkErr.Detail)
})
t.Run("MCPServerIDsPersistedDisabledStillSendable", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
cfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
Enabled: true,
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "chat with a server that is disabled later",
},
},
MCPServerIDs: []uuid.UUID{cfg.ID},
})
require.NoError(t, err)
_, err = client.UpdateMCPServerConfig(ctx, cfg.OrganizationID, cfg.ID, codersdk.UpdateMCPServerConfigRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
// The frontend resubmits the persisted selection on every
// send, so a server disabled after selection must not block
// the send.
_, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "still sendable after the server was disabled",
},
},
MCPServerIDs: &[]uuid.UUID{cfg.ID},
})
require.NoError(t, err)
storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.Equal(t, []uuid.UUID{cfg.ID}, storedChat.MCPServerIDs)
secondCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: firstUser.OrganizationID,
Enabled: true,
})
_, err = client.UpdateMCPServerConfig(ctx, secondCfg.OrganizationID, secondCfg.ID, codersdk.UpdateMCPServerConfigRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
_, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "adding another disabled server is rejected",
},
},
MCPServerIDs: &[]uuid.UUID{cfg.ID, secondCfg.ID},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "One or more MCP server IDs are invalid or disabled.", sdkErr.Message)
require.Equal(t, "Invalid IDs: "+secondCfg.ID.String(), sdkErr.Detail)
})
t.Run("MemberCanCreateChat", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello from an organization member",
},
},
})
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)
})
t.Run("AgentsAccessDefaultRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
createChat := func() error {
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
},
})
return err
}
setDefaults := func(roles []string) {
t.Helper()
org, err := rawDB.GetOrganizationByID(ctx, firstUser.OrganizationID)
require.NoError(t, err)
_, err = rawDB.UpdateOrganization(ctx, database.UpdateOrganizationParams{
ID: org.ID,
UpdatedAt: dbtime.Now(),
Name: org.Name,
DisplayName: org.DisplayName,
Description: org.Description,
Icon: org.Icon,
DefaultOrgMemberRoles: roles,
})
require.NoError(t, err)
}
setMemberRoles := func(roles []string) {
t.Helper()
_, err := client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.UpdateRoles{Roles: roles})
require.NoError(t, err)
}
setDefaults([]string{codersdk.RoleOrganizationWorkspaceAccess})
requireSDKError(t, createChat(), http.StatusForbidden)
setMemberRoles([]string{codersdk.RoleAgentsAccess})
require.NoError(t, createChat())
setMemberRoles([]string{})
requireSDKError(t, createChat(), http.StatusForbidden)
setDefaults(rbac.DefaultOrgMemberRoles())
require.NoError(t, createChat())
})
t.Run("WithReasoningEffort", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "think hard from the start",
},
},
ReasoningEffort: ptr.Ref("high"),
})
require.NoError(t, err)
storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.True(t, storedChat.LastReasoningEffort.Valid)
require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort)
messages, err := db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
require.NoError(t, err)
userMsg := findUserMessage(t, messages)
require.True(t, userMsg.ReasoningEffort.Valid)
require.Equal(t, database.ChatReasoningEffortHigh, userMsg.ReasoningEffort.ChatReasoningEffort)
})
t.Run("HidesSystemPromptMessages", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "verify hidden system prompt",
},
},
})
require.NoError(t, err)
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
for _, message := range messagesResult.Messages {
require.NotEqual(t, codersdk.ChatMessageRoleSystem, message.Role)
}
})
t.Run("ProviderDisabledDefaultModelRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
_, err := client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
// Omitting model_config_id resolves the default model, whose
// provider is now disabled.
_, err = client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("DeniedSameOrganizationDefaultIsRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
_ = dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
Model: "private-default-" + uuid.NewString(),
Enabled: true,
IsDefault: true,
OrganizationID: firstUser.OrganizationID,
GroupACL: database.ChatACL{},
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(
t,
client.Client,
firstUser.OrganizationID,
)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "do not select the private default",
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
})
t.Run("WithPerChatSystemPrompt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello with system prompt",
},
},
SystemPrompt: "You are a Go expert.",
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, chat.ID)
// Use the DB directly to see system messages, which are
// hidden from the public API.
dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
// Expect: deployment system prompt, per-chat system prompt,
// workspace awareness, user message.
var systemMessages []database.ChatMessage
for _, msg := range dbMessages {
if msg.Role == database.ChatMessageRoleSystem {
systemMessages = append(systemMessages, msg)
}
}
require.GreaterOrEqual(t, len(systemMessages), 2,
"expected at least deployment + per-chat system messages")
// The per-chat system prompt should be the second system
// message and contain the user-specified text.
foundPerChat := false
for _, msg := range systemMessages {
if msg.Content.Valid {
raw := string(msg.Content.RawMessage)
if strings.Contains(raw, "You are a Go expert.") {
foundPerChat = true
break
}
}
}
require.True(t, foundPerChat,
"per-chat system prompt not found in system messages")
})
t.Run("PerChatSystemPromptEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello without system prompt",
},
},
SystemPrompt: "",
})
require.NoError(t, err)
dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
// No per-chat system prompt should be present.
for _, msg := range dbMessages {
if msg.Role == database.ChatMessageRoleSystem && msg.Content.Valid {
raw := string(msg.Content.RawMessage)
require.NotContains(t, raw, "You are a Go expert.",
"unexpected per-chat system prompt in messages")
}
}
})
t.Run("WorkspaceSelectsFirstAgent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
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)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("EmptyContent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
// Empty content creates an idle chat; see
// TestPostChats_EmptyContent for the full behavior.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: nil,
})
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusWaiting, chat.Status)
})
}
func TestCreateChatAsUser(t *testing.T) {
t.Parallel()
mAudit := audit.NewMock()
client, api := newChatClientWithAPI(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
createChatModel(t, client)
helloRequest := func(organizationID uuid.UUID) codersdk.CreateChatRequest {
return codersdk.CreateChatRequest{
OrganizationID: organizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello from CreateChatAsUser",
}},
}
}
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
chat, err := api.CreateChatAsUser(ctx, member.ID, helloRequest(firstUser.OrganizationID))
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)
require.Equal(t, codersdk.ChatClientTypeAPI, chat.ClientType)
require.Equal(t, "hello from CreateChatAsUser", chat.Title)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionCreate,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: member.ID,
OrganizationID: firstUser.OrganizationID,
StatusCode: http.StatusCreated,
}))
})
t.Run("RefusedUser", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
refuse func(ctx context.Context, t *testing.T, userID uuid.UUID) uuid.UUID
wantMessage string
}{
{
name: "SuspendedUser",
refuse: func(ctx context.Context, t *testing.T, userID uuid.UUID) uuid.UUID {
_, err := client.UpdateUserStatus(ctx, userID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err)
return userID
},
wantMessage: "not active",
},
{
name: "DeletedUser",
refuse: func(ctx context.Context, t *testing.T, userID uuid.UUID) uuid.UUID {
require.NoError(t, client.DeleteUser(ctx, userID))
return userID
},
wantMessage: "User is deleted.",
},
{
name: "MissingUser",
refuse: func(context.Context, *testing.T, uuid.UUID) uuid.UUID {
return uuid.New()
},
wantMessage: "User does not exist.",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
userID := tc.refuse(ctx, t, member.ID)
_, err := api.CreateChatAsUser(ctx, userID, helloRequest(firstUser.OrganizationID))
responder, ok := httperror.IsResponder(err)
require.True(t, ok, "want a responder error, got %v", err)
status, resp := responder.Response()
require.Equal(t, http.StatusForbidden, status)
require.Contains(t, resp.Message, tc.wantMessage)
chats, err := api.Database.GetChats(dbauthz.AsSystemRestricted(ctx), database.GetChatsParams{
OwnedOnly: true,
ViewerID: userID,
})
require.NoError(t, err)
require.Empty(t, chats)
for _, log := range mAudit.AuditLogs() {
require.False(t, log.ResourceType == database.ResourceTypeChat && log.UserID == userID, "refused user got a chat audit entry")
}
})
}
})
t.Run("AccountStatus", func(t *testing.T) {
t.Parallel()
activationAudits := func(userID uuid.UUID) int {
count := 0
for _, log := range mAudit.AuditLogs() {
if log.ResourceType == database.ResourceTypeUser && log.ResourceID == userID && log.Action == database.AuditActionWrite {
count++
}
}
return count
}
cases := []struct {
name string
status database.UserStatus
lastSeenAgo time.Duration
wantSeen bool
}{
{name: "DormantIsActivated", status: database.UserStatusDormant, lastSeenAgo: 100 * 24 * time.Hour, wantSeen: true},
{name: "SeenRecentlyKeepsLastSeen", status: database.UserStatusActive, lastSeenAgo: 30 * time.Minute},
{name: "SeenOverAnHourAgoUpdatesLastSeen", status: database.UserStatusActive, lastSeenAgo: 2 * time.Hour, wantSeen: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
systemCtx := dbauthz.AsSystemRestricted(ctx)
now := dbtime.Now()
lastSeen := now.Add(-tc.lastSeenAgo)
_, err := api.Database.UpdateUserLastSeenAt(systemCtx, database.UpdateUserLastSeenAtParams{
ID: member.ID,
LastSeenAt: lastSeen,
UpdatedAt: now,
})
require.NoError(t, err)
_, err = api.Database.UpdateUserStatus(systemCtx, database.UpdateUserStatusParams{
ID: member.ID,
Status: tc.status,
UpdatedAt: now,
})
require.NoError(t, err)
auditsBefore := activationAudits(member.ID)
_, err = api.CreateChatAsUser(ctx, member.ID, helloRequest(firstUser.OrganizationID))
require.NoError(t, err)
user, err := api.Database.GetUserByID(systemCtx, member.ID)
require.NoError(t, err)
require.Equal(t, database.UserStatusActive, user.Status)
if tc.wantSeen {
require.True(t, user.LastSeenAt.After(lastSeen), "last_seen_at stayed at %s", user.LastSeenAt)
} else {
require.True(t, lastSeen.Equal(user.LastSeenAt), "last_seen_at changed from %s to %s", lastSeen, user.LastSeenAt)
}
wantAudits := auditsBefore
if tc.status == database.UserStatusDormant {
wantAudits++
}
require.Equal(t, wantAudits, activationAudits(member.ID))
})
}
})
t.Run("HookDenied", func(t *testing.T) {
t.Parallel()
const hookSecret = "test-hook-secret-32-bytes-minimum!!"
consumer := newHookConsumer(t, hookSecret, agenthooks.Hooks{
UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) {
return agenthooks.Response{
Permission: &agenthooks.Permission{Decision: agenthooks.PermissionDeny},
UserMessage: "blocked by policy",
}, nil
},
})
t.Cleanup(consumer.Close)
hookClient, hookAPI := newChatClientWithAPI(t, func(opts *coderdtest.Options) {
opts.ChatWorkerDisabled = true
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String(hookSecret)
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(testutil.WaitShort)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
hookUser := coderdtest.CreateFirstUser(t, hookClient.Client)
createChatModel(t, hookClient)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := hookAPI.CreateChatAsUser(ctx, hookUser.UserID, helloRequest(hookUser.OrganizationID))
responder, ok := httperror.IsResponder(err)
require.True(t, ok, "want a responder error, got %v", err)
status, resp := responder.Response()
require.Equal(t, http.StatusForbidden, status)
require.Equal(t, "blocked by policy", resp.Message)
denied, ok := stderrors.AsType[*chathooks.UserPromptDeniedError](err)
require.True(t, ok, "want a hook denial, got %v", err)
require.Equal(t, "blocked by policy", denied.UserMessage)
})
}
func TestActOnChatAsUser(t *testing.T) {
t.Parallel()
mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map {
diff := audit.Map{}
oldChat, oldOK := old.(database.Chat)
newChat, newOK := newVal.(database.Chat)
if !oldOK || !newOK {
return diff
}
if oldChat.ID != newChat.ID {
diff["id"] = audit.OldNew{Old: oldChat.ID, New: newChat.ID}
}
if oldChat.Archived != newChat.Archived {
diff["archived"] = audit.OldNew{Old: oldChat.Archived, New: newChat.Archived}
}
return diff
})
client, api := newChatClientWithAPI(t, withChatWorkerDisabled, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
createChatModel(t, client)
newChat := func(ctx context.Context, t *testing.T) (*codersdk.ExperimentalClient, codersdk.Chat) {
t.Helper()
memberClient, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
chat, err := api.CreateChatAsUser(ctx, member.ID, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
})
require.NoError(t, err)
return codersdk.NewExperimentalClient(memberClient), chat
}
acquireChat := func(ctx context.Context, t *testing.T, chatID uuid.UUID) {
t.Helper()
chatdCtx := dbauthz.AsChatd(ctx) //nolint:gocritic // Test fixture mirrors chatd background transitions.
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chatID)
require.NoError(t, machine.Update(chatdCtx, func(tx *chatstate.Tx, _ database.Store) error {
_, err := tx.Acquire(chatstate.AcquireInput{WorkerID: uuid.New(), RunnerID: uuid.New()})
return err
}))
}
message := codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello again"}},
}
unarchiveRequest := codersdk.UpdateChatRequest{Archived: ptr.Ref(false)}
chatWrites := func(chatID uuid.UUID) []database.AuditLog {
var logs []database.AuditLog
for _, log := range mAudit.AuditLogs() {
if log.ResourceID == chatID && log.Action == database.AuditActionWrite {
logs = append(logs, log)
}
}
return logs
}
requireSameAudit := func(t *testing.T, overHTTP, inProcess database.AuditLog) {
t.Helper()
require.Equal(t, overHTTP.ResourceType, inProcess.ResourceType)
require.Equal(t, overHTTP.ResourceTarget, inProcess.ResourceTarget)
require.Equal(t, overHTTP.UserID, inProcess.UserID)
require.Equal(t, overHTTP.OrganizationID, inProcess.OrganizationID)
require.Equal(t, overHTTP.StatusCode, inProcess.StatusCode)
}
t.Run("Send", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, chat := newChat(ctx, t)
driveChatToWaiting(ctx, t, api, chat.ID)
resp, err := api.SendChatMessageAsUser(ctx, chat.OwnerID, chat.ID, message)
require.NoError(t, err)
require.False(t, resp.Queued)
require.NotNil(t, resp.Message)
require.Equal(t, codersdk.ChatMessageRoleUser, resp.Message.Role)
})
t.Run("Interrupt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, chat := newChat(ctx, t)
acquireChat(ctx, t, chat.ID)
interrupted, err := api.InterruptChatAsUser(ctx, chat.OwnerID, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, interrupted.ID)
require.Equal(t, codersdk.ChatStatusInterrupting, interrupted.Status)
})
t.Run("Unarchive", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, chat := newChat(ctx, t)
driveChatToWaiting(ctx, t, api, chat.ID)
require.NoError(t, memberClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)}))
require.NoError(t, api.UpdateChatAsUser(ctx, chat.OwnerID, chat.ID, unarchiveRequest))
got, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.False(t, got.Archived)
logs := chatWrites(chat.ID)
require.Len(t, logs, 2)
archive, unarchive := logs[0], logs[1]
requireSameAudit(t, archive, unarchive)
require.Equal(t, chat.OwnerID, unarchive.UserID)
require.EqualValues(t, http.StatusNoContent, unarchive.StatusCode)
var diff map[string]codersdk.AuditDiffField
require.NoError(t, json.Unmarshal(unarchive.Diff, &diff))
require.Equal(t, map[string]codersdk.AuditDiffField{"archived": {Old: true, New: false}}, diff)
})
t.Run("UpdateAuditOnFailure", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, chat := newChat(ctx, t)
require.Error(t, memberClient.UpdateChat(ctx, chat.ID, unarchiveRequest))
require.Error(t, api.UpdateChatAsUser(ctx, chat.OwnerID, chat.ID, unarchiveRequest))
logs := chatWrites(chat.ID)
require.Len(t, logs, 2)
overHTTP, inProcess := logs[0], logs[1]
requireSameAudit(t, overHTTP, inProcess)
require.EqualValues(t, http.StatusBadRequest, inProcess.StatusCode)
require.JSONEq(t, "{}", string(overHTTP.Diff))
require.JSONEq(t, "{}", string(inProcess.Diff))
})
t.Run("Refused", func(t *testing.T) {
t.Parallel()
send := func(ctx context.Context, userID, chatID uuid.UUID) error {
_, err := api.SendChatMessageAsUser(ctx, userID, chatID, message)
return err
}
interrupt := func(ctx context.Context, userID, chatID uuid.UUID) error {
_, err := api.InterruptChatAsUser(ctx, userID, chatID)
return err
}
unarchive := func(ctx context.Context, userID, chatID uuid.UUID) error {
return api.UpdateChatAsUser(ctx, userID, chatID, unarchiveRequest)
}
otherUser := func(_ context.Context, t *testing.T, _ *codersdk.ExperimentalClient, _ codersdk.Chat) uuid.UUID {
_, other := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
return other.ID
}
cases := []struct {
name string
act func(ctx context.Context, userID, chatID uuid.UUID) error
prepare func(ctx context.Context, t *testing.T, memberClient *codersdk.ExperimentalClient, chat codersdk.Chat) uuid.UUID
wantStatus int
wantMessage string
}{
{
name: "SendOtherUsersChat",
act: send,
prepare: otherUser,
wantStatus: http.StatusNotFound,
wantMessage: "Resource not found",
},
{
name: "SendSiteOwnerNotChatOwner",
act: send,
prepare: func(context.Context, *testing.T, *codersdk.ExperimentalClient, codersdk.Chat) uuid.UUID {
return firstUser.UserID
},
wantStatus: http.StatusForbidden,
wantMessage: "Only the chat owner may send messages.",
},
{
name: "InterruptOtherUsersChat",
act: interrupt,
prepare: otherUser,
wantStatus: http.StatusNotFound,
wantMessage: "Resource not found",
},
{
name: "UnarchiveReadOnlyMember",
act: unarchive,
prepare: func(ctx context.Context, t *testing.T, memberClient *codersdk.ExperimentalClient, chat codersdk.Chat) uuid.UUID {
driveChatToWaiting(ctx, t, api, chat.ID)
require.NoError(t, memberClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)}))
_, reader := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
require.NoError(t, memberClient.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{reader.ID.String(): codersdk.ChatRoleRead},
}))
return reader.ID
},
wantStatus: http.StatusNotFound,
wantMessage: "Resource not found",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, chat := newChat(ctx, t)
userID := tc.prepare(ctx, t, memberClient, chat)
err := tc.act(ctx, userID, chat.ID)
responder, ok := httperror.IsResponder(err)
require.True(t, ok, "want a responder error, got %v", err)
status, resp := responder.Response()
require.Equal(t, tc.wantStatus, status)
require.Contains(t, resp.Message, tc.wantMessage)
})
}
})
}
func TestPostChats_OwnerID(t *testing.T) {
t.Parallel()
helloRequest := func(ownerID, organizationID uuid.UUID) codersdk.CreateChatRequest {
return codersdk.CreateChatRequest{
OrganizationID: organizationID,
OwnerID: &ownerID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello on behalf of another user",
}},
}
}
t.Run("OwnerCreatesForMember", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := client.CreateChat(ctx, helloRequest(member.ID, firstUser.OrganizationID))
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)
// The member can read the chat; the prompt stays attributed to the admin.
_, err = memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
messages, err := db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
})
require.NoError(t, err)
userMsg := findUserMessage(t, messages)
require.Equal(t, uuid.NullUUID{UUID: firstUser.UserID, Valid: true}, userMsg.CreatedBy)
})
t.Run("OwnerServiceAccountCreatesForMember", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
// Service accounts are a licensed feature, so seed one directly. It
// is not an organization member: site-wide authority is sufficient.
serviceAccount := dbgen.User(t, db, database.User{
IsServiceAccount: true,
RBACRoles: []string{rbac.RoleOwner().String()},
})
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: serviceAccount.ID,
LoginType: database.LoginTypeToken,
})
serviceAccountClient := codersdk.New(
client.URL,
codersdk.WithSessionToken(token),
codersdk.WithHTTPClient(coderdtest.NewIsolatedHTTPClient(client.URL)),
)
t.Cleanup(serviceAccountClient.HTTPClient.CloseIdleConnections)
chat, err := serviceAccountClient.CreateChat(ctx, helloRequest(member.ID, firstUser.OrganizationID))
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)
})
t.Run("OwnerIDNil", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, err := client.CreateChat(ctx, helloRequest(uuid.Nil, firstUser.OrganizationID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid owner_id: must be a user ID or omitted.", sdkErr.Message)
})
t.Run("OwnerIDIsCaller", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, helloRequest(member.ID, firstUser.OrganizationID))
require.NoError(t, err)
require.Equal(t, member.ID, chat.OwnerID)
})
t.Run("WorkspaceOwnedByMember", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
}).WithAgent().Do()
req := helloRequest(member.ID, firstUser.OrganizationID)
req.WorkspaceID = &workspaceBuild.Workspace.ID
chat, err := client.CreateChat(ctx, req)
require.NoError(t, err)
require.NotNil(t, chat.WorkspaceID)
require.Equal(t, workspaceBuild.Workspace.ID, *chat.WorkspaceID)
})
t.Run("WorkspaceNotAccessibleToOwner", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
// The admin can reach their own workspace, but the chat connects
// as the member, who cannot.
req := helloRequest(member.ID, firstUser.OrganizationID)
req.WorkspaceID = &workspaceBuild.Workspace.ID
_, err := client.CreateChat(ctx, req)
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("ModelConfigNotAccessibleToOwner", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, OrganizationID: firstUser.OrganizationID,
Model: "private-" + uuid.NewString(), Enabled: true, GroupACL: database.ChatACL{},
})
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
// The admin can read the private model; the member cannot, and
// chatd would silently fall back to the default at run time.
req := helloRequest(member.ID, firstUser.OrganizationID)
req.ModelConfigID = &privateConfig.ID
_, err := client.CreateChat(ctx, req)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
t.Run("OrgAdminForbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
orgAdminClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
orgAdminClient := codersdk.NewExperimentalClient(orgAdminClientRaw)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
// Org admins hold org-scoped chat:create, but a chat runs with its
// owner's credentials, so acting as another user needs site-wide
// authority.
_, err := orgAdminClient.CreateChat(ctx, helloRequest(member.ID, firstUser.OrganizationID))
requireSDKError(t, err, http.StatusForbidden)
})
t.Run("MemberForbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, other := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
_, err := memberClient.CreateChat(ctx, helloRequest(other.ID, firstUser.OrganizationID))
requireSDKError(t, err, http.StatusForbidden)
})
t.Run("OwnerNotInOrganization", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
otherOrg := dbgen.Organization(t, db, database.Organization{})
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
_, err := client.CreateChat(ctx, helloRequest(member.ID, otherOrg.ID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat owner must be an active member of the organization.", sdkErr.Message)
})
t.Run("OwnerNotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, err := client.CreateChat(ctx, helloRequest(uuid.New(), firstUser.OrganizationID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat owner must be an active member of the organization.", sdkErr.Message)
})
t.Run("OwnerSuspended", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
_, err := client.UpdateUserStatus(ctx, member.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err)
_, err = client.CreateChat(ctx, helloRequest(member.ID, firstUser.OrganizationID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat owner must be an active member of the organization.", sdkErr.Message)
})
t.Run("OwnerWithoutChatPermission", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
// Organization service accounts deliberately hold no chat permissions.
serviceAccount := dbgen.User(t, db, database.User{IsServiceAccount: true})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: firstUser.OrganizationID,
UserID: serviceAccount.ID,
})
_, err := client.CreateChat(ctx, helloRequest(serviceAccount.ID, firstUser.OrganizationID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat owner does not have permission to use chats.", sdkErr.Message)
})
t.Run("OwnerWithCreateOnlyChatPermission", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client, _ := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
// A custom role can grant chat:create without read or update.
role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: testutil.GetRandomName(t),
DisplayName: "Chat Creator",
OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true},
OrgPermissions: database.CustomRolePermissions{{
ResourceType: rbac.ResourceChat.Type,
Action: policy.ActionCreate,
}},
})
require.NoError(t, err)
serviceAccount := dbgen.User(t, rawDB, database.User{IsServiceAccount: true})
dbgen.OrganizationMember(t, rawDB, database.OrganizationMember{
OrganizationID: firstUser.OrganizationID,
UserID: serviceAccount.ID,
Roles: []string{role.Name},
})
_, err = client.CreateChat(ctx, helloRequest(serviceAccount.ID, firstUser.OrganizationID))
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat owner does not have permission to use chats.", sdkErr.Message)
})
}
// TestChats_ForceOnMCPServerEnforced is the endpoint-level regression
// test for Cure53 CDM-02-010: a regular user who strips force_on MCP
// server IDs from mcp_server_ids when creating a chat or sending a
// message must not be able to exclude those servers.
func TestChats_ForceOnMCPServerEnforced(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
// An admin marks an MCP server as Force On.
forced, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Forced Server",
Slug: "forced-server",
Transport: "streamable_http",
URL: "https://mcp.example.com/forced",
AuthType: "none",
Availability: "force_on",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
})
require.NoError(t, err)
// A regular member tampers with the request by clearing
// mcp_server_ids (Cure53 CDM-02-010 reproduction).
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "test message",
}},
MCPServerIDs: []uuid.UUID{},
})
require.NoError(t, err)
require.Contains(t, chat.MCPServerIDs, forced.ID,
"force_on MCP server must be enforced on chat creation")
// Sending a message with an emptied list must not remove the
// forced server either.
_, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "second message",
}},
MCPServerIDs: &[]uuid.UUID{},
})
require.NoError(t, err)
chatResult, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Contains(t, chatResult.MCPServerIDs, forced.ID,
"force_on MCP server must survive a tampered mcp_server_ids update")
}
func TestPostChats_ClientType(t *testing.T) {
t.Parallel()
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
newChat := func(t *testing.T, clientType codersdk.ChatClientType) codersdk.Chat {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "client type test",
}},
ClientType: clientType,
})
require.NoError(t, err)
return chat
}
t.Run("DefaultIsAPI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Omit ClientType entirely — should default to "api".
chat := newChat(t, "")
require.Equal(t, codersdk.ChatClientTypeAPI, chat.ClientType)
got, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatClientTypeAPI, got.ClientType)
})
t.Run("ExplicitAPI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
chat := newChat(t, codersdk.ChatClientTypeAPI)
require.Equal(t, codersdk.ChatClientTypeAPI, chat.ClientType)
got, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatClientTypeAPI, got.ClientType)
})
t.Run("ExplicitUI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
chat := newChat(t, codersdk.ChatClientTypeUI)
require.Equal(t, codersdk.ChatClientTypeUI, chat.ClientType)
got, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ChatClientTypeUI, got.ClientType)
})
t.Run("InvalidClientType", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "bad client type",
}},
ClientType: "bogus",
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Contains(t, sdkErr.Message, "Invalid client_type")
})
}
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.Client)
modelConfig := createChatModel(t, client)
firstChatA, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "first owner chat",
},
},
})
require.NoError(t, err)
firstChatB, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "second owner chat",
},
},
})
require.NoError(t, err)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
memberDBChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat only",
})
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)
// The chat may have been picked up by the chat worker
// before we list, so accept any status it may have
// reached by now.
require.Contains(t, []codersdk.ChatStatus{
codersdk.ChatStatusRunning,
codersdk.ChatStatusError,
codersdk.ChatStatusWaiting,
}, chat.Status, "unexpected chat status: %s", 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))
}
// The list is already verified as sorted by UpdatedAt
// descending (loop above). We intentionally do NOT
// compare positions using the creation-time UpdatedAt
// values because the chat worker may pick up a chat and
// mutate UpdatedAt between CreateChat and ListChats.
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("SourceCreatedByMeAndSharedWithMeExcludesUnsharedReadableChats", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
ownerClientRaw, owner := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleOwner())
ownerClient := codersdk.NewExperimentalClient(ownerClientRaw)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
ownedChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: "owner created chat",
Status: database.ChatStatusWaiting,
})
sharedChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member shared chat",
Status: database.ChatStatusWaiting,
})
unsharedReadableChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "unshared readable chat",
Status: database.ChatStatusWaiting,
})
err := db.UpdateChatACLByID(dbauthz.As(ctx, rbac.Subject{
ID: member.ID.String(),
Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
Scope: rbac.ScopeAll,
}), database.UpdateChatACLByIDParams{
ID: sharedChat.ID,
UserACL: database.ChatACL{
owner.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
ownerChats, err := ownerClient.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "source:created_by_me,shared_with_me",
})
require.NoError(t, err)
ownerChatIDs := make(map[uuid.UUID]struct{}, len(ownerChats))
for _, chat := range ownerChats {
ownerChatIDs[chat.ID] = struct{}{}
}
require.Contains(t, ownerChatIDs, ownedChat.ID)
require.Contains(t, ownerChatIDs, sharedChat.ID)
require.NotContains(t, ownerChatIDs, unsharedReadableChat.ID)
sharedOnlyChats, err := ownerClient.ListChats(ctx, &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceSharedWithMe,
})
require.NoError(t, err)
sharedOnlyChatIDs := make(map[uuid.UUID]struct{}, len(sharedOnlyChats))
for _, chat := range sharedOnlyChats {
sharedOnlyChatIDs[chat.ID] = struct{}{}
}
require.Contains(t, sharedOnlyChatIDs, sharedChat.ID)
require.NotContains(t, sharedOnlyChatIDs, ownedChat.ID)
require.NotContains(t, sharedOnlyChatIDs, unsharedReadableChat.ID)
memberChats, err := memberClient.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "source:created_by_me,shared_with_me",
})
require.NoError(t, err)
memberChatIDs := make(map[uuid.UUID]struct{}, len(memberChats))
for _, chat := range memberChats {
memberChatIDs[chat.ID] = struct{}{}
}
require.Contains(t, memberChatIDs, sharedChat.ID)
require.NotContains(t, memberChatIDs, ownedChat.ID)
require.NotContains(t, memberChatIDs, unsharedReadableChat.ID)
})
t.Run("OrgMemberCanAccessOwnChats", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat",
})
chats, err := memberClient.ListChats(ctx, nil)
require.NoError(t, err)
require.Len(t, chats, 1)
require.Equal(t, chat.ID, chats[0].ID)
err = memberClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("new title"),
})
require.NoError(t, err)
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL))
_, err := unauthenticatedClient.ListChats(ctx, nil)
requireSDKError(t, err, http.StatusUnauthorized)
})
t.Run("Pagination", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Insert chats with a terminal status so the chatd
// processor never acquires them and never bumps
// updated_at. The GetChats cursor subquery re-reads the
// cursor row's updated_at, so a concurrent bump would
// shift the cursor position between page requests.
const totalChats = 5
createdChatIDs := make([]uuid.UUID, 0, totalChats)
for i := range totalChats {
dbChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: fmt.Sprintf("chat-%d", i),
Status: database.ChatStatusWaiting,
})
createdChatIDs = append(createdChatIDs, dbChat.ID)
}
// Fetch first page with limit=2.
page1, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{Limit: 2},
})
require.NoError(t, err)
require.Len(t, page1, 2)
// Fetch second page using after_id from last item of page 1.
page2, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{
AfterID: uuid.MustParse(page1[len(page1)-1].ID.String()),
Limit: 2,
},
})
require.NoError(t, err)
require.Len(t, page2, 2)
// Ensure page1 and page2 have no overlap.
page1IDs := make(map[uuid.UUID]struct{})
for _, c := range page1 {
page1IDs[c.ID] = struct{}{}
}
for _, c := range page2 {
_, overlap := page1IDs[c.ID]
require.False(t, overlap, "page2 should not contain items from page1")
}
// Fetch third page — should have 1 remaining chat.
page3, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{
AfterID: uuid.MustParse(page2[len(page2)-1].ID.String()),
Limit: 2,
},
})
require.NoError(t, err)
require.Len(t, page3, 1)
// All 5 chats should be accounted for.
allIDs := make(map[uuid.UUID]struct{})
for _, c := range append(append(page1, page2...), page3...) {
allIDs[c.ID] = struct{}{}
}
for _, id := range createdChatIDs {
_, found := allIDs[id]
require.True(t, found, "chat %s should appear in paginated results", id)
}
// Fetch with offset=3, limit=2 — should return 2 chats.
offsetPage, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{Offset: 3, Limit: 2},
})
require.NoError(t, err)
require.Len(t, offsetPage, 2)
// No limit should return all chats.
allChats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
require.Len(t, allChats, totalChats)
})
// Test that a pinned chat with an old updated_at appears on page 1.
t.Run("PinnedOnFirstPage", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Insert chats directly with a terminal status: see
// the Pagination subtest for the cursor-race rationale.
// Direct insertion also avoids spawning 51 background
// chat processors, which causes timeouts under -race.
pinnedDBChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "pinned-chat",
Status: database.ChatStatusWaiting,
})
// Fill page 1 with newer chats so the pinned chat
// would normally be pushed off the first page
// (default limit 50).
const fillerCount = 51
for i := range fillerCount {
_ = dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: fmt.Sprintf("filler-%d", i),
Status: database.ChatStatusWaiting,
})
}
// Pin the earliest chat.
err := client.UpdateChat(ctx, pinnedDBChat.ID, codersdk.UpdateChatRequest{
PinOrder: ptr.Ref(int32(1)),
})
require.NoError(t, err)
// Fetch page 1 with default limit (50).
page1, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{Limit: 50},
})
require.NoError(t, err)
// The pinned chat must appear on page 1.
page1IDs := make(map[uuid.UUID]struct{}, len(page1))
for _, c := range page1 {
page1IDs[c.ID] = struct{}{}
}
_, found := page1IDs[pinnedDBChat.ID]
require.True(t, found, "pinned chat should appear on page 1")
// The pinned chat should be the first item in the list.
require.Equal(t, pinnedDBChat.ID, page1[0].ID, "pinned chat should be first")
})
// Test cursor pagination with a mix of pinned and unpinned chats.
t.Run("CursorWithPins", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Insert chats directly with a terminal status: see
// the Pagination subtest for the cursor-race rationale.
const totalChats = 5
createdChatIDs := make([]uuid.UUID, 0, totalChats)
for i := range totalChats {
dbChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: fmt.Sprintf("cursor-pin-chat-%d", i),
Status: database.ChatStatusWaiting,
})
createdChatIDs = append(createdChatIDs, dbChat.ID)
}
// Pin the first two chats (oldest updated_at).
// PinChatByID and UpdateChatPinOrder do not touch
// updated_at, so the cursor ordering stays stable.
err := client.UpdateChat(ctx, createdChatIDs[0], codersdk.UpdateChatRequest{
PinOrder: ptr.Ref(int32(1)),
})
require.NoError(t, err)
err = client.UpdateChat(ctx, createdChatIDs[1], codersdk.UpdateChatRequest{
PinOrder: ptr.Ref(int32(1)),
})
require.NoError(t, err)
// Paginate with limit=2 using cursor (after_id).
const pageSize = 2
maxPages := totalChats/pageSize + 2
var allPaginated []codersdk.Chat
var afterID uuid.UUID
for range maxPages {
opts := &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{Limit: pageSize},
}
if afterID != uuid.Nil {
opts.AfterID = afterID
}
page, listErr := client.ListChats(ctx, opts)
require.NoError(t, listErr)
if len(page) == 0 {
break
}
allPaginated = append(allPaginated, page...)
afterID = page[len(page)-1].ID
}
// All chats should appear exactly once.
seenIDs := make(map[uuid.UUID]struct{}, len(allPaginated))
for _, c := range allPaginated {
_, dup := seenIDs[c.ID]
require.False(t, dup, "chat %s appeared more than once", c.ID)
seenIDs[c.ID] = struct{}{}
}
require.Len(t, seenIDs, totalChats, "all chats should appear in paginated results")
// Pinned chats should come before unpinned ones, and
// within the pinned group, lower pin_order sorts first.
pinnedSeen := false
unpinnedSeen := false
for _, c := range allPaginated {
if c.PinOrder > 0 {
require.False(t, unpinnedSeen, "pinned chat %s appeared after unpinned chat", c.ID)
pinnedSeen = true
} else {
unpinnedSeen = true
}
}
require.True(t, pinnedSeen, "at least one pinned chat should exist")
// Verify within-pinned ordering: pin_order=1 before
// pin_order=2 (the -pin_order DESC column).
require.Equal(t, createdChatIDs[0], allPaginated[0].ID,
"pin_order=1 chat should be first")
require.Equal(t, createdChatIDs[1], allPaginated[1].ID,
"pin_order=2 chat should be second")
})
t.Run("ChildChatsEmbeddedNotStandalone", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "root chat with children",
},
},
})
require.NoError(t, err)
// Insert child chats directly via the database.
child1 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child one",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
child2 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child two",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
// Also create a standalone root chat to verify it still appears.
standalone, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "standalone root chat",
},
},
})
require.NoError(t, err)
chats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
// Only root chats should appear at the top level.
rootIDs := make(map[uuid.UUID]struct{}, len(chats))
for _, c := range chats {
rootIDs[c.ID] = struct{}{}
require.Nil(t, c.ParentChatID, "top-level entry should have no parent")
}
require.Contains(t, rootIDs, parentChat.ID)
require.Contains(t, rootIDs, standalone.ID)
require.NotContains(t, rootIDs, child1.ID, "child1 should not appear at top level")
require.NotContains(t, rootIDs, child2.ID, "child2 should not appear at top level")
// Find the parent in the list and verify children are embedded.
var parent codersdk.Chat
for _, c := range chats {
if c.ID == parentChat.ID {
parent = c
break
}
}
require.Len(t, parent.Children, 2, "parent should embed 2 children")
// Children are ordered by created_at DESC (newest first).
childIDs := []uuid.UUID{parent.Children[0].ID, parent.Children[1].ID}
require.Equal(t, child2.ID, childIDs[0])
require.Equal(t, child1.ID, childIDs[1])
// Verify each child has correct parent/root references.
for _, child := range parent.Children {
require.NotNil(t, child.ParentChatID)
require.Equal(t, parentChat.ID, *child.ParentChatID)
require.NotNil(t, child.RootChatID)
require.Equal(t, parentChat.ID, *child.RootChatID)
}
// Standalone root chat should have an empty children slice.
for _, c := range chats {
if c.ID == standalone.ID {
require.NotNil(t, c.Children)
require.Empty(t, c.Children)
break
}
}
})
t.Run("PaginationCountsOnlyRootChats", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Create 3 root chats, each with 2 children.
for i := range 3 {
parent, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: fmt.Sprintf("parent %d", i),
},
},
})
require.NoError(t, err)
for j := range 2 {
_ = dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: fmt.Sprintf("child %d-%d", i, j),
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
})
}
}
// Request with limit=2: should get 2 root chats (not 2 of
// the 9 total chats). Each root should have its children.
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Pagination: codersdk.Pagination{Limit: 2},
})
require.NoError(t, err)
require.Len(t, chats, 2, "limit should apply to root chats only")
for _, c := range chats {
require.Nil(t, c.ParentChatID)
require.Len(t, c.Children, 2, "each root should embed its 2 children")
}
})
t.Run("DiffURLFilter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Helper that creates a chat (root or child) with a diff status URL.
create := func(title, url string, parentID uuid.NullUUID) database.Chat {
rootID := parentID
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: title,
ParentChatID: parentID,
RootChatID: rootID,
})
if url != "" {
staleAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
Url: sql.NullString{String: url, Valid: true},
GitBranch: "feature/test",
GitRemoteOrigin: "git@github.com:coder/coder.git",
StaleAt: staleAt,
},
)
require.NoError(t, err)
}
return chat
}
// Root chat directly linked to the target PR.
rootWithPR := create("root with pr", "https://github.com/coder/coder/pull/1", uuid.NullUUID{})
// Root chat whose sub-agent owns the PR. The filter should still
// surface the parent because the URL lives on a descendant.
rootWithChildPR := create("root with child pr", "", uuid.NullUUID{})
_ = create(
"sub-agent with pr",
"https://github.com/coder/coder/pull/2",
uuid.NullUUID{UUID: rootWithChildPR.ID, Valid: true},
)
// Root chat with an unrelated PR; should not match either filter.
_ = create("unrelated pr", "https://github.com/coder/coder/pull/999", uuid.NullUUID{})
// Root chat with no diff status at all.
_ = create("no diff", "", uuid.NullUUID{})
// Archived root chat that points at the same URL as `rootWithPR`.
// Used to verify the archived filter and the diff_url filter
// compose at the SQL layer rather than ignoring each other.
archivedWithPR := create(
"archived with pr",
"https://github.com/coder/coder/pull/3",
uuid.NullUUID{},
)
require.NoError(t, client.UpdateChat(ctx, archivedWithPR.ID, codersdk.UpdateChatRequest{
Archived: ptr.Ref(true),
}))
t.Run("MatchesRoot", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"https://github.com/coder/coder/pull/1"`,
})
require.NoError(t, err)
require.Len(t, chats, 1)
require.Equal(t, rootWithPR.ID, chats[0].ID)
})
t.Run("MatchesViaSubAgent", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"https://github.com/coder/coder/pull/2"`,
})
require.NoError(t, err)
require.Len(t, chats, 1, "root chat should surface even when only a child has the PR")
require.Equal(t, rootWithChildPR.ID, chats[0].ID)
})
t.Run("CaseInsensitive", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"HTTPS://GITHUB.COM/CODER/CODER/PULL/1"`,
})
require.NoError(t, err)
require.Len(t, chats, 1)
require.Equal(t, rootWithPR.ID, chats[0].ID)
})
t.Run("NoMatch", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"https://github.com/coder/coder/pull/424242"`,
})
require.NoError(t, err)
require.Empty(t, chats)
})
t.Run("InvalidURL", func(t *testing.T) {
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"ftp://example.com/x"`,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.NotEmpty(t, sdkErr.Validations, "expected validation error")
require.Equal(t, "diff_url", sdkErr.Validations[0].Field)
})
t.Run("ArchivedFilteredOut", func(t *testing.T) {
// Default archived filter is false, so an archived chat with
// a matching diff URL must not surface.
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `diff_url:"https://github.com/coder/coder/pull/3"`,
})
require.NoError(t, err)
require.Empty(t, chats, "archived chat must not match the default filter")
})
t.Run("ArchivedTrueComposes", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `archived:true diff_url:"https://github.com/coder/coder/pull/3"`,
})
require.NoError(t, err)
require.Len(t, chats, 1)
require.Equal(t, archivedWithPR.ID, chats[0].ID)
})
})
}
func TestListChatModels(t *testing.T) {
t.Parallel()
providerByID := func(t *testing.T, response codersdk.OrganizationChatModelsResponse, id uuid.UUID) codersdk.ChatModelProviderDescriptor {
t.Helper()
for _, provider := range response.Providers {
if provider.ID == id {
return provider
}
}
t.Fatalf("provider %s not found", id)
return codersdk.ChatModelProviderDescriptor{}
}
containsModel := func(response codersdk.OrganizationChatModelsResponse, id uuid.UUID) bool {
return slices.ContainsFunc(response.Models, func(model codersdk.ChatModel) bool { return model.ID == id })
}
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
model := createChatModel(t, client)
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, containsModel(response, model.ID))
require.True(t, providerByID(t, response, model.AIProviderID).Available)
})
t.Run("DeniedSameOrganizationEnabledModelIsHidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, OrganizationID: firstUser.OrganizationID,
Model: "private-" + uuid.NewString(), Enabled: true, GroupACL: database.ChatACL{},
})
memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
response, err := codersdk.NewExperimentalClient(memberRaw).ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.False(t, containsModel(response, privateConfig.ID))
})
t.Run("NonDefaultOrgIncludesOnlyLocalModels", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
defaultConfig := createChatModel(t, client)
chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
contextLimit := int64(4096)
local, err := client.CreateChatModel(ctx, chatOrg.ID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "local-" + uuid.NewString(), ContextLimit: &contextLimit})
require.NoError(t, err)
third, err := client.CreateChatModel(ctx, thirdOrg.ID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "third-" + uuid.NewString(), ContextLimit: &contextLimit})
require.NoError(t, err)
memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID)
response, err := codersdk.NewExperimentalClient(memberRaw).ChatModels(ctx, chatOrg.ID)
require.NoError(t, err)
require.True(t, containsModel(response, local.ID))
require.False(t, containsModel(response, defaultConfig.ID))
require.False(t, containsModel(response, third.ID))
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_, err := codersdk.NewExperimentalClient(codersdk.New(client.URL)).ChatModels(ctx, firstUser.OrganizationID)
requireSDKError(t, err, http.StatusUnauthorized)
})
t.Run("CopilotOnlyUnsupported", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, string(codersdk.AIProviderTypeCopilot), "")
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.False(t, providerByID(t, response, provider.ID).Available)
require.Equal(t, []codersdk.ChatUnsupportedProvider{{Provider: "copilot", DisplayName: "GitHub Copilot"}}, response.UnsupportedProviders)
})
t.Run("SupportedProviderHasNoUnsupportedEntry", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, response.UnsupportedProviders)
})
t.Run("CentralOnlyProviderAvailable", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
model := createChatModel(t, client)
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, response, model.AIProviderID).Available)
})
t.Run("UserOnlyProviderRequiresUserKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "anthropic", "")
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
descriptor := providerByID(t, response, provider.ID)
require.False(t, descriptor.Available)
require.Equal(t, codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired, descriptor.UnavailableReason)
_, err = client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "user-key"})
require.NoError(t, err)
response, err = client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, response, provider.ID).Available)
})
t.Run("CentralAndUserWithFallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "google", "provider-key")
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, response, provider.ID).Available)
_, err = client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "user-key"})
require.NoError(t, err)
response, err = client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, response, provider.ID).Available)
})
t.Run("SameTypeProvidersUseExactCredentialStatus", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
availableProvider := createAIProviderForTest(t, client, "openai", "available-key")
unavailableProvider := createAIProviderForTest(t, client, "openai", "")
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, response, availableProvider.ID).Available)
unavailable := providerByID(t, response, unavailableProvider.ID)
require.False(t, unavailable.Available)
require.NotEmpty(t, unavailable.UnavailableReason)
})
t.Run("UserCredentialsAreCallerIsolated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "anthropic", "")
otherRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
other := codersdk.NewExperimentalClient(otherRaw)
_, err := client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "owner-key"})
require.NoError(t, err)
ownerResponse, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
otherResponse, err := other.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, providerByID(t, ownerResponse, provider.ID).Available)
require.True(t, providerByID(t, ownerResponse, provider.ID).HasUserAPIKey)
require.False(t, providerByID(t, otherResponse, provider.ID).Available)
require.False(t, providerByID(t, otherResponse, provider.ID).HasUserAPIKey)
})
t.Run("IncludesOnlyDefaultOrganizationModels", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
contextLimit := int64(4096)
defaultConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "default-" + uuid.NewString(), ContextLimit: &contextLimit})
require.NoError(t, err)
otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
other := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, Model: "other-" + uuid.NewString(), OrganizationID: otherOrg.ID})
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, containsModel(response, defaultConfig.ID))
require.False(t, containsModel(response, other.ID))
})
t.Run("DisabledProvidersAndModelsRemainVisible", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := createAIProviderForTest(t, client, "openai", "test-key")
contextLimit := int64(4096)
enabledModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "enabled-" + uuid.NewString(), ContextLimit: &contextLimit})
require.NoError(t, err)
disabledModel, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "disabled-" + uuid.NewString(), ContextLimit: &contextLimit})
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, firstUser.OrganizationID, disabledModel.ID, codersdk.UpdateChatModelRequest{Enabled: ptr.Ref(false)})
require.NoError(t, err)
response, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, containsModel(response, enabledModel.ID))
require.True(t, slices.ContainsFunc(response.Models, func(model codersdk.ChatModel) bool {
return model.ID == disabledModel.ID && !model.Enabled
}))
_, err = client.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{Enabled: ptr.Ref(false)})
require.NoError(t, err)
response, err = client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, containsModel(response, enabledModel.ID))
require.True(t, containsModel(response, disabledModel.ID))
disabledProvider := providerByID(t, response, provider.ID)
require.False(t, disabledProvider.Enabled)
require.False(t, disabledProvider.Available)
})
}
func TestListChats_Search(t *testing.T) {
t.Parallel()
setup := func(t *testing.T) (context.Context, *codersdk.ExperimentalClient, database.Store, codersdk.CreateFirstUserResponse, codersdk.ChatModel) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
return ctx, client, db, firstUser, modelConfig
}
createChat := func(t *testing.T, db database.Store, firstUser codersdk.CreateFirstUserResponse, modelConfigID uuid.UUID, title string) database.Chat {
t.Helper()
return dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfigID,
Title: title,
Status: database.ChatStatusWaiting,
})
}
insertMessage := func(t *testing.T, db database.Store, firstUser codersdk.CreateFirstUserResponse, modelConfigID, chatID uuid.UUID, text string) {
t.Helper()
content, err := json.Marshal([]map[string]string{{"type": "text", "text": text}})
require.NoError(t, err)
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
CreatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: content, Valid: true},
})
}
backfillSearchTsv := func(ctx context.Context, t *testing.T, db database.Store) {
t.Helper()
_, err := db.BackfillChatMessagesSearchTsv(dbauthz.AsSystemRestricted(ctx), 1000)
require.NoError(t, err)
}
chatIDs := func(chats []codersdk.Chat) map[uuid.UUID]struct{} {
ids := make(map[uuid.UUID]struct{}, len(chats))
for _, chat := range chats {
ids[chat.ID] = struct{}{}
}
return ids
}
t.Run("MatchesTitleAndMessageBody", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)
titleMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes upgrade notes")
bodyMatch := createChat(t, db, firstUser, modelConfig.ID, "plain title")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatch.ID, "restart the kubernetes cluster")
noMatch := createChat(t, db, firstUser, modelConfig.ID, "unrelated chat")
insertMessage(t, db, firstUser, modelConfig.ID, noMatch.ID, "terraform apply failure")
backfillSearchTsv(ctx, t, db)
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"kubernetes"`,
})
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, titleMatch.ID)
require.Contains(t, ids, bodyMatch.ID)
require.NotContains(t, ids, noMatch.ID)
// The 'english' config stems both sides, so an inflected query
// form matches the stored words.
chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"restarting"`,
})
require.NoError(t, err)
require.Contains(t, chatIDs(chats), bodyMatch.ID)
})
t.Run("NoSearchableWordsReturnsEmpty", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)
// Message search uses the 'english' config, which drops
// stopwords: search:"or" tokenizes to no lexemes and matches
// nothing, just like search:"!!!". The title must not contain the
// searched words because title search keeps the 'simple' config,
// which retains stopwords. The control query proves the empty
// results come from tokenization, not missing data.
control := createChat(t, db, firstUser, modelConfig.ID, "plain title")
insertMessage(t, db, firstUser, modelConfig.ID, control.ID, "fix this or that")
backfillSearchTsv(ctx, t, db)
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"fix"`,
})
require.NoError(t, err)
require.Contains(t, chatIDs(chats), control.ID)
chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"or"`,
})
require.NoError(t, err)
require.Empty(t, chats)
chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"!!!"`,
})
require.NoError(t, err)
require.Empty(t, chats)
})
t.Run("ComposesWithRepoFilterAndArchivedDefault", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)
linkRepo := func(chatID uuid.UUID, remote string) {
t.Helper()
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chatID,
GitBranch: "main",
GitRemoteOrigin: remote,
StaleAt: time.Now().UTC().Add(time.Hour),
},
)
require.NoError(t, err)
}
bothMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes in coder repo")
linkRepo(bothMatch.ID, "git@github.com:acme/widget.git")
searchOnly := createChat(t, db, firstUser, modelConfig.ID, "kubernetes elsewhere")
linkRepo(searchOnly.ID, "git@github.com:acme/other.git")
repoOnly := createChat(t, db, firstUser, modelConfig.ID, "plain title")
linkRepo(repoOnly.ID, "git@github.com:acme/widget.git")
// Matches via message body, not title, so composition also covers
// search_tsv.
bodyMatch := createChat(t, db, firstUser, modelConfig.ID, "quiet title")
linkRepo(bodyMatch.ID, "git@github.com:acme/widget.git")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatch.ID, "kubernetes rollout stuck")
bodyMatchWrongRepo := createChat(t, db, firstUser, modelConfig.ID, "quiet title two")
linkRepo(bodyMatchWrongRepo.ID, "git@github.com:acme/other.git")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatchWrongRepo.ID, "kubernetes rollout stuck")
archivedMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes archived")
linkRepo(archivedMatch.ID, "git@github.com:acme/widget.git")
_, err := db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), archivedMatch.ID)
require.NoError(t, err)
backfillSearchTsv(ctx, t, db)
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `repo:widget search:"kubernetes"`,
})
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, bothMatch.ID)
require.Contains(t, ids, bodyMatch.ID)
require.NotContains(t, ids, bodyMatchWrongRepo.ID)
require.NotContains(t, ids, searchOnly.ID)
require.NotContains(t, ids, repoOnly.ID)
// Archived chats stay hidden unless archived:true is requested.
require.NotContains(t, ids, archivedMatch.ID)
})
t.Run("NoSearchTermUnchanged", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)
chat := createChat(t, db, firstUser, modelConfig.ID, "kubernetes upgrade notes")
other := createChat(t, db, firstUser, modelConfig.ID, "unrelated chat")
chats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, chat.ID)
require.Contains(t, ids, other.ID)
})
t.Run("MutualExclusionWithTitleReturns400", func(t *testing.T) {
t.Parallel()
ctx, client, _, _, _ := setup(t)
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:alpha title:beta`,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "search", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, `"title"`)
})
}
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)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "watch route created event",
},
},
})
require.NoError(t, err)
for {
var payload codersdk.ChatWatchEvent
err = wsjson.Read(ctx, conn, &payload)
require.NoError(t, err)
if payload.Kind == codersdk.ChatWatchEventKindCreated &&
payload.Chat.ID == createdChat.ID {
break
}
}
})
t.Run("CreatedEventIncludesAllChatFields", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "watch route fields completeness test",
},
},
})
require.NoError(t, err)
var got codersdk.Chat
testutil.Eventually(ctx, t, func(_ context.Context) bool {
var payload codersdk.ChatWatchEvent
if readErr := wsjson.Read(ctx, conn, &payload); readErr != nil {
return false
}
if payload.Kind == codersdk.ChatWatchEventKindCreated &&
payload.Chat.ID == createdChat.ID {
got = payload.Chat
return true
}
return false
}, testutil.IntervalFast, "expected a created event for chat %s", createdChat.ID)
require.Equal(t, createdChat.ID, got.ID)
require.Equal(t, createdChat.OwnerID, got.OwnerID)
require.Equal(t, modelConfig.ID, got.LastModelConfigID)
require.Equal(t, createdChat.Title, got.Title)
// CreateChat inserts new chats in the running state under the
// chatstate state machine, so the created event carries running.
require.Equal(t, codersdk.ChatStatusRunning, got.Status)
require.NotNil(t, got.RootChatID)
require.Equal(t, createdChat.ID, *got.RootChatID)
require.NotZero(t, got.CreatedAt)
require.NotZero(t, got.UpdatedAt)
})
t.Run("DiffStatusChangeIncludesDiffStatusAndOmitsInjectedContext", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
})
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(rawClient)
db := api.Database
chatDaemon := api.ChatDaemon()
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Insert a chat and a diff status row.
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "diff status watch test",
})
refreshedAt := time.Now().UTC().Truncate(time.Second)
staleAt := refreshedAt.Add(time.Hour)
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
Url: sql.NullString{String: "https://github.com/coder/coder/pull/99", Valid: true},
GitBranch: "feature/test",
GitRemoteOrigin: "git@github.com:coder/coder.git",
StaleAt: staleAt,
},
)
require.NoError(t, err)
_, err = db.UpsertChatDiffStatus(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusParams{
ChatID: chat.ID,
Url: sql.NullString{String: "https://github.com/coder/coder/pull/99", Valid: true},
GitBranch: "feature/test",
GitRemoteOrigin: "git@github.com:coder/coder.git",
PullRequestState: sql.NullString{String: "open", Valid: true},
Additions: 42,
Deletions: 7,
ChangedFiles: 5,
RefreshedAt: refreshedAt,
StaleAt: staleAt,
},
)
require.NoError(t, err)
// Open the watch WebSocket.
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
err = chatDaemon.PublishDiffStatusChange(dbauthz.AsChatd(ctx), chat.ID, codersdk.DiffStatusRef{
RemoteOrigin: "git@github.com:coder/coder.git",
GitBranch: "feature/test",
})
require.NoError(t, err)
var received codersdk.ChatWatchEvent
for {
err = wsjson.Read(ctx, conn, &received)
require.NoError(t, err)
if received.Kind == codersdk.ChatWatchEventKindDiffStatusChange &&
received.Chat.ID == chat.ID {
break
}
}
// Verify the event carries the full DiffStatus.
require.NotNil(t, received.Chat.DiffStatus, "diff_status_change event must include DiffStatus")
ds := received.Chat.DiffStatus
require.Equal(t, chat.ID, ds.ChatID)
require.NotNil(t, ds.URL)
require.Equal(t, "https://github.com/coder/coder/pull/99", *ds.URL)
require.NotNil(t, ds.PullRequestState)
require.Equal(t, "open", *ds.PullRequestState)
require.EqualValues(t, 42, ds.Additions)
require.EqualValues(t, 7, ds.Deletions)
require.EqualValues(t, 5, ds.ChangedFiles)
require.NotNil(t, received.ChangedDiffStatus)
require.Equal(t, "git@github.com:coder/coder.git", received.ChangedDiffStatus.Ref.RemoteOrigin)
require.Equal(t, "feature/test", received.ChangedDiffStatus.Ref.GitBranch)
require.NotNil(t, received.ChangedDiffStatus.Status)
require.NotNil(t, received.ChangedDiffStatus.Status.URL)
require.Equal(
t,
"https://github.com/coder/coder/pull/99",
*received.ChangedDiffStatus.Status.URL,
)
require.NotNil(t, received.ChangedDiffStatus.Status.PullRequestState)
require.Equal(
t,
"open",
*received.ChangedDiffStatus.Status.PullRequestState,
)
require.EqualValues(t, 42, received.ChangedDiffStatus.Status.Additions)
require.EqualValues(t, 7, received.ChangedDiffStatus.Status.Deletions)
require.EqualValues(t, 5, received.ChangedDiffStatus.Status.ChangedFiles)
})
t.Run("ArchiveAndUnarchiveEmitEventsForDescendants", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "watch root chat",
},
},
})
require.NoError(t, err)
// The parent chat is created via the API, so the chat worker moves
// it to running. Archiving is only allowed from a terminal state,
// so wait for it to settle before archiving below.
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
childOne := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "watch child 1",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
childTwo := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "watch child 2",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
collectLifecycleEvents := func(expectedKind codersdk.ChatWatchEventKind) map[uuid.UUID]codersdk.ChatWatchEvent {
t.Helper()
events := make(map[uuid.UUID]codersdk.ChatWatchEvent, 3)
for len(events) < 3 {
var payload codersdk.ChatWatchEvent
err = wsjson.Read(ctx, conn, &payload)
require.NoError(t, err)
if payload.Kind != expectedKind {
continue
}
events[payload.Chat.ID] = payload
}
return events
}
assertLifecycleEvents := func(events map[uuid.UUID]codersdk.ChatWatchEvent, archived bool) {
t.Helper()
require.Len(t, events, 3)
for _, chatID := range []uuid.UUID{parentChat.ID, childOne.ID, childTwo.ID} {
payload, ok := events[chatID]
require.True(t, ok, "missing event for chat %s", chatID)
require.Equal(t, archived, payload.Chat.Archived)
}
}
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
deletedEvents := collectLifecycleEvents(codersdk.ChatWatchEventKindDeleted)
assertLifecycleEvents(deletedEvents, true)
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
require.NoError(t, err)
createdEvents := collectLifecycleEvents(codersdk.ChatWatchEventKindCreated)
assertLifecycleEvents(createdEvents, false)
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
unauthenticatedClient := codersdk.New(client.URL)
res, err := unauthenticatedClient.Request(
ctx,
http.MethodGet,
"/api/v2/chats/watch",
nil,
)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
}
func TestUserAIProviderKeys(t *testing.T) {
t.Parallel()
createOpenAIProvider := func(t *testing.T, client *codersdk.ExperimentalClient, name string, enabled bool, apiKeys ...string) codersdk.AIProvider {
t.Helper()
provider, err := client.CreateAIProvider(testutil.Context(t, testutil.WaitLong), codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: name,
Enabled: enabled,
BaseURL: "https://api.openai.example.com/v1",
APIKeys: apiKeys,
})
require.NoError(t, err)
return provider
}
findUserAIProviderKeyConfig := func(
t *testing.T,
configs []codersdk.UserAIProviderKeyConfig,
providerID uuid.UUID,
) *codersdk.UserAIProviderKeyConfig {
t.Helper()
for i := range configs {
if configs[i].Provider.ID == providerID {
return &configs[i]
}
}
return nil
}
t.Run("SelfServiceLifecycle", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider := createOpenAIProvider(t, adminClient, "test-user-key-"+uuid.NewString(), true, "test-provider-api-key")
configs, err := memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg := findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.False(t, cfg.HasUserAPIKey)
require.True(t, cfg.HasProviderAPIKey)
require.True(t, cfg.BYOKEnabled)
cfgValue, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "test-user-api-key"})
require.NoError(t, err)
require.Equal(t, provider.ID, cfgValue.Provider.ID)
require.True(t, cfgValue.HasUserAPIKey)
require.True(t, cfgValue.HasProviderAPIKey)
require.True(t, cfgValue.BYOKEnabled)
configs, err = memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg = findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.True(t, cfg.HasUserAPIKey)
cfgValue, err = memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "replacement-user-api-key"})
require.NoError(t, err)
require.Equal(t, provider.ID, cfgValue.Provider.ID)
require.True(t, cfgValue.HasUserAPIKey)
configs, err = memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg = findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.True(t, cfg.HasUserAPIKey)
require.NoError(t, memberClient.DeleteUserAIProviderKey(ctx, "me", provider.ID))
configs, err = memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg = findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.False(t, cfg.HasUserAPIKey)
})
t.Run("ListsDisabledProviderWithSavedUserKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider := createOpenAIProvider(t, adminClient, "test-disabled-saved-user-key-"+uuid.NewString(), true)
_, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "test-user-api-key"})
require.NoError(t, err)
enabled := false
_, err = adminClient.UpdateAIProvider(ctx, provider.ID.String(), codersdk.UpdateAIProviderRequest{Enabled: &enabled})
require.NoError(t, err)
configs, err := memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg := findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.False(t, cfg.Provider.Enabled)
require.True(t, cfg.HasUserAPIKey)
})
t.Run("RejectsDisabledProvider", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider := createOpenAIProvider(t, adminClient, "test-disabled-user-key-"+uuid.NewString(), false)
_, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "test-user-api-key"})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is disabled.", sdkErr.Message)
})
t.Run("AcceptsBedrockProvider", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider, err := adminClient.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeBedrock,
Name: "test-bedrock-user-key-" + uuid.NewString(),
Enabled: true,
BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
Settings: codersdk.AIProviderSettings{
Bedrock: &codersdk.AIProviderBedrockSettings{
Region: "us-east-1",
Model: "anthropic.claude-3-5-sonnet",
SmallFastModel: "anthropic.claude-3-5-haiku",
},
},
})
require.NoError(t, err)
// Bedrock providers accept a user-supplied Bedrock API key, which the
// AI gateway forwards as a bearer token.
cfg, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "test-user-api-key"})
require.NoError(t, err)
require.True(t, cfg.HasUserAPIKey)
configs, err := memberClient.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
listed := findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, listed)
require.True(t, listed.HasUserAPIKey)
})
t.Run("RejectsLargeAPIKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider := createOpenAIProvider(t, adminClient, "test-large-user-key-"+uuid.NewString(), true)
_, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: strings.Repeat("x", 10241)})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key too large.", sdkErr.Message)
})
t.Run("RejectsWhitespaceAPIKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
provider := createOpenAIProvider(t, adminClient, "test-whitespace-user-key-"+uuid.NewString(), true)
_, err := memberClient.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: " "})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key must not contain leading or trailing whitespace.", sdkErr.Message)
})
t.Run("BYOKDisabledRejectsUpsertAndAllowsDelete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
values := coderdtest.DeploymentValues(t)
values.AI.BridgeConfig.AllowBYOK = serpent.Bool(false)
// The aibridged reloader logs at error level when it sees a provider
// configured with no API key and BYOK disabled. That state is the
// scenario under test, so suppress its error logs here.
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
client := newChatClient(t, func(o *coderdtest.Options) {
o.DeploymentValues = values
o.Logger = &logger
})
_ = coderdtest.CreateFirstUser(t, client.Client)
provider := createOpenAIProvider(t, client, "test-byok-disabled-"+uuid.NewString(), true)
_, err := client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "test-user-api-key"})
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "BYOK is disabled.", sdkErr.Message)
configs, err := client.ListUserAIProviderKeyConfigs(ctx, "me")
require.NoError(t, err)
cfg := findUserAIProviderKeyConfig(t, configs, provider.ID)
require.NotNil(t, cfg)
require.False(t, cfg.BYOKEnabled)
require.NoError(t, client.DeleteUserAIProviderKey(ctx, "me", provider.ID))
})
}
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.Client)
modelConfig := createChatModel(t, client)
configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
require.NotEmpty(t, configs.Models)
require.NotEmpty(t, configs.Providers)
require.Empty(t, configs.UnsupportedProviders)
found := false
for _, config := range configs.Models {
if config.ID == modelConfig.ID {
found = true
require.Equal(t, modelConfig.AIProviderID, config.AIProviderID)
require.Equal(t, modelConfig.Model, config.Model)
require.True(t, config.IsDefault)
}
}
require.True(t, found)
})
t.Run("CollectionIncludesDisabledModelConfigs", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
enabled := false
disabledConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-disabled",
DisplayName: "GPT-4o Disabled",
Enabled: &enabled,
ContextLimit: &contextLimit,
})
require.NoError(t, err)
require.False(t, disabledConfig.Enabled)
configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, slices.ContainsFunc(configs.Models, func(config codersdk.ChatModel) bool {
return config.ID == disabledConfig.ID && !config.Enabled
}))
})
t.Run("OrganizationMemberSeesReadableModels", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
readableConfig := createChatModel(t, adminClient)
privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: readableConfig.AIProviderID, Valid: true},
Model: "private-" + uuid.NewString(),
OrganizationID: firstUser.OrganizationID,
GroupACL: database.ChatACL{},
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
configs, err := memberClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
modelIDs := make([]uuid.UUID, 0, len(configs.Models))
for _, config := range configs.Models {
modelIDs = append(modelIDs, config.ID)
}
require.Contains(t, modelIDs, readableConfig.ID)
require.NotContains(t, modelIDs, privateConfig.ID)
})
t.Run("BedrockDescriptorReportsEffectiveCredentials", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider := dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AIProviderTypeBedrock,
})
models, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
for _, descriptor := range models.Providers {
if descriptor.ID == provider.ID {
require.False(t, descriptor.HasAPIKey)
require.False(t, descriptor.HasUserAPIKey)
require.True(t, descriptor.HasEffectiveAPIKey)
require.True(t, descriptor.Available)
require.Empty(t, descriptor.UnavailableReason)
return
}
}
t.Fatal("bedrock provider descriptor not found")
})
t.Run("ComputerUseProvider", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
anthropicProvider := dbgen.AIProvider(t, db, database.AIProvider{Type: database.AIProviderTypeAnthropic})
bedrockProvider := dbgen.AIProvider(t, db, database.AIProvider{Type: database.AIProviderTypeBedrock})
compatProvider := dbgen.AIProvider(t, db, database.AIProvider{Type: database.AIProviderTypeOpenaiCompat})
want := map[uuid.UUID]codersdk.ChatComputerUseProvider{}
for _, tc := range []struct {
provider database.AIProvider
model string
want codersdk.ChatComputerUseProvider
}{
{provider: anthropicProvider, model: "claude-opus-5-5", want: codersdk.ChatComputerUseProviderAnthropic},
{provider: bedrockProvider, model: "us.anthropic.claude-opus-5-5", want: codersdk.ChatComputerUseProviderAnthropic},
{provider: bedrockProvider, model: "openai.gpt-5.4"},
{provider: compatProvider, model: "claude-opus-5-5", want: codersdk.ChatComputerUseProviderOpenAICompat},
} {
config := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: tc.provider.ID, Valid: true},
Model: tc.model,
OrganizationID: firstUser.OrganizationID,
})
want[config.ID] = tc.want
}
models, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
got := map[uuid.UUID]codersdk.ChatComputerUseProvider{}
for _, model := range models.Models {
if _, ok := want[model.ID]; ok {
got[model.ID] = model.ComputerUseProvider
}
}
require.Equal(t, want, got)
})
}
func TestGetChatModel(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.Client)
modelConfig := createChatModel(t, client)
got, err := client.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
require.Equal(t, modelConfig.ID, got.ID)
})
t.Run("WrongOrganizationConcealedForAllItemActions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
wrongOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
for _, tc := range []struct {
name string
method string
body any
}{
{name: "Get", method: http.MethodGet},
{name: "Patch", method: http.MethodPatch, body: codersdk.UpdateChatModelRequest{DisplayName: "wrong organization"}},
{name: "Delete", method: http.MethodDelete},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := client.Request(ctx, tc.method, fmt.Sprintf(
"/api/v2/organizations/%s/chats/models/%s",
wrongOrganization.ID,
modelConfig.ID,
), tc.body)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
}
stored, err := db.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfig.ID)
require.NoError(t, err)
require.Equal(t, modelConfig.DisplayName, stored.DisplayName)
})
t.Run("OrganizationMemberCanReadSharedModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
modelConfig := createChatModel(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
got, err := memberClient.ChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
require.Equal(t, modelConfig.ID, got.ID)
})
t.Run("OrganizationMemberCannotReadPrivateModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
sharedConfig := createChatModel(t, adminClient)
privateConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: sharedConfig.AIProviderID, Valid: true},
Model: "private-" + uuid.NewString(),
OrganizationID: firstUser.OrganizationID,
GroupACL: database.ChatACL{},
})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.ChatModel(ctx, privateConfig.OrganizationID, privateConfig.ID)
requireSDKError(t, err, http.StatusNotFound)
})
}
func TestCreateChatModelConfig(t *testing.T) {
t.Parallel()
t.Run("UnauthorizedPrincipalCannotProbeProviderState", func(t *testing.T) {
t.Parallel()
// The write is authorized before any privileged provider lookup, so
// a principal without create-in-org gets an identical denial whether
// the provider ID is missing, disabled, or configured: no provider
// state is disclosed.
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
configuredProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
disabledProvider := createAIProviderForTest(t, client, "google", "test-api-key-2")
enabled := false
_, err := client.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{
Enabled: &enabled,
})
require.NoError(t, err)
for _, tc := range []struct {
name string
providerID uuid.UUID
}{
{"missing", uuid.New()},
{"disabled", disabledProvider.ID},
{"configured", configuredProvider.ID},
} {
t.Run(tc.name, func(t *testing.T) {
contextLimit := int64(4096)
_, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &tc.providerID,
Model: "probe-" + tc.name,
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "Forbidden.", sdkErr.Message)
})
}
})
t.Run("NormalCreateConcealsHiddenAndMissingOrganizations", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
provider := createAIProviderForTest(t, adminClient, "openai", "test-api-key")
hiddenOrganization := dbgen.Organization(t, db, database.Organization{IsDefault: false})
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
contextLimit := int64(4096)
var concealedMessage string
for _, organizationID := range []uuid.UUID{hiddenOrganization.ID, uuid.New()} {
_, err := memberClient.CreateChatModel(ctx, organizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "concealed-" + uuid.NewString(),
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusNotFound)
if concealedMessage == "" {
concealedMessage = sdkErr.Message
}
require.Equal(t, concealedMessage, sdkErr.Message)
}
configs, err := adminClient.ChatModels(ctx, hiddenOrganization.ID)
require.NoError(t, err)
require.Empty(t, configs.Models)
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
isDefault := true
modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, modelConfig.ID)
require.Equal(t, aiProvider.ID, modelConfig.AIProviderID)
require.Equal(t, "gpt-4o-mini", modelConfig.Model)
require.EqualValues(t, 4096, modelConfig.ContextLimit)
require.True(t, modelConfig.IsDefault)
configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs.Models, 1)
})
t.Run("ProviderReferenceLockFailureUsesCreateErrorText", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
store.armFailProviderReferenceLock(aiProvider.ID)
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusInternalServerError)
require.Equal(t, "Failed to create chat model config.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "get AI provider for create")
require.NotContains(t, sdkErr.Detail, "get AI provider for update")
})
t.Run("ConcurrentCreatesElectSingleDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
// Concurrent creators race to self-elect a default while one claims
// it via a follow-up update, mirroring a terraform apply. Unserialized,
// the losers 409 on the single-default unique index.
const creators = 10
contextLimit := int64(4096)
var claimed codersdk.ChatModel
var eg errgroup.Group
for i := range creators - 1 {
eg.Go(func() error {
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: fmt.Sprintf("gpt-4o-mini-%d", i),
ContextLimit: &contextLimit,
})
return err
})
}
eg.Go(func() error {
created, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o",
ContextLimit: &contextLimit,
})
if err != nil {
return xerrors.Errorf("create claimed config: %w", err)
}
claimed, err = client.UpdateChatModel(ctx, created.OrganizationID, created.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(true),
})
if err != nil {
return xerrors.Errorf("promote claimed config: %w", err)
}
return nil
})
require.NoError(t, eg.Wait())
configs, err := client.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs.Models, creators)
var defaults []uuid.UUID
for _, cfg := range configs.Models {
if cfg.IsDefault {
defaults = append(defaults, cfg.ID)
}
}
require.Equal(t, []uuid.UUID{claimed.ID}, defaults)
})
t.Run("SuccessCustomOrgRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
_ = coderdtest.CreateFirstUser(t, client.Client)
defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
require.NoError(t, err)
// A custom org role holding only chat_model_config permissions
// passes write authorization for configs in its org and holds no
// grant anywhere else; org role permissions are scoped to the
// organization the role belongs to.
role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: testutil.GetRandomName(t),
DisplayName: "Chat Model Config Admin",
OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true},
OrgPermissions: database.CustomRolePermissions{
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionCreate,
},
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionRead,
},
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionUpdate,
},
},
})
require.NoError(t, err)
// An existing org default keeps the create off the self-promotion
// path; the writer below holds no chat_model_config:update grant
// beyond its own org. The owner seeds the provider and the
// default: both operations need site-wide permissions a
// non-owner lacks.
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
_ = createAdditionalChatModel(t, client, "openai", "gpt-4o-existing")
adminClientRaw, adminUser := coderdtest.CreateAnotherUser(
t,
client.Client,
defaultOrg.ID,
)
_, err = client.UpdateOrganizationMemberRoles(
ctx,
defaultOrg.ID,
adminUser.ID.String(),
codersdk.UpdateRoles{Roles: []string{role.Name}},
)
require.NoError(t, err)
adminClient := codersdk.NewExperimentalClient(adminClientRaw)
contextLimit := int64(4096)
modelConfig, err := adminClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
row, err := rawDB.GetChatModelConfigByID(ctx, modelConfig.ID)
require.NoError(t, err)
require.Equal(t, defaultOrg.ID, row.OrganizationID)
require.False(t, row.IsDefault)
require.Equal(
t,
database.ChatACL{
defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
row.GroupACL,
)
require.Equal(t, database.ChatACL{}, row.UserACL)
})
// An organization role can manage the full model lifecycle. Default
// election must not select another organization's config.
t.Run("OrgRoleWritesThroughLifecycle", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
_ = coderdtest.CreateFirstUser(t, client.Client)
defaultOrg, err := rawDB.GetDefaultOrganization(ctx)
require.NoError(t, err)
// The writer role holds chat_model_config permissions in the
// default org and nothing else: user-scoped member permissions do
// not carry the org-wide create grant the route needs, so the role
// must supply every action the lifecycle touches.
role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{
Name: testutil.GetRandomName(t),
DisplayName: "Chat Model Config Admin",
OrganizationID: uuid.NullUUID{UUID: defaultOrg.ID, Valid: true},
OrgPermissions: database.CustomRolePermissions{
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionCreate,
},
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionRead,
},
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionUpdate,
},
{
ResourceType: rbac.ResourceChatModelConfig.Type,
Action: policy.ActionDelete,
},
},
})
require.NoError(t, err)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
writerClientRaw, writerUser := coderdtest.CreateAnotherUser(
t,
client.Client,
defaultOrg.ID,
)
_, err = client.UpdateOrganizationMemberRoles(
ctx,
defaultOrg.ID,
writerUser.ID.String(),
codersdk.UpdateRoles{Roles: []string{role.Name}},
)
require.NoError(t, err)
writerClient := codersdk.NewExperimentalClient(writerClientRaw)
contextLimit := int64(4096)
defaultConfig, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
require.True(t, defaultConfig.IsDefault)
updated, err := writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "Window Write",
})
require.NoError(t, err)
require.Equal(t, "Window Write", updated.DisplayName)
candidate, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini-candidate",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
notDefault := false
_, err = writerClient.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ¬Default,
})
require.NoError(t, err)
candidateRow, err := rawDB.GetChatModelConfigByID(ctx, candidate.ID)
require.NoError(t, err)
require.True(t, candidateRow.IsDefault)
err = writerClient.DeleteChatModel(ctx, candidate.OrganizationID, candidate.ID)
require.NoError(t, err)
defaultRow, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID)
require.NoError(t, err)
require.True(t, defaultRow.IsDefault)
promoted, err := writerClient.CreateChatModel(ctx, defaultOrg.ID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini-promoted",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
isDefault := true
_, err = writerClient.UpdateChatModel(ctx, promoted.OrganizationID, promoted.ID, codersdk.UpdateChatModelRequest{
IsDefault: &isDefault,
})
require.NoError(t, err)
promotedRow, err := rawDB.GetChatModelConfigByID(ctx, promoted.ID)
require.NoError(t, err)
require.True(t, promotedRow.IsDefault)
})
t.Run("ReasoningEffortStored", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("medium"),
Max: ptr.Ref("xhigh"),
},
},
})
require.NoError(t, err)
require.NotNil(t, modelConfig.ModelConfig)
require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort)
require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort.Default)
require.Equal(t, "medium", *modelConfig.ModelConfig.ReasoningEffort.Default)
require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort.Max)
require.Equal(t, "xhigh", *modelConfig.ModelConfig.ReasoningEffort.Max)
require.Equal(t, []string{"none", "minimal", "low", "medium", "high", "xhigh"}, modelConfig.ReasoningEfforts)
})
t.Run("ReasoningEffortRejectsSingleValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("high"),
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model config.", sdkErr.Message)
require.Equal(t, "reasoning_effort.default and reasoning_effort.max must both be set", sdkErr.Detail)
})
t.Run("ReasoningEffortRejectsInvalidValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(" HIGH "),
Max: ptr.Ref("high"),
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model config.", sdkErr.Message)
require.Equal(
t,
`reasoning_effort.default " HIGH " must be one of none, minimal, low, medium, high, xhigh, max`,
sdkErr.Detail,
)
})
t.Run("ReasoningEffortRejectsDefaultAboveMax", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("xhigh"),
Max: ptr.Ref("low"),
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model config.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "must not exceed")
})
t.Run("MissingContextLimit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Context limit is required.", sdkErr.Message)
})
t.Run("AIProviderIDRequired", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "AI provider ID is required.", sdkErr.Message)
})
t.Run("ProviderNotConfigured", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
contextLimit := int64(4096)
missingProviderID := uuid.New()
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &missingProviderID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
t.Run("WithAIProviderID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-model-config-provider-" + uuid.NewString(),
Enabled: true,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)
contextLimit := int64(4096)
modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, modelConfig.AIProviderID)
require.Equal(t, provider.ID, modelConfig.AIProviderID)
})
t.Run("AIProviderIDNotConfigured", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
missingProviderID := uuid.New()
contextLimit := int64(4096)
_, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &missingProviderID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
t.Run("AIProviderIDDisabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-disabled-model-provider-" + uuid.NewString(),
Enabled: false,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)
contextLimit := int64(4096)
_, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is disabled.", sdkErr.Message)
})
t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "openrouter",
Enabled: true,
BaseURL: "https://openrouter.ai/api/v1",
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
contextLimit := int64(4096)
_, err = client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "anthropic/claude-opus-4.6",
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
})
t.Run("ForbiddenForOrganizationMember", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
aiProvider := createAIProviderForTest(t, adminClient, "openai", "test-api-key")
contextLimit := int64(4096)
_, err := memberClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
// A plain org member holds no chat model config permissions in the
// org, so the create is rejected by the write pre-check before any
// privileged provider lookup.
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "Forbidden.", sdkErr.Message)
})
}
func TestUpdateChatModel(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.Client)
modelConfig := createChatModel(t, client)
contextLimit := int64(8192)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
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)
configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
require.Len(t, configs.Models, 1)
})
t.Run("SoleConfigRemainsDefaultWhenDemoted", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(false),
})
require.NoError(t, err)
require.True(t, updated.IsDefault)
defaultConfig, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), firstUser.OrganizationID)
require.NoError(t, err)
require.Equal(t, modelConfig.ID, defaultConfig.ID)
})
t.Run("PromotionLeavesOtherOrganizationDefaultUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
candidateConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"promotion-candidate",
)
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "other-org-default-" + uuid.NewString(),
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
OrganizationID: otherOrg.ID,
IsDefault: true,
})
updated, err := client.UpdateChatModel(ctx, candidateConfig.OrganizationID, candidateConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(true),
})
require.NoError(t, err)
require.True(t, updated.IsDefault)
orgDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), firstUser.OrganizationID)
require.NoError(t, err)
require.Equal(t, candidateConfig.ID, orgDefault.ID)
unchangedOtherDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), otherOrg.ID)
require.NoError(t, err)
require.Equal(t, otherDefault.ID, unchangedOtherDefault.ID)
})
t.Run("UnchangedProviderWithoutAIProviderID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-mini-updated",
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.NotEqual(t, uuid.Nil, updated.AIProviderID)
require.Equal(t, modelConfig.AIProviderID, updated.AIProviderID)
require.Equal(t, "gpt-4o-mini-updated", updated.Model)
})
t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "openrouter",
Enabled: true,
BaseURL: "https://openrouter.ai/api/v1",
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
contextLimit := int64(4096)
modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "anthropic/claude-opus-4.6",
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
})
t.Run("AllowsUnrelatedEditOnExistingMisconfiguredOpenAI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "openrouter",
Enabled: true,
BaseURL: "https://openrouter.ai/api/v1",
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "anthropic/claude-opus-4.6",
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
})
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "Existing OpenRouter Config",
})
require.NoError(t, err)
require.Equal(t, "Existing OpenRouter Config", updated.DisplayName)
require.Equal(t, modelConfig.Model, updated.Model)
})
t.Run("RejectsProviderChangeToMisconfiguredOpenAI", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
validProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenrouter,
Name: "openrouter-valid",
Enabled: true,
BaseURL: "https://openrouter.ai/api/v1",
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
misconfiguredProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "openrouter",
Enabled: true,
BaseURL: "https://openrouter.ai/api/v1",
APIKeys: []string{"test-api-key"},
})
require.NoError(t, err)
contextLimit := int64(4096)
modelConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &validProvider.ID,
Model: "anthropic/claude-opus-4.6",
ContextLimit: &contextLimit,
})
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &misconfiguredProvider.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
})
t.Run("DisablePreservesRecordAndRejectsUseForMembers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
modelConfig := createChatModel(t, adminClient)
enabled := false
updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Enabled: &enabled,
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.False(t, updated.Enabled)
adminConfigs, err := adminClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, slices.ContainsFunc(adminConfigs.Models, func(config codersdk.ChatModel) bool {
return config.ID == modelConfig.ID && !config.Enabled
}))
collection, err := memberClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, slices.ContainsFunc(collection.Models, func(config codersdk.ChatModel) bool {
return config.ID == modelConfig.ID && !config.Enabled
}))
_, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "use a disabled model config",
}},
ModelConfigID: &modelConfig.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
t.Run("ReEnableUpdatesCollectionDescriptor", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
aiProvider := createAIProviderForTest(t, adminClient, "openai", "test-api-key")
contextLimit := int64(4096)
enabled := false
modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-reenable",
DisplayName: "GPT-4o Re-enable",
Enabled: &enabled,
ContextLimit: &contextLimit,
})
require.NoError(t, err)
require.False(t, modelConfig.Enabled)
collection, err := memberClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, slices.ContainsFunc(collection.Models, func(config codersdk.ChatModel) bool {
return config.ID == modelConfig.ID && !config.Enabled
}))
enabled = true
updated, err := adminClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Enabled: &enabled,
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.True(t, updated.Enabled)
collection, err = memberClient.ChatModels(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.True(t, slices.ContainsFunc(collection.Models, func(config codersdk.ChatModel) bool {
return config.ID == modelConfig.ID && config.Enabled
}))
})
t.Run("UpdateAIProviderID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeAnthropic,
Name: "test-update-model-provider-" + uuid.NewString(),
Enabled: true,
BaseURL: "https://api.anthropic.com",
})
require.NoError(t, err)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-3-5-sonnet-latest",
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, updated.AIProviderID)
require.Equal(t, provider.ID, updated.AIProviderID)
})
t.Run("UpdateProviderPreservesAIProviderIDWhenTypeUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeAnthropic,
Name: "test-preserve-model-provider-" + uuid.NewString(),
Enabled: true,
BaseURL: "https://api.anthropic.com",
})
require.NoError(t, err)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
Model: "claude-3-5-sonnet-latest",
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, updated.AIProviderID)
updated, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "claude-3-5-haiku-latest",
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, updated.AIProviderID)
require.Equal(t, provider.ID, updated.AIProviderID)
})
t.Run("UpdateAIProviderIDNotConfigured", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
missingProviderID := uuid.New()
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &missingProviderID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
t.Run("UpdateAIProviderIDDisabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "test-update-disabled-model-provider-" + uuid.NewString(),
Enabled: false,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &provider.ID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is disabled.", sdkErr.Message)
})
t.Run("ProviderNotConfigured", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
missingProviderID := uuid.New()
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
AIProviderID: &missingProviderID,
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
t.Run("NotFoundWhenTargetRowDisappearsInTx", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
store.armFailNextUpdate(modelConfig.ID)
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "missing in tx",
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("InternalServerErrorWhenDefaultCandidateDisappearsInTx", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
aiProvider := createAIProviderForTest(t, client, "anthropic", "candidate-api-key")
contextLimit := int64(4096)
isDefault := false
candidateConfig, err := client.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &aiProvider.ID,
Model: "claude-3-5-sonnet",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
})
require.NoError(t, err)
store.armFailNextUpdate(candidateConfig.ID)
_, err = client.UpdateChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID, codersdk.UpdateChatModelRequest{
IsDefault: ptr.Ref(false),
})
sdkErr := requireSDKError(t, err, http.StatusInternalServerError)
require.Equal(t, "Failed to update chat model config.", sdkErr.Message)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_, err := client.UpdateChatModel(ctx, firstUser.OrganizationID, uuid.New(), codersdk.UpdateChatModelRequest{
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.Client)
modelConfig := createChatModel(t, client)
contextLimit := int64(0)
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
ContextLimit: &contextLimit,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Context limit must be greater than zero.", sdkErr.Message)
})
// A row that disappears between the pre-read and the locked read must
// 404, not silently no-op the update.
t.Run("NotFoundWhenTargetRowDisappearsAtLockedRead", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
store.armVanishAtLockedRead(modelConfig.ID)
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "vanished before update",
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("MergesFromLockedCopy", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
const sentinel = "locked-copy-sentinel-model"
store.armMutateAtLockedRead(modelConfig.ID, sentinel)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "only-display-name",
})
require.NoError(t, err)
require.Equal(t, sentinel, updated.Model)
require.Equal(t, "only-display-name", updated.DisplayName)
})
t.Run("StoredProviderDisabledOnModelOnlyUpdate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
disabled := false
_, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
Enabled: &disabled,
})
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-different",
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is disabled.", sdkErr.Message)
})
t.Run("StoredProviderMissingOnModelOnlyUpdate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
err := client.DeleteAIProvider(ctx, modelConfig.AIProviderID.String())
require.NoError(t, err)
_, err = client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
Model: "gpt-4o-different",
})
sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed)
require.Equal(t, "AI provider is not configured.", sdkErr.Message)
})
t.Run("RejectsACLKeys", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
for _, tc := range []struct {
name string
key string
value any
}{
{name: "group_acl/object", key: "group_acl", value: map[string]any{}},
{name: "group_acl/null", key: "group_acl", value: nil},
{name: "user_acl/object", key: "user_acl", value: map[string]any{}},
{name: "user_acl/null", key: "user_acl", value: nil},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := client.Request(ctx, http.MethodPatch, fmt.Sprintf(
"/api/v2/organizations/%s/chats/models/%s",
modelConfig.OrganizationID,
modelConfig.ID,
), map[string]any{tc.key: tc.value})
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Contains(t, sdkErr.Message, "nested /acl endpoint")
})
}
})
t.Run("CompressionThresholdOutOfRange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
_, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
CompressionThreshold: ptr.Ref(int32(150)),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid compression threshold.", sdkErr.Message)
})
t.Run("CompressionThresholdInRange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
CompressionThreshold: ptr.Ref(int32(55)),
})
require.NoError(t, err)
require.Equal(t, int32(55), updated.CompressionThreshold)
})
t.Run("ModelConfigUpdated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
updated, err := client.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
ModelConfig: &codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("high"),
Max: ptr.Ref("high"),
},
},
})
require.NoError(t, err)
require.NotNil(t, updated.ModelConfig)
require.NotNil(t, updated.ModelConfig.ReasoningEffort)
require.NotNil(t, updated.ModelConfig.ReasoningEffort.Default)
require.Equal(t, "high", *updated.ModelConfig.ReasoningEffort.Default)
})
t.Run("InvalidModelConfigID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
res, err := client.Request(
ctx,
http.MethodPatch,
fmt.Sprintf("/api/v2/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
codersdk.UpdateChatModelRequest{DisplayName: "ignored"},
)
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, `Invalid UUID "not-a-uuid".`, 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.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
modelConfig := createChatModel(t, adminClient)
_, err := memberClient.UpdateChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID, codersdk.UpdateChatModelRequest{
DisplayName: "member update",
})
// Action-aware item admission conceals a caller without update access.
sdkErr := requireSDKError(t, err, http.StatusNotFound)
require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message)
})
}
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.Client)
modelConfig := createChatModel(t, client)
err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
require.NoError(t, err)
configs, err := client.ChatModels(ctx, modelConfig.OrganizationID)
require.NoError(t, err)
for _, config := range configs.Models {
require.NotEqual(t, modelConfig.ID, config.ID)
}
})
t.Run("DeletionLeavesOtherOrganizationDefaultUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
candidateConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"delete-candidate",
)
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherDefault := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "other-org-default-" + uuid.NewString(),
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
OrganizationID: otherOrg.ID,
IsDefault: true,
})
err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID)
require.NoError(t, err)
orgDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), firstUser.OrganizationID)
require.NoError(t, err)
require.Equal(t, candidateConfig.ID, orgDefault.ID)
unchangedOtherDefault, err := db.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), otherOrg.ID)
require.NoError(t, err)
require.Equal(t, otherDefault.ID, unchangedOtherDefault.ID)
})
// Deleting the default must not promote a config whose provider is
// disabled while a usable candidate exists.
t.Run("PromotesUsableConfigOverDisabledProvider", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
// Same provider type as the enabled candidate with an
// alphabetically earlier model, so it sorts first in the
// reselection order.
createProviderDisabledChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"a-provider-disabled-model",
)
enabledConfig := createAdditionalChatModel(
t,
client,
coderdtest.TestChatProviderOpenAICompat,
"z-enabled-model",
)
err := client.DeleteChatModel(ctx, defaultConfig.OrganizationID, defaultConfig.ID)
require.NoError(t, err)
configs, err := client.ChatModels(ctx, defaultConfig.OrganizationID)
require.NoError(t, err)
defaultID := uuid.Nil
for _, config := range configs.Models {
if config.IsDefault {
defaultID = config.ID
}
}
require.Equal(t, enabledConfig.ID, defaultID)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
err := client.DeleteChatModel(ctx, firstUser.OrganizationID, uuid.New())
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("NotFoundWhenTargetRowDisappearsAtDelete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithModelConfigHookStore(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
store.armFailNextDelete(modelConfig.ID)
err := client.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("InvalidModelConfigID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
res, err := client.Request(
ctx,
http.MethodDelete,
fmt.Sprintf("/api/v2/organizations/%s/chats/models/not-a-uuid", firstUser.OrganizationID),
nil,
)
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, `Invalid UUID "not-a-uuid".`, 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.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
modelConfig := createChatModel(t, adminClient)
err := memberClient.DeleteChatModel(ctx, modelConfig.OrganizationID, modelConfig.ID)
// Action-aware item admission conceals a caller without delete access.
sdkErr := requireSDKError(t, err, http.StatusNotFound)
require.Equal(t, "Resource not found or you do not have access to this resource", sdkErr.Message)
})
}
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.Client)
modelConfig := createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "get chat route payload",
},
},
})
require.NoError(t, err)
chatResult, err := client.GetChat(ctx, createdChat.ID)
require.NoError(t, err)
messagesResult, err := client.GetChatMessages(ctx, createdChat.ID, nil)
require.NoError(t, err)
require.Equal(t, createdChat.ID, chatResult.ID)
require.Equal(t, firstUser.UserID, chatResult.OwnerID)
require.Equal(t, modelConfig.ID, chatResult.LastModelConfigID)
require.Equal(t, "get chat route payload", chatResult.Title)
require.NotZero(t, chatResult.CreatedAt)
require.NotZero(t, chatResult.UpdatedAt)
require.NotEmpty(t, messagesResult.Messages)
require.Empty(t, messagesResult.QueuedMessages)
foundUserMessage := false
for _, message := range messagesResult.Messages {
require.Equal(t, createdChat.ID, message.ChatID)
require.NotEqual(t, codersdk.ChatMessageRoleSystem, message.Role)
for _, part := range message.Content {
if message.Role == codersdk.ChatMessageRoleUser &&
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.Client)
_ = createChatModel(t, client)
createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "private chat",
},
},
})
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err = otherClient.GetChat(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
// AIGatewayDisabled regression-tests that getChat is a pure DB read that
// still works when the AI Gateway is disabled and api.chatDaemon is nil.
// It builds the server without starting the test AI bridge daemon
// (unlike every other subtest here) and seeds the chat directly into the
// database, since the create-chat route itself requires the daemon.
t.Run("AIGatewayDisabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
values := coderdtest.DeploymentValues(t)
require.NoError(t, values.AI.BridgeConfig.Enabled.Set("false"))
opts := newChatTestOptions(t, values)
rawClient, _, api := coderdtest.NewWithAPI(t, opts)
client := codersdk.NewExperimentalClient(rawClient)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := dbgen.ChatModelConfig(t, api.Database, database.ChatModelConfig{})
seededChat := dbgen.Chat(t, api.Database, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "ai gateway disabled chat",
})
chatResult, err := client.GetChat(ctx, seededChat.ID)
require.NoError(t, err)
require.Equal(t, seededChat.ID, chatResult.ID)
require.Equal(t, firstUser.UserID, chatResult.OwnerID)
require.Equal(t, modelConfig.ID, chatResult.LastModelConfigID)
require.Equal(t, "ai gateway disabled chat", chatResult.Title)
})
t.Run("FilesHydrated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(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", "hydrated.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Create a chat with a text + file part.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "check file hydration"}, {Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
// GET the chat — files must be hydrated with all metadata fields.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
f := chatResult.Files[0]
require.Equal(t, uploadResp.ID, f.ID)
require.Equal(t, firstUser.UserID, f.OwnerID)
require.NotEqual(t, uuid.Nil, f.OrganizationID)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, "hydrated.png", f.Name)
require.NotZero(t, f.CreatedAt)
})
// ToolCreatedFilesLinked exercises the DB path that chatd uses
// when a tool (e.g. propose_plan) creates a file: InsertChatFile
// then LinkChatFiles. This is a DB-level test because driving
// the full chatd tool-call pipeline requires an LLM mock.
t.Run("ToolCreatedFilesLinked", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
// Create a chat via the API so all metadata is set up.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "tool file test"},
},
})
require.NoError(t, err)
// Mimic what chatd's StoreFile closure does:
// 1. InsertChatFile
// 2. LinkChatFiles
//nolint:gocritic // Using AsChatd to mimic the chatd background worker.
chatdCtx := dbauthz.AsChatd(ctx)
fileRow, err := store.InsertChatFile(chatdCtx, database.InsertChatFileParams{
OwnerID: firstUser.UserID,
OrganizationID: firstUser.OrganizationID,
Name: "plan.md",
Mimetype: "text/markdown",
Data: []byte("# Plan"),
})
require.NoError(t, err)
rejected, err := store.LinkChatFiles(chatdCtx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.DefaultChatMaxAttachmentsPerChat),
FileIds: []uuid.UUID{fileRow.ID},
})
require.NoError(t, err)
require.Equal(t, int32(0), rejected, "0 rejected = all files linked")
// Verify via the API that the file appears in the chat.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
f := chatResult.Files[0]
require.Equal(t, fileRow.ID, f.ID)
require.Equal(t, firstUser.UserID, f.OwnerID)
require.Equal(t, firstUser.OrganizationID, f.OrganizationID)
require.Equal(t, "plan.md", f.Name)
require.Equal(t, "text/markdown", f.MimeType)
})
t.Run("GetChatEmbedsChildren", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent for getChat",
},
},
})
require.NoError(t, err)
// The parent chat is created via the API, so the chat worker moves
// it to running. Archiving is only allowed from a terminal state,
// so wait for it to settle before archiving below.
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child for getChat",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
// Fetching the root chat should embed its children.
result, err := client.GetChat(ctx, parentChat.ID)
require.NoError(t, err)
require.Len(t, result.Children, 1)
require.Equal(t, child.ID, result.Children[0].ID)
require.NotNil(t, result.Children[0].ParentChatID)
require.Equal(t, parentChat.ID, *result.Children[0].ParentChatID)
// Fetching a child chat should not have children.
childResult, err := client.GetChat(ctx, child.ID)
require.NoError(t, err)
require.NotNil(t, childResult.Children)
require.Empty(t, childResult.Children)
// An archived root should still embed its cascaded
// archived children (guards against the filter getting
// hardcoded to false).
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
archivedResult, err := client.GetChat(ctx, parentChat.ID)
require.NoError(t, err)
require.True(t, archivedResult.Archived, "root should be archived")
require.Len(t, archivedResult.Children, 1, "archived root should embed its archived child")
require.Equal(t, child.ID, archivedResult.Children[0].ID)
require.True(t, archivedResult.Children[0].Archived, "embedded child should be archived")
})
}
func TestGetChatUserPrompts(t *testing.T) {
t.Parallel()
insertUserMessage := func(
t *testing.T,
ctx context.Context,
db database.Store,
chatID uuid.UUID,
modelConfigID uuid.UUID,
userID uuid.UUID,
parts []codersdk.ChatMessagePart,
visibility database.ChatMessageVisibility,
deleted bool,
) database.ChatMessage {
t.Helper()
content, err := chatprompt.MarshalParts(parts)
require.NoError(t, err)
msgs, err := db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: chatID,
CreatedBy: []uuid.UUID{userID},
ModelConfigID: []uuid.UUID{modelConfigID},
Role: []database.ChatMessageRole{database.ChatMessageRoleUser},
ContentVersion: []int16{chatprompt.CurrentContentVersion},
Content: []string{string(content.RawMessage)},
Visibility: []database.ChatMessageVisibility{visibility},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
require.Len(t, msgs, 1)
if deleted {
require.NoError(t, db.SoftDeleteChatMessageByID(dbauthz.AsSystemRestricted(ctx), msgs[0].ID))
}
return database.ChatMessage(msgs[0])
}
t.Run("NewestFirstFiltering", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts route test",
})
require.NoError(t, err)
// Older user prompt with multiple text parts that need
// concatenation in original order.
want1 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "first "},
{Type: codersdk.ChatMessagePartTypeText, Text: "prompt"},
},
database.ChatMessageVisibilityBoth, false,
)
// User prompt with a non-text part interleaved; only text
// parts should appear in the response, joined verbatim.
want2 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "hello "},
{Type: codersdk.ChatMessagePartTypeFile, MediaType: "text/plain", Data: []byte("x")},
{Type: codersdk.ChatMessagePartTypeText, Text: "world"},
},
database.ChatMessageVisibilityBoth, false,
)
// Whitespace-only prompt; must be filtered out by the
// HAVING clause so cycling never lands on a blank entry.
insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: " \n\t "},
},
database.ChatMessageVisibilityBoth, false,
)
// Assistant-role message with otherwise-valid content;
// the SQL filter cm.role = 'user' must exclude it from
// the response.
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"},
})
require.NoError(t, err)
_, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: chat.ID,
CreatedBy: []uuid.UUID{user.UserID},
ModelConfigID: []uuid.UUID{modelConfig.ID},
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
ContentVersion: []int16{chatprompt.CurrentContentVersion},
Content: []string{string(assistantContent.RawMessage)},
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
// Legacy V0 user message stored as a scalar JSON string
// (predates migration 000434). The jsonb_typeof guard in
// GetChatUserPromptsByChatID must silently exclude this row;
// without the guard, jsonb_array_elements would raise
// "cannot extract elements from a scalar" and the request
// would 500.
_, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: chat.ID,
CreatedBy: []uuid.UUID{user.UserID},
ModelConfigID: []uuid.UUID{modelConfig.ID},
Role: []database.ChatMessageRole{database.ChatMessageRoleUser},
ContentVersion: []int16{0},
Content: []string{`"plain text from V0"`},
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
// Soft-deleted prompt; must not appear.
insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "deleted prompt"},
},
database.ChatMessageVisibilityBoth, true,
)
// Model-only visibility prompt; must not appear (composer
// only shows what the user actually typed).
insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "model only"},
},
database.ChatMessageVisibilityModel, false,
)
// Newest user-visible prompt; should come first in the
// response.
want3 := insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "newest prompt"},
},
database.ChatMessageVisibilityUser, false,
)
resp, err := client.GetChatPrompts(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Prompts, 3, "expected exactly the three user-visible non-blank prompts")
require.Equal(t, want3.ID, resp.Prompts[0].ID)
require.Equal(t, "newest prompt", resp.Prompts[0].Text)
require.Equal(t, want2.ID, resp.Prompts[1].ID)
require.Equal(t, "hello world", resp.Prompts[1].Text)
require.Equal(t, want1.ID, resp.Prompts[2].ID)
require.Equal(t, "first prompt", resp.Prompts[2].Text)
})
t.Run("LimitClampsResults", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts limit test",
})
require.NoError(t, err)
for i := range 5 {
insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, user.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: fmt.Sprintf("prompt %d", i)},
},
database.ChatMessageVisibilityBoth, false,
)
}
resp, err := client.GetChatPrompts(ctx, chat.ID, &codersdk.ChatPromptsOptions{Limit: 2})
require.NoError(t, err)
require.Len(t, resp.Prompts, 2)
require.Equal(t, "prompt 4", resp.Prompts[0].Text)
require.Equal(t, "prompt 3", resp.Prompts[1].Text)
})
t.Run("InvalidLimitRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts invalid limit test",
})
require.NoError(t, err)
_, err = client.GetChatPrompts(ctx, chat.ID, &codersdk.ChatPromptsOptions{Limit: 5000})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("NotFoundForOtherUsers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: firstUser.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts cross-owner test",
})
require.NoError(t, err)
insertUserMessage(t, ctx, db, chat.ID, modelConfig.ID, firstUser.UserID,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "private prompt"},
},
database.ChatMessageVisibilityBoth, false,
)
memberClient, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberExp := codersdk.NewExperimentalClient(memberClient)
_, err = memberExp.GetChatPrompts(ctx, chat.ID, nil)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("EmptyResultIsJSONArray", func(t *testing.T) {
t.Parallel()
// Boundary: a chat with no user-visible prompts must
// serialize to {"prompts":[]}, not {"prompts":null}, so
// the composer's cycle code can branch on len() without
// guarding against nil. We exercise both branches: a chat
// with zero messages, and a chat that has only an
// assistant message (the SQL filter excludes it).
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
emptyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts empty chat test",
})
require.NoError(t, err)
resp, err := client.GetChatPrompts(ctx, emptyChat.ID, nil)
require.NoError(t, err)
require.NotNil(t, resp.Prompts, "prompts must be [] not nil")
require.Empty(t, resp.Prompts)
assistantOnlyChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "prompts assistant-only chat test",
})
require.NoError(t, err)
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeText, Text: "assistant reply"},
})
require.NoError(t, err)
_, err = db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: assistantOnlyChat.ID,
CreatedBy: []uuid.UUID{user.UserID},
ModelConfigID: []uuid.UUID{modelConfig.ID},
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
ContentVersion: []int16{chatprompt.CurrentContentVersion},
Content: []string{string(assistantContent.RawMessage)},
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
resp, err = client.GetChatPrompts(ctx, assistantOnlyChat.ID, nil)
require.NoError(t, err)
require.NotNil(t, resp.Prompts, "prompts must be [] not nil")
require.Empty(t, resp.Prompts)
})
}
func TestGetChatAttachments(t *testing.T) {
t.Parallel()
insertMessage := func(
t *testing.T,
ctx context.Context,
db database.Store,
chat database.Chat,
role database.ChatMessageRole,
parts []codersdk.ChatMessagePart,
visibility database.ChatMessageVisibility,
deleted bool,
) database.ChatMessage {
t.Helper()
content, err := chatprompt.MarshalParts(parts)
require.NoError(t, err)
msgs, err := db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: chat.ID,
CreatedBy: []uuid.UUID{chat.OwnerID},
ModelConfigID: []uuid.UUID{chat.LastModelConfigID},
Role: []database.ChatMessageRole{role},
ContentVersion: []int16{chatprompt.CurrentContentVersion},
Content: []string{string(content.RawMessage)},
Visibility: []database.ChatMessageVisibility{visibility},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
require.Len(t, msgs, 1)
if deleted {
require.NoError(t, db.SoftDeleteChatMessageByID(dbauthz.AsSystemRestricted(ctx), msgs[0].ID))
}
return database.ChatMessage(msgs[0])
}
insertChat := func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse, modelConfigID uuid.UUID) database.Chat {
t.Helper()
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: user.OrganizationID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.UserID,
LastModelConfigID: modelConfigID,
Title: "attachments route test",
})
require.NoError(t, err)
return chat
}
t.Run("NewestFirstFiltering", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := insertChat(t, ctx, db, user, modelConfig.ID)
fileA := codersdk.ChatMessageFile(uuid.New(), "image/png", "a.png")
fileB := codersdk.ChatMessageFile(uuid.New(), "text/plain", "b.txt")
fileC := codersdk.ChatMessageFile(uuid.New(), "text/plain", "c.txt")
fileD := codersdk.ChatMessageFile(uuid.New(), "image/png", "d.png")
text := codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "hello"}
userUpload := insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{text, fileA},
database.ChatMessageVisibilityBoth, false)
agentFiles := insertMessage(t, ctx, db, chat, database.ChatMessageRoleAssistant,
[]codersdk.ChatMessagePart{fileB, text, fileC},
database.ChatMessageVisibilityBoth, false)
// Each of these hides its file part from the user, so none may
// appear in the response.
insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{codersdk.ChatMessageFile(uuid.New(), "text/plain", "deleted.txt")},
database.ChatMessageVisibilityBoth, true)
insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{codersdk.ChatMessageFile(uuid.New(), "text/plain", "model.txt")},
database.ChatMessageVisibilityModel, false)
insertMessage(t, ctx, db, chat, database.ChatMessageRoleTool,
[]codersdk.ChatMessagePart{codersdk.ChatMessageFile(uuid.New(), "image/png", "tool.png")},
database.ChatMessageVisibilityBoth, false)
insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{text},
database.ChatMessageVisibilityBoth, false)
// The hook-context part is stripped from client responses, so the
// file part's index must shift to match the messages endpoint.
hookPrompt := insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeHookContext, Text: "model only"},
fileD,
},
database.ChatMessageVisibilityUser, false)
// An inline part keeps its bytes because it has no stored file.
inlinePaste := insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeFile, MediaType: "text/plain", Data: []byte("pasted")},
},
database.ChatMessageVisibilityBoth, false)
resp, err := client.GetChatAttachments(ctx, chat.ID, nil)
require.NoError(t, err)
type attachment struct {
MessageID int64
PartIndex int
Role codersdk.ChatAttachmentRole
FileID uuid.UUID
}
got := make([]attachment, 0, len(resp.Attachments))
for _, a := range resp.Attachments {
got = append(got, attachment{a.MessageID, a.PartIndex, a.Role, a.File.FileID.UUID})
}
require.Equal(t, []attachment{
{inlinePaste.ID, 0, codersdk.ChatAttachmentRoleUser, uuid.Nil},
{hookPrompt.ID, 0, codersdk.ChatAttachmentRoleUser, fileD.FileID.UUID},
{agentFiles.ID, 2, codersdk.ChatAttachmentRoleAssistant, fileC.FileID.UUID},
{agentFiles.ID, 0, codersdk.ChatAttachmentRoleAssistant, fileB.FileID.UUID},
{userUpload.ID, 1, codersdk.ChatAttachmentRoleUser, fileA.FileID.UUID},
}, got)
messages, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
contentByID := make(map[int64][]codersdk.ChatMessagePart, len(messages.Messages))
for _, m := range messages.Messages {
contentByID[m.ID] = m.Content
}
for _, a := range resp.Attachments {
content := contentByID[a.MessageID]
require.Less(t, a.PartIndex, len(content))
part := content[a.PartIndex]
require.Equal(t, codersdk.ChatMessagePartTypeFile, part.Type)
require.Equal(t, codersdk.ChatAttachmentFile{
Type: codersdk.ChatAttachmentFileTypeFile,
MediaType: part.MediaType,
Name: part.Name,
Data: part.Data,
FileID: part.FileID,
}, a.File, "attachment must carry the messages endpoint part's file fields")
}
before, err := client.GetChatAttachments(ctx, chat.ID, &codersdk.ChatAttachmentsOptions{BeforeID: agentFiles.ID})
require.NoError(t, err)
require.Len(t, before.Attachments, 1)
require.Equal(t, userUpload.ID, before.Attachments[0].MessageID)
none, err := client.GetChatAttachments(ctx, chat.ID, &codersdk.ChatAttachmentsOptions{BeforeID: userUpload.ID})
require.NoError(t, err)
require.NotNil(t, none.Attachments, "attachments must be [] not nil")
require.Empty(t, none.Attachments)
})
t.Run("InlineFileWireFormat", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := insertChat(t, ctx, db, user, modelConfig.ID)
stored := codersdk.ChatMessageFile(uuid.New(), "text/plain", "stored.txt")
insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{
stored,
{Type: codersdk.ChatMessagePartTypeFile, MediaType: "text/plain", Data: []byte("pasted")},
},
database.ChatMessageVisibilityBoth, false)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/chats/%s/attachments", chat.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var body struct {
Attachments []struct {
PartIndex int `json:"part_index"`
File map[string]json.RawMessage `json:"file"`
} `json:"attachments"`
}
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
files := make(map[int]map[string]json.RawMessage, len(body.Attachments))
for _, a := range body.Attachments {
files[a.PartIndex] = a.File
}
require.Len(t, files, 2)
require.JSONEq(t, fmt.Sprintf("%q", stored.FileID.UUID), string(files[0]["file_id"]))
require.NotContains(t, files[1], "file_id", "an inline file must omit file_id, not send null")
var data string
require.NoError(t, json.Unmarshal(files[1]["data"], &data), "data must be a base64 string")
require.Equal(t, base64.StdEncoding.EncodeToString([]byte("pasted")), data)
})
t.Run("InvalidBeforeIDRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := insertChat(t, ctx, db, user, modelConfig.ID)
res, err := client.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/chats/%s/attachments?before_id=-1", chat.ID), nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("NotFoundForOtherUsers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := insertChat(t, ctx, db, firstUser, modelConfig.ID)
insertMessage(t, ctx, db, chat, database.ChatMessageRoleUser,
[]codersdk.ChatMessagePart{codersdk.ChatMessageFile(uuid.New(), "text/plain", "private.txt")},
database.ChatMessageVisibilityBoth, false)
memberClient, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberExp := codersdk.NewExperimentalClient(memberClient)
_, err := memberExp.GetChatAttachments(ctx, chat.ID, nil)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func TestPatchChat(t *testing.T) {
t.Parallel()
createChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, orgID uuid.UUID, text string) codersdk.Chat {
t.Helper()
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: orgID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: text,
},
},
})
require.NoError(t, err)
return chat
}
getChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID) codersdk.Chat {
t.Helper()
chat, err := client.GetChat(ctx, chatID)
require.NoError(t, err)
return chat
}
createStoredChat := func(
ctx context.Context,
t *testing.T,
db database.Store,
ownerID uuid.UUID,
orgID uuid.UUID,
modelConfigID uuid.UUID,
title string,
) codersdk.Chat {
t.Helper()
dbChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: orgID,
OwnerID: ownerID,
LastModelConfigID: modelConfigID,
Title: title,
})
return db2sdk.Chat(dbChat, nil, nil)
}
t.Run("PlanMode", func(t *testing.T) {
t.Parallel()
t.Run("SetToPlan", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "set plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, codersdk.ChatPlanModePlan, updated.PlanMode)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("Clear", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "clear plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
})
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanMode("")),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Empty(t, updated.PlanMode)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("RejectsInvalidValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanMode("invalid")),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid plan_mode value.", sdkErr.Message)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
})
t.Run("WorkspaceBinding", func(t *testing.T) {
t.Parallel()
t.Run("BindExistingExternalWorkspace", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).Seed(database.WorkspaceBuild{
HasExternalAgent: sql.NullBool{Bool: true, Valid: true},
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"bind workspace",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.NotNil(t, updated.WorkspaceID)
require.Equal(t, workspaceBuild.Workspace.ID, *updated.WorkspaceID)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("WorkspaceNotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"missing workspace",
)
workspaceID := uuid.New()
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
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)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("RejectsCrossOrgWorkspaceBinding", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
secondOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: secondOrg.ID,
UserID: firstUser.UserID,
})
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: secondOrg.ID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"cross org workspace binding",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Workspace does not belong to this chat's organization.", sdkErr.Message)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("ClearWorkspaceBinding", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"clear workspace binding",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
require.NoError(t, err)
workspaceID := uuid.Nil
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceID,
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Nil(t, updated.WorkspaceID)
require.Nil(t, updated.BuildID)
require.Nil(t, updated.AgentID)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
})
t.Run("NonOwnerForbidden", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
_, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
}).WithAgent().Do()
// The deployment owner can SSH into its own workspace, so only
// the ownership check can stop this rebind.
ownerWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat",
WorkspaceID: uuid.NullUUID{UUID: memberWorkspace.Workspace.ID, Valid: true},
})
for _, workspaceID := range []uuid.UUID{ownerWorkspace.Workspace.ID, uuid.Nil} {
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("renamed by admin"),
WorkspaceID: &workspaceID,
})
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "Only the chat owner can change its workspace.", sdkErr.Message)
persisted := getChat(ctx, t, client, chat.ID)
require.Equal(t, "member chat", persisted.Title)
require.NotNil(t, persisted.WorkspaceID)
require.Equal(t, memberWorkspace.Workspace.ID, *persisted.WorkspaceID)
}
})
})
t.Run("Title", func(t *testing.T) {
t.Parallel()
t.Run("Rename", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "original title")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("renamed title"),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "renamed title", updated.Title)
})
t.Run("TrimsWhitespace", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "before trim")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(" padded title "),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "padded title", updated.Title)
})
t.Run("RejectsEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "keep original")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(" "),
})
requireSDKError(t, err, http.StatusBadRequest)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, chat.Title, updated.Title)
})
t.Run("RejectedRequestLeavesTitleUnchanged", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
chat := createStoredChat(ctx, t, db, firstUser.UserID, firstUser.OrganizationID, modelConfig.ID, "stored title")
require.Equal(t, codersdk.ChatTitleSourceFallback, chat.TitleSource)
invalid := []struct {
field string
req codersdk.UpdateChatRequest
}{
{"labels", codersdk.UpdateChatRequest{Labels: &map[string]string{"": "bad"}}},
{"archived", codersdk.UpdateChatRequest{Archived: new(false)}},
{"pin_order", codersdk.UpdateChatRequest{PinOrder: new(int32(-1))}},
{"workspace_id", codersdk.UpdateChatRequest{WorkspaceID: new(uuid.New())}},
{"plan_mode", codersdk.UpdateChatRequest{PlanMode: new(codersdk.ChatPlanMode("invalid"))}},
}
for _, tc := range invalid {
for _, title := range []string{chat.Title, "new title"} {
req := tc.req
req.Title = &title
err := client.UpdateChat(ctx, chat.ID, req)
requireSDKError(t, err, http.StatusBadRequest)
stored := getChat(ctx, t, client, chat.ID)
require.Equal(t, chat.Title, stored.Title, "invalid %s, title %q", tc.field, title)
require.Equal(t, chat.TitleSource, stored.TitleSource, "invalid %s, title %q", tc.field, title)
require.True(t, chat.TitleUpdatedAt.Equal(stored.TitleUpdatedAt), "invalid %s, title %q", tc.field, title)
}
}
})
t.Run("LengthBoundaries", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
title string
expectOK bool
storedAs string
}{
{
name: "ExactlyMaxASCII",
title: strings.Repeat("a", 200),
expectOK: true,
storedAs: strings.Repeat("a", 200),
},
{
name: "OneOverMaxASCII",
title: strings.Repeat("a", 201),
expectOK: false,
},
{
name: "ExactlyMaxMultiByte",
title: strings.Repeat("é", 200),
expectOK: true,
storedAs: strings.Repeat("é", 200),
},
{
name: "OneOverMaxMultiByte",
title: strings.Repeat("é", 201),
expectOK: false,
},
{
name: "TrimsDownToMax",
title: " " + strings.Repeat("a", 200) + " ",
expectOK: true,
storedAs: strings.Repeat("a", 200),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "boundary baseline")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref(tc.title),
})
updated := getChat(ctx, t, client, chat.ID)
if tc.expectOK {
require.NoError(t, err)
require.Equal(t, tc.storedAs, updated.Title)
} else {
requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, chat.Title, updated.Title)
}
})
}
})
t.Run("RecordsUserSourceWithoutChangingUpdatedAt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
clientRaw, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
Database: db,
Pubsub: ps,
})
aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
client := codersdk.NewExperimentalClient(clientRaw)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "steady title")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
past := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
_, err := sqlDB.ExecContext(ctx,
"UPDATE chats SET title = $1, title_source = 'fallback', updated_at = $2 WHERE id = $3",
"steady title", past, chat.ID,
)
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: new("steady title"),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, "steady title", updated.Title)
require.Equal(t, codersdk.ChatTitleSourceUser, updated.TitleSource)
require.WithinDuration(t, past, updated.UpdatedAt, time.Second)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: new("renamed in place"),
})
require.NoError(t, err)
renamed := getChat(ctx, t, client, chat.ID)
require.Equal(t, "renamed in place", renamed.Title)
require.Equal(t, codersdk.ChatTitleSourceUser, renamed.TitleSource)
require.True(t, renamed.TitleUpdatedAt.After(updated.TitleUpdatedAt))
require.WithinDuration(t, past, renamed.UpdatedAt, time.Second)
})
t.Run("PublishesWatchEvent", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "announce me")
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
conn, err := client.Dial(ctx, "/api/v2/chats/watch", nil)
require.NoError(t, err)
defer conn.Close(websocket.StatusNormalClosure, "done")
go func() {
_ = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("announced name"),
})
}()
var received codersdk.ChatWatchEvent
for {
if err := wsjson.Read(ctx, conn, &received); err != nil {
break
}
if received.Kind == codersdk.ChatWatchEventKindTitleChange &&
received.Chat.ID == chat.ID {
require.Equal(t, "announced name", received.Chat.Title)
return
}
}
t.Fatalf("did not observe title_change event for chat %s", chat.ID)
})
})
}
func TestArchiveChat(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, api := newChatClientWithAPI(t, func(o *coderdtest.Options) {
o.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chatToArchive, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "archive me",
},
},
})
require.NoError(t, err)
chatToKeep, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "keep me",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, chatToArchive.ID)
coderdtest.WaitForChatSettled(ctx, t, api, chatToKeep.ID)
chatsBeforeArchive, err := client.ListChats(ctx, nil)
require.NoError(t, err)
require.Len(t, chatsBeforeArchive, 2)
err = client.UpdateChat(ctx, chatToArchive.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
// Default (no filter) returns only non-archived chats.
allChats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
require.Len(t, allChats, 1)
require.Equal(t, chatToKeep.ID, allChats[0].ID)
// archived:false returns only non-archived chats.
activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived: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{
Query: "archived:true",
})
require.NoError(t, err)
require.Len(t, archivedChats, 1)
require.Equal(t, chatToArchive.ID, archivedChats[0].ID)
require.True(t, archivedChats[0].Archived)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chatToArchive.ID,
ResourceTarget: chatToArchive.ID.String()[:8],
UserID: firstUser.UserID,
}))
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
err := client.UpdateChat(ctx, uuid.New(), codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("ArchivesChildren", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent chat",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
// Insert child chats directly via the database.
child1 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
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},
})
child2 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
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},
})
// Archive the parent via the API.
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
// archived:false should exclude the entire archived family.
activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived: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")
// archived:true should return the parent with both
// cascaded children embedded.
archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived:true",
})
require.NoError(t, err)
var foundParent *codersdk.Chat
for _, chat := range archivedChats {
if chat.ID == parentChat.ID {
foundParent = &chat
break
}
}
require.NotNil(t, foundParent, "parent should appear in archived list")
require.True(t, foundParent.Archived, "parent should be archived")
require.Len(t, foundParent.Children, 2, "both archived children should be embedded under the archived parent")
childIDs := map[uuid.UUID]bool{}
for _, child := range foundParent.Children {
require.True(t, child.Archived, "embedded child should be archived")
childIDs[child.ID] = true
}
require.True(t, childIDs[child1.ID], "child1 should be embedded under archived parent")
require.True(t, childIDs[child2.ID], "child2 should be embedded under archived parent")
})
t.Run("AllowsChildChatArchiveIndividually", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
// Insert a child chat directly via the database.
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
// Archive state changes must target the root chat and cascade.
// Child archive attempts are rejected to preserve the family invariant.
err = client.UpdateChat(ctx, child.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
requireSDKError(t, err, http.StatusBadRequest)
dbChild, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child.ID)
require.NoError(t, err)
require.False(t, dbChild.Archived, "child should remain active")
dbParent, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), parentChat.ID)
require.NoError(t, err)
require.False(t, dbParent.Archived, "parent should stay active")
})
}
func TestUnarchiveChat(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "archive then unarchive me",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
// Archive the chat first.
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
// Verify it's archived.
archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived:true",
})
require.NoError(t, err)
require.Len(t, archivedChats, 1)
require.True(t, archivedChats[0].Archived)
// Unarchive the chat.
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
require.NoError(t, err)
// Verify it's no longer archived.
activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived: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{
Query: "archived:true",
})
require.NoError(t, err)
require.Empty(t, archivedChats)
})
t.Run("UnarchivesChildren", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent chat",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
child1 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
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},
})
child2 := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
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},
})
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
require.NoError(t, err)
activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived:false",
})
require.NoError(t, err)
// Children no longer appear as top-level entries.
// They are embedded inside the parent's Children field.
var foundParent *codersdk.Chat
for _, chat := range activeChats {
require.NotEqual(t, child1.ID, chat.ID, "child1 should not appear at top level")
require.NotEqual(t, child2.ID, chat.ID, "child2 should not appear at top level")
if chat.ID == parentChat.ID {
foundParent = &chat
}
}
require.NotNil(t, foundParent, "parent should be listed as active")
require.False(t, foundParent.Archived)
// Verify children are embedded and unarchived.
require.Len(t, foundParent.Children, 2)
childIDs := map[uuid.UUID]bool{}
for _, child := range foundParent.Children {
require.False(t, child.Archived)
childIDs[child.ID] = true
}
require.True(t, childIDs[child1.ID], "child1 should be embedded")
require.True(t, childIDs[child2.ID], "child2 should be embedded")
archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: "archived:true",
})
require.NoError(t, err)
for _, chat := range archivedChats {
require.NotEqual(t, parentChat.ID, chat.ID, "parent should not remain archived")
}
dbParent, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), parentChat.ID)
require.NoError(t, err)
require.False(t, dbParent.Archived, "parent should be unarchived")
dbChild1, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child1.ID)
require.NoError(t, err)
require.False(t, dbChild1.Archived, "child1 should be unarchived")
dbChild2, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child2.ID)
require.NoError(t, err)
require.False(t, dbChild2.Archived, "child2 should be unarchived")
})
t.Run("NotArchived", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "not archived",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
// Trying to unarchive a non-archived chat should fail.
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
requireSDKError(t, err, http.StatusBadRequest)
})
t.Run("RejectsChildChatWhenParentArchived", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
// Create a parent chat via the API.
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
// Insert a child directly via the database, then archive the
// parent so the whole family is archived (cascade).
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
err = client.UpdateChat(ctx, parentChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
// Unarchiving the child while the parent stays archived
// must be rejected. Otherwise the child becomes a ghost
// (active list excludes the parent, archived list's child
// query filters archived=true so the now-unarchived child
// is also excluded).
err = client.UpdateChat(ctx, child.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
requireSDKError(t, err, http.StatusBadRequest)
dbChild, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child.ID)
require.NoError(t, err)
require.True(t, dbChild.Archived, "child should still be archived")
})
t.Run("AllowsChildChatWhenParentNotArchived", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db, api := newChatClientWithAPIAndDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
parentChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "parent",
},
},
})
require.NoError(t, err)
coderdtest.WaitForChatSettled(ctx, t, api, parentChat.ID)
// Simulate legacy lone-archived child (from before the
// child-archive gate existed) by inserting it directly
// with archived=true while the parent is not archived.
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "legacy child",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
_, err = db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), child.ID)
require.NoError(t, err)
// Archive state changes must target the root chat, even when
// the child is a legacy lone-archived row.
err = client.UpdateChat(ctx, child.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
requireSDKError(t, err, http.StatusBadRequest)
dbChild, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), child.ID)
require.NoError(t, err)
require.True(t, dbChild.Archived, "child should remain archived")
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
err := client.UpdateChat(ctx, uuid.New(), codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
requireSDKError(t, err, http.StatusNotFound)
})
}
func TestChatPinOrder(t *testing.T) {
t.Parallel()
createChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, orgID uuid.UUID, title string) codersdk.Chat {
t.Helper()
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: orgID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: title,
},
},
})
require.NoError(t, err)
return chat
}
getChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID) codersdk.Chat {
t.Helper()
chat, err := client.GetChat(ctx, chatID)
require.NoError(t, err)
return chat
}
t.Run("PinReorderAndUnpin", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
first := createChat(ctx, t, client, firstUser.OrganizationID, "first pinned chat")
second := createChat(ctx, t, client, firstUser.OrganizationID, "second pinned chat")
third := createChat(ctx, t, client, firstUser.OrganizationID, "third pinned chat")
err := client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
err = client.UpdateChat(ctx, second.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
err = client.UpdateChat(ctx, third.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
first = getChat(ctx, t, client, first.ID)
second = getChat(ctx, t, client, second.ID)
third = getChat(ctx, t, client, third.ID)
require.EqualValues(t, 1, first.PinOrder)
require.EqualValues(t, 2, second.PinOrder)
require.EqualValues(t, 3, third.PinOrder)
err = client.UpdateChat(ctx, third.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
first = getChat(ctx, t, client, first.ID)
second = getChat(ctx, t, client, second.ID)
third = getChat(ctx, t, client, third.ID)
require.EqualValues(t, 2, first.PinOrder)
require.EqualValues(t, 3, second.PinOrder)
require.EqualValues(t, 1, third.PinOrder)
err = client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(0))})
require.NoError(t, err)
first = getChat(ctx, t, client, first.ID)
second = getChat(ctx, t, client, second.ID)
third = getChat(ctx, t, client, third.ID)
require.Zero(t, first.PinOrder)
require.EqualValues(t, 2, second.PinOrder)
require.EqualValues(t, 1, third.PinOrder)
})
t.Run("ArchiveClearsPinOrder", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, api := newChatClientWithAPI(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
first := createChat(ctx, t, client, firstUser.OrganizationID, "pinned then archived")
second := createChat(ctx, t, client, firstUser.OrganizationID, "stays pinned")
coderdtest.WaitForChatSettled(ctx, t, api, first.ID)
coderdtest.WaitForChatSettled(ctx, t, api, second.ID)
// Pin both.
err := client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
err = client.UpdateChat(ctx, second.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
require.NoError(t, err)
// Archive the first — pin_order should be cleared.
err = client.UpdateChat(ctx, first.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
require.NoError(t, err)
first = getChat(ctx, t, client, first.ID)
second = getChat(ctx, t, client, second.ID)
require.Zero(t, first.PinOrder, "archived chat should have pin_order 0")
require.True(t, first.Archived)
// The remaining pin keeps its original position. The next
// pin/unpin/reorder operation compacts via ROW_NUMBER().
require.EqualValues(t, 2, second.PinOrder, "remaining pin keeps original position")
})
t.Run("RejectsNegative", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "negative pin order")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(-1))})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Pin order must be non-negative.", sdkErr.Message)
chat = getChat(ctx, t, client, chat.ID)
require.Zero(t, chat.PinOrder)
})
t.Run("RejectsChildChat", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModel(t, client)
parentChat := createChat(ctx, t, client, firstUser.OrganizationID, "parent chat")
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child chat",
Status: database.ChatStatusWaiting,
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
err := client.UpdateChat(ctx, child.ID, codersdk.UpdateChatRequest{PinOrder: ptr.Ref(int32(1))})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Cannot pin a child chat.", sdkErr.Message)
result := getChat(ctx, t, client, child.ID)
require.Zero(t, result.PinOrder)
})
}
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)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
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 {
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID, nil)
if getErr != nil {
return false
}
for _, queued := range messagesResult.QueuedMessages {
if queued.ID == created.QueuedMessage.ID &&
queued.ChatID == chat.ID &&
hasTextPart(queued.Content, messageText) {
return true
}
}
for _, message := range messagesResult.Messages {
if message.Role == codersdk.ChatMessageRoleUser && 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, codersdk.ChatMessageRoleUser, created.Message.Role)
require.NotZero(t, created.Message.ID)
require.True(t, hasTextPart(created.Message.Content, messageText))
require.Eventually(t, func() bool {
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID, nil)
if getErr != nil {
return false
}
for _, message := range messagesResult.Messages {
if message.ID == created.Message.ID &&
message.Role == codersdk.ChatMessageRoleUser &&
hasTextPart(message.Content, messageText) {
return true
}
}
return false
}, testutil.WaitLong, testutil.IntervalFast)
}
})
t.Run("ProviderDisabledModelConfigRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial message before disabled provider switch",
}},
})
require.NoError(t, err)
providerDisabledConfig := createProviderDisabledChatModel(
t,
client,
"openai",
"gpt-4o-send-provider-disabled-"+uuid.NewString(),
)
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "switch to a provider-disabled model",
}},
ModelConfigID: ptr.Ref(providerDisabledConfig.ID),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message)
})
t.Run("CrossOrgModelConfigRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial message before cross-org switch",
}},
})
require.NoError(t, err)
otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
otherConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true},
Model: "cross-org-send-" + uuid.NewString(),
Enabled: true,
OrganizationID: otherOrg.ID,
})
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "reject another organization's model",
}},
ModelConfigID: ptr.Ref(otherConfig.ID),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message)
})
t.Run("HistoricalForeignModelFallsBackToLocalDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
foreignConfig := createChatModel(t, client)
provider := createAIProviderForTest(t, client, "openai-compat", "test-api-key")
chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
localConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
Model: "local-send-" + uuid.NewString(),
Enabled: true,
IsDefault: true,
OrganizationID: chatOrg.ID,
})
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: chatOrg.ID,
OwnerID: member.ID,
LastModelConfigID: foreignConfig.ID,
Title: "historical foreign model",
})
resp, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "use the local fallback",
}},
})
require.NoError(t, err)
require.NotNil(t, resp.Message)
require.NotNil(t, resp.Message.ModelConfigID)
require.Equal(t, localConfig.ID, *resp.Message.ModelConfigID)
require.Equal(t, firstUser.OrganizationID, foreignConfig.OrganizationID)
})
t.Run("HistoricalForeignModelWithoutLocalDefaultReturnsGuidance", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
foreignConfig := createChatModel(t, client)
chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false})
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: chatOrg.ID,
OwnerID: member.ID,
LastModelConfigID: foreignConfig.ID,
Title: "historical foreign model without fallback",
})
_, err := memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "require a local fallback",
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("ProviderDisabledDefaultFallbackRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
defaultConfig := createChatModel(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial message before provider disable",
}},
})
require.NoError(t, err)
_, err = client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
// Without an explicit model the fallback walks last model ->
// default, both under the now-disabled provider.
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "message after provider disable",
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "No chat model is available in this organization.", sdkErr.Message)
require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail)
})
t.Run("MemberCanCreateChatMessage", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t,