From 18a30a7a1053d94c1a17ba65b3bc0a72877a412c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 20 Apr 2026 13:34:41 +0200 Subject: [PATCH] feat: add chat debug HTTP handlers and API docs (#23918) --- coderd/coderd.go | 9 + coderd/database/db2sdk/db2sdk.go | 35 ++ coderd/database/db2sdk/db2sdk_test.go | 116 ++++++ coderd/exp_chats.go | 228 +++++++++++ coderd/exp_chats_test.go | 531 +++++++++++++++++++++++++- codersdk/chats.go | 94 ++++- site/src/api/typesGenerated.ts | 7 +- 7 files changed, 1002 insertions(+), 18 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index f07a085dc9..a6cacda548 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -784,6 +784,7 @@ func New(options *Options) *API { SubscribeFn: options.ChatSubscribeFn, MaxChatsPerAcquire: int32(maxChatsPerAcquire), //nolint:gosec // maxChatsPerAcquire is clamped to int32 range above. ProviderAPIKeys: ChatProviderAPIKeysFromDeploymentValues(options.DeploymentValues), + AlwaysEnableDebugLogs: options.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value(), AgentConn: api.agentProvider.AgentConn, AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout, InstructionLookupTimeout: options.ChatdInstructionLookupTimeout, @@ -1187,6 +1188,10 @@ func New(options *Options) *API { r.Put("/explore-model-override", api.putChatExploreModelOverride) r.Get("/desktop-enabled", api.getChatDesktopEnabled) r.Put("/desktop-enabled", api.putChatDesktopEnabled) + r.Get("/debug-logging", api.getChatDebugLogging) + r.Put("/debug-logging", api.putChatDebugLogging) + r.Get("/user-debug-logging", api.getUserChatDebugLogging) + r.Put("/user-debug-logging", api.putUserChatDebugLogging) r.Get("/user-prompt", api.getUserChatCustomPrompt) r.Put("/user-prompt", api.putUserChatCustomPrompt) r.Get("/user-compaction-thresholds", api.getUserChatCompactionThresholds) @@ -1257,6 +1262,10 @@ func New(options *Options) *API { r.Delete("/", api.deleteChatQueuedMessage) r.Post("/promote", api.promoteChatQueuedMessage) }) + r.Route("/debug", func(r chi.Router) { + r.Get("/runs", api.getChatDebugRuns) + r.Get("/runs/{debugRun}", api.getChatDebugRun) + }) }) }) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index ce826f7869..6213c71673 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1545,6 +1545,14 @@ func chatMessageParts(m database.ChatMessage) ([]codersdk.ChatMessagePart, error return parts, nil } +func nullUUIDPtr(v uuid.NullUUID) *uuid.UUID { + if !v.Valid { + return nil + } + value := v.UUID + return &value +} + func nullInt64Ptr(v sql.NullInt64) *int64 { if !v.Valid { return nil @@ -1761,6 +1769,33 @@ func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep { } } +// ChatDebugRunDetail converts a database.ChatDebugRun and its steps +// to a codersdk.ChatDebugRun. +func ChatDebugRunDetail(r database.ChatDebugRun, steps []database.ChatDebugStep) codersdk.ChatDebugRun { + sdkSteps := make([]codersdk.ChatDebugStep, 0, len(steps)) + for _, s := range steps { + sdkSteps = append(sdkSteps, ChatDebugStep(s)) + } + return codersdk.ChatDebugRun{ + ID: r.ID, + ChatID: r.ChatID, + RootChatID: nullUUIDPtr(r.RootChatID), + ParentChatID: nullUUIDPtr(r.ParentChatID), + ModelConfigID: nullUUIDPtr(r.ModelConfigID), + TriggerMessageID: nullInt64Ptr(r.TriggerMessageID), + HistoryTipMessageID: nullInt64Ptr(r.HistoryTipMessageID), + Kind: codersdk.ChatDebugRunKind(r.Kind), + Status: codersdk.ChatDebugStatus(r.Status), + Provider: nullStringPtr(r.Provider), + Model: nullStringPtr(r.Model), + Summary: rawJSONObject(r.Summary), + StartedAt: r.StartedAt, + UpdatedAt: r.UpdatedAt, + FinishedAt: nullTimePtr(r.FinishedAt), + Steps: sdkSteps, + } +} + // ChildChatRows converts child chat rows to codersdk.Chat values, // resolving diff statuses from the shared map. When diffStatuses // is non-nil, children without an entry receive an empty DiffStatus. diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 095a20042e..acdb43b03e 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -483,6 +483,122 @@ func TestChatDebugStep_JSONNullYieldsEmptyStructures(t *testing.T) { require.Empty(t, sdk.Metadata, "JSON literal null must produce empty map") } +func TestChatDebugRunDetail(t *testing.T) { + t.Parallel() + + startedAt := time.Now().UTC().Round(time.Second) + finishedAt := startedAt.Add(5 * time.Second) + rootChatID := uuid.New() + parentChatID := uuid.New() + modelConfigID := uuid.New() + triggerMessageID := int64(7) + historyTipMessageID := int64(11) + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}, + ParentChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + TriggerMessageID: sql.NullInt64{Int64: triggerMessageID, Valid: true}, + HistoryTipMessageID: sql.NullInt64{Int64: historyTipMessageID, Valid: true}, + Kind: "chat_turn", + Status: "completed", + Provider: sql.NullString{String: "openai", Valid: true}, + Model: sql.NullString{String: "gpt-4o", Valid: true}, + Summary: json.RawMessage(`{"step_count":2}`), + StartedAt: startedAt, + UpdatedAt: finishedAt, + FinishedAt: sql.NullTime{Time: finishedAt, Valid: true}, + } + steps := []database.ChatDebugStep{ + { + ID: uuid.New(), + RunID: run.ID, + ChatID: run.ChatID, + StepNumber: 1, + Operation: "stream", + Status: "completed", + NormalizedRequest: json.RawMessage(`{"messages":[]}`), + Attempts: json.RawMessage(`[]`), + Metadata: json.RawMessage(`{}`), + StartedAt: startedAt, + UpdatedAt: finishedAt, + }, + { + ID: uuid.New(), + RunID: run.ID, + ChatID: run.ChatID, + StepNumber: 2, + Operation: "generate", + Status: "completed", + NormalizedRequest: json.RawMessage(`{"messages":[]}`), + Attempts: json.RawMessage(`[]`), + Metadata: json.RawMessage(`{}`), + StartedAt: startedAt, + UpdatedAt: finishedAt, + }, + } + + sdk := db2sdk.ChatDebugRunDetail(run, steps) + + require.Equal(t, run.ID, sdk.ID) + require.Equal(t, run.ChatID, sdk.ChatID) + require.NotNil(t, sdk.RootChatID) + require.Equal(t, rootChatID, *sdk.RootChatID) + require.NotNil(t, sdk.ParentChatID) + require.Equal(t, parentChatID, *sdk.ParentChatID) + require.NotNil(t, sdk.ModelConfigID) + require.Equal(t, modelConfigID, *sdk.ModelConfigID) + require.NotNil(t, sdk.TriggerMessageID) + require.Equal(t, triggerMessageID, *sdk.TriggerMessageID) + require.NotNil(t, sdk.HistoryTipMessageID) + require.Equal(t, historyTipMessageID, *sdk.HistoryTipMessageID) + require.Equal(t, codersdk.ChatDebugRunKindChatTurn, sdk.Kind) + require.Equal(t, codersdk.ChatDebugStatusCompleted, sdk.Status) + require.NotNil(t, sdk.Provider) + require.Equal(t, "openai", *sdk.Provider) + require.NotNil(t, sdk.Model) + require.Equal(t, "gpt-4o", *sdk.Model) + require.Equal(t, map[string]any{"step_count": float64(2)}, sdk.Summary) + require.Equal(t, startedAt, sdk.StartedAt) + require.Equal(t, finishedAt, sdk.UpdatedAt) + require.NotNil(t, sdk.FinishedAt) + require.Equal(t, finishedAt, *sdk.FinishedAt) + require.Len(t, sdk.Steps, 2) + require.Equal(t, steps[0].ID, sdk.Steps[0].ID) + require.Equal(t, codersdk.ChatDebugStepOperationStream, sdk.Steps[0].Operation) + require.Equal(t, steps[1].ID, sdk.Steps[1].ID) + require.Equal(t, codersdk.ChatDebugStepOperationGenerate, sdk.Steps[1].Operation) +} + +func TestChatDebugRunDetail_NullableFieldsNil(t *testing.T) { + t.Parallel() + + run := database.ChatDebugRun{ + ID: uuid.New(), + ChatID: uuid.New(), + Kind: "chat_turn", + Status: "in_progress", + Summary: json.RawMessage(`{}`), + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + sdk := db2sdk.ChatDebugRunDetail(run, nil) + + require.Nil(t, sdk.RootChatID, "NULL RootChatID should map to nil") + require.Nil(t, sdk.ParentChatID, "NULL ParentChatID should map to nil") + require.Nil(t, sdk.ModelConfigID, "NULL ModelConfigID should map to nil") + require.Nil(t, sdk.TriggerMessageID, "NULL TriggerMessageID should map to nil") + require.Nil(t, sdk.HistoryTipMessageID, "NULL HistoryTipMessageID should map to nil") + require.Nil(t, sdk.Provider, "NULL Provider should map to nil") + require.Nil(t, sdk.Model, "NULL Model should map to nil") + require.Nil(t, sdk.FinishedAt, "NULL FinishedAt should map to nil") + require.NotNil(t, sdk.Steps, "nil steps slice should serialize as empty array") + require.Empty(t, sdk.Steps) +} + func TestAIBridgeInterception(t *testing.T) { t.Parallel() diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 464ee12eb4..0fdf13611b 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3660,6 +3660,140 @@ func (api *API) putChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } +func (api *API) deploymentChatDebugLoggingEnabled() bool { + return api.DeploymentValues != nil && api.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value() +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatDebugLogging(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) { + httpapi.ResourceNotFound(rw) + return + } + + allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching chat debug logging setting.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugLoggingAdminSettings{ + AllowUsers: err == nil && allowUsers, + ForcedByDeployment: api.deploymentChatDebugLoggingEnabled(), + }) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.UpdateChatDebugLoggingAllowUsersRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + if err := api.Database.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating chat debug logging setting.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + forcedByDeployment := api.deploymentChatDebugLoggingEnabled() + allowUsers := false + if !forcedByDeployment { + enabled, err := api.Database.GetChatDebugLoggingAllowUsers(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching chat debug logging setting.", + Detail: err.Error(), + }) + return + } + allowUsers = err == nil && enabled + } + + debugEnabled := forcedByDeployment + if allowUsers { + enabled, err := api.Database.GetUserChatDebugLoggingEnabled(ctx, apiKey.UserID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user chat debug logging setting.", + Detail: err.Error(), + }) + return + } + debugEnabled = err == nil && enabled + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatDebugLoggingSettings{ + DebugLoggingEnabled: debugEnabled, + UserToggleAllowed: !forcedByDeployment && allowUsers, + ForcedByDeployment: forcedByDeployment, + }) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) putUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + if api.deploymentChatDebugLoggingEnabled() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Chat debug logging is already forced on by deployment configuration.", + }) + return + } + + allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching chat debug logging setting.", + Detail: err.Error(), + }) + return + } + if err != nil || !allowUsers { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "An administrator has not enabled user-controlled chat debug logging.", + }) + return + } + + var req codersdk.UpdateUserChatDebugLoggingRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + if err := api.Database.UpsertUserChatDebugLoggingEnabled(ctx, database.UpsertUserChatDebugLoggingEnabledParams{ + UserID: apiKey.UserID, + DebugLoggingEnabled: req.DebugLoggingEnabled, + }); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating user chat debug logging setting.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. @@ -6378,3 +6512,97 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } + +// getChatDebugRuns returns a list of debug run summaries for a chat. +// EXPERIMENTAL +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatDebugRuns(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + + const maxDebugRuns = 100 + runs, err := api.Database.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{ + ChatID: chat.ID, + LimitVal: maxDebugRuns, + }) + if err != nil { + // The chat may have been deleted or access revoked between + // middleware extraction and this query (dbauthz re-authorizes + // on read). Surface those races as 404 to match the rest of + // this API and avoid leaking backend details. + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching debug runs.", + Detail: err.Error(), + }) + return + } + + summaries := make([]codersdk.ChatDebugRunSummary, 0, len(runs)) + for _, run := range runs { + summaries = append(summaries, db2sdk.ChatDebugRunSummary(run)) + } + httpapi.Write(ctx, rw, http.StatusOK, summaries) +} + +// getChatDebugRun returns a single debug run with its steps. +// EXPERIMENTAL +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatDebugRun(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + + runIDStr := chi.URLParam(r, "debugRun") + runID, err := uuid.Parse(runIDStr) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid debug run ID.", + Detail: err.Error(), + }) + return + } + + run, err := api.Database.GetChatDebugRunByID(ctx, runID) + if err != nil { + // Treat both not-found and authorization failures as 404 to + // avoid leaking the existence of runs the caller cannot access. + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching debug run.", + Detail: err.Error(), + }) + return + } + + // Verify the run belongs to this chat. + if run.ChatID != chat.ID { + httpapi.ResourceNotFound(rw) + return + } + + steps, err := api.Database.GetChatDebugStepsByRunID(ctx, run.ID) + if err != nil { + // The run may have been deleted or access may have changed + // between the two queries. Treat not-found/authz errors as + // 404 for consistency with the run lookup above. + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching debug steps.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatDebugRunDetail(run, steps)) +} diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 2db2f9670d..7c501c6064 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -9082,6 +9082,517 @@ func TestChatDesktopEnabled(t *testing.T) { }) } +func TestChatDebugLoggingSettings(t *testing.T) { + t.Parallel() + + t.Run("DefaultDisabled", 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) + + adminResp, err := adminClient.GetChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, adminResp.AllowUsers) + require.False(t, adminResp.ForcedByDeployment) + + userResp, err := memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, userResp.DebugLoggingEnabled) + require.False(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + }) + + t.Run("AdminAllowsUsersToOptIn", 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) + + err := adminClient.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{ + AllowUsers: true, + }) + require.NoError(t, err) + + userResp, err := memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, userResp.DebugLoggingEnabled) + require.True(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + + err = memberClient.UpdateUserChatDebugLogging(ctx, codersdk.UpdateUserChatDebugLoggingRequest{ + DebugLoggingEnabled: true, + }) + require.NoError(t, err) + + userResp, err = memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.True(t, userResp.DebugLoggingEnabled) + require.True(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + + // Admin revocation must flip the user's effective state even + // while the stored opt-in is true. A regression that kept + // returning the stored opt-in would be masked if the user had + // already opted out, so we revoke here before the user touches + // their setting. + err = adminClient.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{ + AllowUsers: false, + }) + require.NoError(t, err) + + userResp, err = memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, userResp.DebugLoggingEnabled) + require.False(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + + // Re-allowing must restore the previously stored opt-in + // without requiring the user to opt in again. + err = adminClient.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{ + AllowUsers: true, + }) + require.NoError(t, err) + + userResp, err = memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.True(t, userResp.DebugLoggingEnabled, "stored opt-in must survive an admin allow/revoke cycle") + require.True(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + + // User can explicitly opt back out while admin still allows the + // toggle. This exercises the UpsertUserChatDebugLoggingEnabled + // success path for the false value. + err = memberClient.UpdateUserChatDebugLogging(ctx, codersdk.UpdateUserChatDebugLoggingRequest{ + DebugLoggingEnabled: false, + }) + require.NoError(t, err) + + userResp, err = memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, userResp.DebugLoggingEnabled) + require.True(t, userResp.UserToggleAllowed) + require.False(t, userResp.ForcedByDeployment) + }) + + t.Run("UserWriteFailsWhenAdminDisabled", 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) + + err := memberClient.UpdateUserChatDebugLogging(ctx, codersdk.UpdateUserChatDebugLoggingRequest{ + DebugLoggingEnabled: true, + }) + requireSDKError(t, err, http.StatusForbidden) + }) + + t.Run("NonAdminCannotManageAdminSetting", 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) + + _, err := memberClient.GetChatDebugLogging(ctx) + requireSDKError(t, err, http.StatusNotFound) + + err = memberClient.UpdateChatDebugLogging(ctx, codersdk.UpdateChatDebugLoggingAllowUsersRequest{ + AllowUsers: true, + }) + requireSDKError(t, err, http.StatusForbidden) + }) + + t.Run("DeploymentForceEnablesDebugLogging", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + values := chatDeploymentValues(t) + values.AI.Chat.DebugLoggingEnabled = serpent.Bool(true) + adminClient := newChatClientWithDeploymentValues(t, values) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + adminResp, err := adminClient.GetChatDebugLogging(ctx) + require.NoError(t, err) + require.False(t, adminResp.AllowUsers) + require.True(t, adminResp.ForcedByDeployment) + + userResp, err := memberClient.GetUserChatDebugLogging(ctx) + require.NoError(t, err) + require.True(t, userResp.DebugLoggingEnabled) + require.False(t, userResp.UserToggleAllowed) + require.True(t, userResp.ForcedByDeployment) + + err = memberClient.UpdateUserChatDebugLogging(ctx, codersdk.UpdateUserChatDebugLoggingRequest{ + DebugLoggingEnabled: false, + }) + requireSDKError(t, err, http.StatusConflict) + }) + + t.Run("UnauthenticatedUserReadFails", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + adminClient := newChatClient(t) + coderdtest.CreateFirstUser(t, adminClient.Client) + + anonClient := codersdk.NewExperimentalClient(codersdk.New(adminClient.URL)) + _, err := anonClient.GetUserChatDebugLogging(ctx) + requireSDKError(t, err, http.StatusUnauthorized) + }) +} + +// seedChatDebugRun inserts a debug run for a chat, bypassing the chatd +// service so HTTP handlers can be exercised in isolation. Steps are +// inserted separately via seedChatDebugStep. +func seedChatDebugRun( + ctx context.Context, + t *testing.T, + db database.Store, + chatID uuid.UUID, + startedAt time.Time, +) database.ChatDebugRun { + t.Helper() + + run, err := db.InsertChatDebugRun(dbauthz.AsSystemRestricted(ctx), database.InsertChatDebugRunParams{ + ChatID: chatID, + Kind: string(codersdk.ChatDebugRunKindChatTurn), + Status: string(codersdk.ChatDebugStatusInProgress), + Provider: sql.NullString{String: "openai", Valid: true}, + Model: sql.NullString{String: "gpt-4o-mini", Valid: true}, + StartedAt: sql.NullTime{Time: startedAt, Valid: true}, + UpdatedAt: sql.NullTime{Time: startedAt, Valid: true}, + }) + require.NoError(t, err) + return run +} + +func seedChatDebugStep( + ctx context.Context, + t *testing.T, + db database.Store, + run database.ChatDebugRun, + stepNumber int32, +) database.ChatDebugStep { + t.Helper() + + step, err := db.InsertChatDebugStep(dbauthz.AsSystemRestricted(ctx), database.InsertChatDebugStepParams{ + RunID: run.ID, + ChatID: run.ChatID, + StepNumber: stepNumber, + Operation: string(codersdk.ChatDebugStepOperationStream), + Status: string(codersdk.ChatDebugStatusCompleted), + }) + require.NoError(t, err) + return step +} + +func TestChatDebugRuns(t *testing.T) { + t.Parallel() + + t.Run("ListReturnsRunsNewestFirst", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess()) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: firstUser.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: member.ID, + LastModelConfigID: modelConfig.ID, + Title: "debug-runs-list", + }) + require.NoError(t, err) + + base := time.Now().UTC().Add(-time.Hour).Round(time.Second) + older := seedChatDebugRun(ctx, t, db, chat.ID, base) + newer := seedChatDebugRun(ctx, t, db, chat.ID, base.Add(10*time.Minute)) + + runs, err := memberClient.GetChatDebugRuns(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, runs, 2) + require.Equal(t, newer.ID, runs[0].ID, "newest run must come first") + require.Equal(t, older.ID, runs[1].ID) + require.Equal(t, codersdk.ChatDebugRunKindChatTurn, runs[0].Kind) + require.Equal(t, codersdk.ChatDebugStatusInProgress, runs[0].Status) + }) + + t.Run("ListCapsAt100", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-runs-cap", + }) + require.NoError(t, err) + + base := time.Now().UTC().Add(-24 * time.Hour).Round(time.Second) + // Seed 101 runs with monotonically increasing started_at. The + // handler caps at 100, so the oldest run (i=0) must be excluded + // and the remaining runs must be returned newest-first. + seeded := make([]database.ChatDebugRun, 101) + for i := range seeded { + seeded[i] = seedChatDebugRun(ctx, t, db, chat.ID, base.Add(time.Duration(i)*time.Minute)) + } + + runs, err := client.GetChatDebugRuns(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, runs, 100, "list must be capped at maxDebugRuns") + require.Equal(t, seeded[100].ID, runs[0].ID, "newest seeded run must come first") + require.Equal(t, seeded[1].ID, runs[99].ID, "oldest retained run must be last, proving the cap drops the oldest") + returned := make(map[uuid.UUID]struct{}, len(runs)) + for _, r := range runs { + returned[r.ID] = struct{}{} + } + require.NotContains(t, returned, seeded[0].ID, "oldest seeded run must be excluded by the cap") + }) + + t.Run("ReturnsEmptyListWhenNoRuns", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-runs-empty", + }) + require.NoError(t, err) + + // Guard against a regression from `make([]..., 0, n)` to + // `var summaries []...`, which would silently serialize as + // `null` instead of `[]`. + runs, err := client.GetChatDebugRuns(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, runs, "runs slice must be non-nil even when empty") + require.Empty(t, runs) + }) + + t.Run("NonExistentChatReturns404", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + coderdtest.CreateFirstUser(t, client.Client) + + _, err := client.GetChatDebugRuns(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("NonOwnerCannotList", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + // Chat owned by the first (admin) user. + 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: "debug-runs-other-owner", + }) + require.NoError(t, err) + + seedChatDebugRun(ctx, t, db, chat.ID, time.Now().UTC()) + + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess()) + otherClient := codersdk.NewExperimentalClient(otherClientRaw) + + _, err = otherClient.GetChatDebugRuns(ctx, chat.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + +func TestChatDebugRun(t *testing.T) { + t.Parallel() + + t.Run("ReturnsRunWithSteps", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-run-detail", + }) + require.NoError(t, err) + + run := seedChatDebugRun(ctx, t, db, chat.ID, time.Now().UTC()) + firstStep := seedChatDebugStep(ctx, t, db, run, 1) + secondStep := seedChatDebugStep(ctx, t, db, run, 2) + + got, err := client.GetChatDebugRun(ctx, chat.ID, run.ID) + require.NoError(t, err) + require.Equal(t, run.ID, got.ID) + require.Equal(t, chat.ID, got.ChatID) + require.Equal(t, codersdk.ChatDebugRunKindChatTurn, got.Kind) + require.Equal(t, codersdk.ChatDebugStatusInProgress, got.Status) + require.NotNil(t, got.Provider) + require.Equal(t, "openai", *got.Provider) + require.Len(t, got.Steps, 2) + require.Equal(t, firstStep.ID, got.Steps[0].ID) + require.Equal(t, secondStep.ID, got.Steps[1].ID) + require.Equal(t, codersdk.ChatDebugStepOperationStream, got.Steps[0].Operation) + }) + + t.Run("ReturnsRunWithoutSteps", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-run-empty", + }) + require.NoError(t, err) + run := seedChatDebugRun(ctx, t, db, chat.ID, time.Now().UTC()) + + got, err := client.GetChatDebugRun(ctx, chat.ID, run.ID) + require.NoError(t, err) + require.Equal(t, run.ID, got.ID) + require.NotNil(t, got.Steps, "steps slice must be non-nil even when empty") + require.Empty(t, got.Steps) + }) + + t.Run("InvalidRunIDReturns400", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-run-bad-uuid", + }) + require.NoError(t, err) + + // Issue a raw request with a non-UUID run ID to exercise the + // handler's parser path. + res, err := client.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/experimental/chats/%s/debug/runs/not-a-uuid", chat.ID), nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NonExistentRunReturns404", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(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: "debug-run-missing", + }) + require.NoError(t, err) + + _, err = client.GetChatDebugRun(ctx, chat.ID, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) + + t.Run("RunOnOtherChatReturns404", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + // Two chats owned by the same user. A run on chat A must not + // be addressable through chat B's URL. + chatA, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: firstUser.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "debug-run-chat-a", + }) + require.NoError(t, err) + chatB, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{ + OrganizationID: firstUser.OrganizationID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: firstUser.UserID, + LastModelConfigID: modelConfig.ID, + Title: "debug-run-chat-b", + }) + require.NoError(t, err) + + runOnA := seedChatDebugRun(ctx, t, db, chatA.ID, time.Now().UTC()) + + _, err = client.GetChatDebugRun(ctx, chatB.ID, runOnA.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + func TestChatWorkspaceTTL(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -9227,7 +9738,7 @@ func TestChatRetentionDays(t *testing.T) { requireSDKError(t, err, http.StatusBadRequest) } -//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance. +//nolint:tparallel // subtests share state via client, firstUser, modelConfig func TestUserChatCompactionThresholds(t *testing.T) { t.Parallel() @@ -9235,7 +9746,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - t.Run("EmptyByDefault", func(t *testing.T) { + t.Run("EmptyByDefault", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) thresholds, err := client.GetUserChatCompactionThresholds(ctx) @@ -9243,7 +9754,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { require.Empty(t, thresholds.Thresholds) }) - t.Run("PutAndGet", func(t *testing.T) { + t.Run("PutAndGet", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) override, err := client.UpdateUserChatCompactionThreshold(ctx, modelConfig.ID, codersdk.UpdateUserChatCompactionThresholdRequest{ @@ -9260,7 +9771,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { require.EqualValues(t, 75, thresholds.Thresholds[0].ThresholdPercent) }) - t.Run("UpsertChangesValue", func(t *testing.T) { + t.Run("UpsertChangesValue", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateUserChatCompactionThreshold(ctx, modelConfig.ID, codersdk.UpdateUserChatCompactionThresholdRequest{ @@ -9280,7 +9791,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { require.EqualValues(t, 75, thresholds.Thresholds[0].ThresholdPercent) }) - t.Run("BoundaryValues", func(t *testing.T) { + t.Run("BoundaryValues", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) override, err := client.UpdateUserChatCompactionThreshold(ctx, modelConfig.ID, codersdk.UpdateUserChatCompactionThresholdRequest{ @@ -9306,7 +9817,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { require.EqualValues(t, 100, thresholds.Thresholds[0].ThresholdPercent) }) - t.Run("ValidationRejectsInvalid", func(t *testing.T) { + t.Run("ValidationRejectsInvalid", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateUserChatCompactionThreshold(ctx, modelConfig.ID, codersdk.UpdateUserChatCompactionThresholdRequest{ @@ -9320,7 +9831,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { requireSDKError(t, err, http.StatusBadRequest) }) - t.Run("Delete", func(t *testing.T) { + t.Run("Delete", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) err := client.DeleteUserChatCompactionThreshold(ctx, modelConfig.ID) @@ -9331,14 +9842,14 @@ func TestUserChatCompactionThresholds(t *testing.T) { require.Empty(t, thresholds.Thresholds) }) - t.Run("DeleteIdempotent", func(t *testing.T) { + t.Run("DeleteIdempotent", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) err := client.DeleteUserChatCompactionThreshold(ctx, modelConfig.ID) require.NoError(t, err) }) - t.Run("NonExistentModelConfig", func(t *testing.T) { + t.Run("NonExistentModelConfig", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) fakeID := uuid.New() @@ -9348,7 +9859,7 @@ func TestUserChatCompactionThresholds(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - t.Run("IsolatedPerUser", func(t *testing.T) { + t.Run("IsolatedPerUser", func(t *testing.T) { //nolint:paralleltest // subtests share parent state ctx := testutil.Context(t, testutil.WaitLong) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) diff --git a/codersdk/chats.go b/codersdk/chats.go index 98c64de452..7bba7b8c1d 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -722,10 +722,9 @@ type ChatDebugRunSummary struct { FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"` } -// ChatDebugRun is the detailed run response including steps. -// This type is consumed by the run-detail handler added in a later -// PR in this stack; it is forward-declared here so that all SDK -// types live in the same schema-layer commit. +// ChatDebugRun is the detailed run response returned by the run-detail +// endpoint. It includes the same summary fields as ChatDebugRunSummary +// along with the full step history for the run. type ChatDebugRun struct { ID uuid.UUID `json:"id" format:"uuid"` ChatID uuid.UUID `json:"chat_id" format:"uuid"` @@ -2343,6 +2342,93 @@ func (c *ExperimentalClient) WatchChats(ctx context.Context) (<-chan ChatWatchEv }), nil } +// GetChatDebugLogging returns the runtime admin setting that allows +// users to opt into chat debug logging. +func (c *ExperimentalClient) GetChatDebugLogging(ctx context.Context) (ChatDebugLoggingAdminSettings, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/debug-logging", nil) + if err != nil { + return ChatDebugLoggingAdminSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatDebugLoggingAdminSettings{}, ReadBodyAsError(res) + } + var resp ChatDebugLoggingAdminSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateChatDebugLogging updates the runtime admin setting that allows +// users to opt into chat debug logging. +func (c *ExperimentalClient) UpdateChatDebugLogging(ctx context.Context, req UpdateChatDebugLoggingAllowUsersRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/debug-logging", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// GetUserChatDebugLogging returns whether chat debug logging is active +// for the current user and whether the user may change it. +func (c *ExperimentalClient) GetUserChatDebugLogging(ctx context.Context) (UserChatDebugLoggingSettings, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-debug-logging", nil) + if err != nil { + return UserChatDebugLoggingSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserChatDebugLoggingSettings{}, ReadBodyAsError(res) + } + var resp UserChatDebugLoggingSettings + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateUserChatDebugLogging updates the current user's chat debug +// logging preference. +func (c *ExperimentalClient) UpdateUserChatDebugLogging(ctx context.Context, req UpdateUserChatDebugLoggingRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/user-debug-logging", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// GetChatDebugRuns returns the debug runs for a chat. +func (c *ExperimentalClient) GetChatDebugRuns(ctx context.Context, chatID uuid.UUID) ([]ChatDebugRunSummary, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/debug/runs", chatID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var resp []ChatDebugRunSummary + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// GetChatDebugRun returns a single debug run along with its full step +// history. Use GetChatDebugRuns when only the run summary list is needed. +func (c *ExperimentalClient) GetChatDebugRun(ctx context.Context, chatID uuid.UUID, runID uuid.UUID) (ChatDebugRun, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/debug/runs/%s", chatID, runID), nil) + if err != nil { + return ChatDebugRun{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatDebugRun{}, ReadBodyAsError(res) + } + var resp ChatDebugRun + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // GetChat returns a chat by ID. func (c *ExperimentalClient) GetChat(ctx context.Context, chatID uuid.UUID) (Chat, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s", chatID), nil) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 24fb6c9f8b..b8f4bea13f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1432,10 +1432,9 @@ export interface ChatDebugLoggingAdminSettings { // From codersdk/chats.go /** - * ChatDebugRun is the detailed run response including steps. - * This type is consumed by the run-detail handler added in a later - * PR in this stack; it is forward-declared here so that all SDK - * types live in the same schema-layer commit. + * ChatDebugRun is the detailed run response returned by the run-detail + * endpoint. It includes the same summary fields as ChatDebugRunSummary + * along with the full step history for the run. */ export interface ChatDebugRun { readonly id: string;