mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add chat debug HTTP handlers and API docs (#23918)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Generated
+3
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user