diff --git a/coderd/aibridgedtest/aibridgedtest.go b/coderd/aibridgedtest/aibridgedtest.go index 7c577c982e..97ccbbb184 100644 --- a/coderd/aibridgedtest/aibridgedtest.go +++ b/coderd/aibridgedtest/aibridgedtest.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/database/pubsub" ) // StartTestAIBridgeDaemon wires an in-process aibridged daemon onto the @@ -37,6 +38,21 @@ func StartTestAIBridgeDaemon( metrics *aibridged.Metrics, ) { t.Helper() + StartTestAIBridgeDaemonWithPubsub(ctx, t, api, metrics, api.Pubsub) +} + +// StartTestAIBridgeDaemonWithPubsub is StartTestAIBridgeDaemon with an +// explicit pubsub for the provider-reload subscription. A pubsub that is +// disconnected from api.Pubsub cuts the daemon off from provider change +// events, leaving the initial synchronous load as its only route source. +func StartTestAIBridgeDaemonWithPubsub( + ctx context.Context, + t testing.TB, + api *coderd.API, + metrics *aibridged.Metrics, + ps pubsub.Pubsub, +) { + t.Helper() logger := api.Logger.Named("aibridged").Leveled(slog.LevelDebug) cfg := api.DeploymentValues.AI.BridgeConfig @@ -63,7 +79,7 @@ func StartTestAIBridgeDaemon( // The reloader fetches providers from coderd over srv's DRPC client; the // subscription drives an initial load and refreshes on change events. reloader := cli.NewPoolRPCReloader(pool, srv.ClientContext, cfg, logger.Named("reloader"), nil, metrics) - unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber")) + unsubscribe, err := aibridged.SubscribeProviderReload(ctx, ps, reloader, logger.Named("subscriber")) if err != nil { t.Fatalf("subscribe provider reload: %v", err) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 7076396116..e62da540fa 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -40,6 +40,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -141,6 +142,14 @@ func newChatClientWithAPIAndDatabase(t testing.TB, overrides ...func(*coderdtest 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, @@ -9540,9 +9549,10 @@ func TestRegenerateChatTitle(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createTitleGenerationModelConfig(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -9560,13 +9570,45 @@ func TestRegenerateChatTitle(t *testing.T) { require.Equal(t, "Test Chat", updated.Title) }) + t.Run("NoPubsubDelivery", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db, api := newChatClientWithoutAIBridge(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createTitleGenerationModelConfig(t, client) + + // Wire the daemon's reload subscription to a pubsub coderd never + // publishes to: gateway routes can then only come from the + // synchronous initial load. This guards the invariant the + // create-config-before-daemon pattern above relies on; if the + // initial load is removed or made asynchronous, this fails + // deterministically instead of reintroducing the startup race. + isolated := dbpubsub.NewInMemory() + t.Cleanup(func() { _ = isolated.Close() }) + aibridgedtest.StartTestAIBridgeDaemonWithPubsub(t.Context(), t, api, nil, isolated) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "New Chat", + }) + seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) + + updated, err := client.RegenerateChatTitle(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, "Test Chat", updated.Title) + }) + t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createTitleGenerationModelConfig(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -9614,9 +9656,10 @@ func TestRegenerateChatTitle(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithAPIAndDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfigWithTitleFailure(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, @@ -9726,9 +9769,10 @@ func TestProposeChatTitle(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createTitleGenerationModelConfig(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -9774,9 +9818,10 @@ func TestProposeChatTitle(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) user := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createTitleGenerationModelConfig(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, @@ -9809,9 +9854,10 @@ func TestProposeChatTitle(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithAPIAndDatabase(t) + client, db, api := newChatClientWithoutAIBridge(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModelConfigWithTitleFailure(t, client) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID,