feat: add chat debug HTTP handlers and API docs (#23918)

This commit is contained in:
Thomas Kosiewski
2026-04-20 13:34:41 +02:00
committed by GitHub
parent ea00d2d396
commit 18a30a7a10
7 changed files with 1002 additions and 18 deletions
+9
View File
@@ -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)
})
})
})
+35
View File
@@ -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.
+116
View File
@@ -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()
+228
View File
@@ -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))
}
+521 -10
View File
@@ -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)
+90 -4
View File
@@ -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)
+3 -4
View File
@@ -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;