Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a935d340b5 | |||
| af1b7a40ad | |||
| 74fa6e88e2 | |||
| 76f342f2fc |
@@ -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,
|
||||
@@ -1182,6 +1183,10 @@ func New(options *Options) *API {
|
||||
r.Put("/system-prompt", api.putChatSystemPrompt)
|
||||
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)
|
||||
@@ -1252,6 +1257,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1525,6 +1525,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
|
||||
@@ -1717,6 +1725,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,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatRows converts a slice of database.GetChatsRow (which embeds
|
||||
// Chat plus HasUnread) to codersdk.Chat, looking up diff statuses
|
||||
// from the provided map. When diffStatusesByChatID is non-nil,
|
||||
|
||||
@@ -3141,6 +3141,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.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
enabled, err := api.Database.GetChatDebugLoggingEnabled(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{
|
||||
DebugLoggingEnabled: err == nil && enabled,
|
||||
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.UpsertChatDebugLoggingEnabled(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.GetChatDebugLoggingEnabled(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.GetChatDebugLoggingEnabled(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.
|
||||
@@ -5867,3 +6001,95 @@ 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 {
|
||||
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 _, r := range runs {
|
||||
summaries = append(summaries, db2sdk.ChatDebugRunSummary(r))
|
||||
}
|
||||
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 errors.Is(err, sql.ErrNoRows) || dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Debug run not found.",
|
||||
})
|
||||
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.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Debug run not found.",
|
||||
})
|
||||
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 errors.Is(err, sql.ErrNoRows) || dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Debug run not found.",
|
||||
})
|
||||
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))
|
||||
}
|
||||
|
||||
+152
-10
@@ -7747,6 +7747,148 @@ 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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatWorkspaceTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
@@ -7892,7 +8034,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()
|
||||
|
||||
@@ -7900,7 +8042,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)
|
||||
@@ -7908,7 +8050,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{
|
||||
@@ -7925,7 +8067,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{
|
||||
@@ -7945,7 +8087,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{
|
||||
@@ -7971,7 +8113,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{
|
||||
@@ -7985,7 +8127,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)
|
||||
@@ -7996,14 +8138,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()
|
||||
@@ -8013,7 +8155,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)
|
||||
|
||||
+515
-55
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatcost"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
@@ -129,6 +130,7 @@ type Server struct {
|
||||
pubsub pubsub.Pubsub
|
||||
webpushDispatcher webpush.Dispatcher
|
||||
providerAPIKeys chatprovider.ProviderAPIKeys
|
||||
debugSvc *chatdebug.Service
|
||||
configCache *chatConfigCache
|
||||
configCacheUnsubscribe func()
|
||||
|
||||
@@ -1210,7 +1212,10 @@ func (p *Server) EditMessage(
|
||||
return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
|
||||
}
|
||||
|
||||
var result EditMessageResult
|
||||
var (
|
||||
result EditMessageResult
|
||||
editedMsg database.ChatMessage
|
||||
)
|
||||
txErr := p.db.InTx(func(tx database.Store) error {
|
||||
lockedChat, err := tx.GetChatByIDForUpdate(ctx, opts.ChatID)
|
||||
if err != nil {
|
||||
@@ -1221,17 +1226,17 @@ func (p *Server) EditMessage(
|
||||
return limitErr
|
||||
}
|
||||
|
||||
existing, err := tx.GetChatMessageByID(ctx, opts.EditedMessageID)
|
||||
editedMsg, err = tx.GetChatMessageByID(ctx, opts.EditedMessageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrEditedMessageNotFound
|
||||
}
|
||||
return xerrors.Errorf("get edited message: %w", err)
|
||||
}
|
||||
if existing.ChatID != opts.ChatID {
|
||||
if editedMsg.ChatID != opts.ChatID {
|
||||
return ErrEditedMessageNotFound
|
||||
}
|
||||
if existing.Role != database.ChatMessageRoleUser {
|
||||
if editedMsg.Role != database.ChatMessageRoleUser {
|
||||
return ErrEditedMessageNotUser
|
||||
}
|
||||
|
||||
@@ -1258,8 +1263,8 @@ func (p *Server) EditMessage(
|
||||
appendChatMessage(&msgParams, newChatMessage(
|
||||
database.ChatMessageRoleUser,
|
||||
content,
|
||||
existing.Visibility,
|
||||
existing.ModelConfigID.UUID,
|
||||
editedMsg.Visibility,
|
||||
editedMsg.ModelConfigID.UUID,
|
||||
chatprompt.CurrentContentVersion,
|
||||
).withCreatedBy(opts.CreatedBy))
|
||||
newMessages, err := insertChatMessageWithStore(ctx, tx, msgParams)
|
||||
@@ -1302,6 +1307,26 @@ func (p *Server) EditMessage(
|
||||
})
|
||||
p.publishStatus(opts.ChatID, result.Chat.Status, result.Chat.WorkerID)
|
||||
p.publishChatPubsubEvent(result.Chat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
|
||||
// Best-effort debug row cleanup. We do not wait for the active
|
||||
// worker to stop because activeChats is process-local and would
|
||||
// not cover multi-replica deployments. Any rows that survive
|
||||
// this pass are caught by the periodic stale-finalization sweep.
|
||||
if p.debugSvc != nil {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cleanupCancel()
|
||||
if _, err := p.debugSvc.DeleteAfterMessageID(
|
||||
cleanupCtx,
|
||||
opts.ChatID,
|
||||
editedMsg.ID-1,
|
||||
); err != nil {
|
||||
p.logger.Warn(ctx, "failed to delete chat debug rows after edit",
|
||||
slog.F("chat_id", opts.ChatID),
|
||||
slog.F("edited_message_id", editedMsg.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
p.signalWake()
|
||||
|
||||
return result, nil
|
||||
@@ -1316,46 +1341,67 @@ func (p *Server) ArchiveChat(ctx context.Context, chat database.Chat) error {
|
||||
return xerrors.New("chat_id is required")
|
||||
}
|
||||
|
||||
statusChat := chat
|
||||
interrupted := false
|
||||
var archivedChats []database.Chat
|
||||
var (
|
||||
archivedChats []database.Chat
|
||||
interruptedChats []database.Chat
|
||||
)
|
||||
if err := p.db.InTx(func(tx database.Store) error {
|
||||
lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
|
||||
if err != nil {
|
||||
if _, err := tx.GetChatByIDForUpdate(ctx, chat.ID); err != nil {
|
||||
return xerrors.Errorf("lock chat for archive: %w", err)
|
||||
}
|
||||
statusChat = lockedChat
|
||||
|
||||
// We do not call setChatWaiting here because it intentionally preserves
|
||||
// pending chats so queued-message promotion can win. Archiving is a
|
||||
// harder stop: both pending and running chats must transition to waiting.
|
||||
if lockedChat.Status == database.ChatStatusPending || lockedChat.Status == database.ChatStatusRunning {
|
||||
statusChat, err = tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
var err error
|
||||
archivedChats, err = tx.ArchiveChatByID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("archive chat: %w", err)
|
||||
}
|
||||
|
||||
for i, archivedChat := range archivedChats {
|
||||
if archivedChat.Status != database.ChatStatusPending &&
|
||||
archivedChat.Status != database.ChatStatusRunning {
|
||||
continue
|
||||
}
|
||||
|
||||
updatedChat, updateErr := tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: archivedChat.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: sql.NullString{},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set chat waiting before archive: %w", err)
|
||||
if updateErr != nil {
|
||||
return xerrors.Errorf("set archived chat waiting before cleanup: %w", updateErr)
|
||||
}
|
||||
interrupted = true
|
||||
}
|
||||
|
||||
archivedChats, err = tx.ArchiveChatByID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("archive chat: %w", err)
|
||||
archivedChats[i] = updatedChat
|
||||
interruptedChats = append(interruptedChats, updatedChat)
|
||||
}
|
||||
return nil
|
||||
}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if interrupted {
|
||||
p.publishStatus(chat.ID, statusChat.Status, statusChat.WorkerID)
|
||||
p.publishChatPubsubEvent(statusChat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
for _, interruptedChat := range interruptedChats {
|
||||
p.publishStatus(interruptedChat.ID, interruptedChat.Status, interruptedChat.WorkerID)
|
||||
p.publishChatPubsubEvent(interruptedChat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
}
|
||||
|
||||
// Best-effort debug row cleanup — no process-local wait so this
|
||||
// works correctly across replicas. If an active goroutine writes
|
||||
// new debug rows after the delete, FinalizeStale will mark them
|
||||
// as interrupted. Those orphaned rows are harmless because the
|
||||
// chat itself is archived and no longer served through the API.
|
||||
if p.debugSvc != nil {
|
||||
for _, archivedChat := range archivedChats {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
if _, err := p.debugSvc.DeleteByChatID(cleanupCtx, archivedChat.ID); err != nil {
|
||||
p.logger.Warn(ctx, "failed to delete chat debug rows after archive",
|
||||
slog.F("chat_id", archivedChat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
cleanupCancel()
|
||||
}
|
||||
}
|
||||
|
||||
p.publishChatPubsubEvents(archivedChats, codersdk.ChatWatchEventKindDeleted)
|
||||
@@ -1818,6 +1864,8 @@ func (p *Server) InterruptChat(
|
||||
}
|
||||
}
|
||||
|
||||
// Debug runs are finalized in the execution path when the owning
|
||||
// goroutine observes cancellation, so we do not mutate debug state here.
|
||||
updatedChat, err := p.setChatWaiting(ctx, chat.ID)
|
||||
if err != nil {
|
||||
p.logger.Error(ctx, "failed to mark chat as waiting",
|
||||
@@ -2058,7 +2106,23 @@ func (p *Server) regenerateChatTitleWithStore(
|
||||
return database.Chat{}, err
|
||||
}
|
||||
|
||||
title, usage, err := generateManualTitle(ctx, messages, model)
|
||||
debugEnabled := p.debugSvc != nil && p.debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
||||
titleCtx := ctx
|
||||
titleModel := model
|
||||
finishDebugRun := func(error) {}
|
||||
if debugEnabled {
|
||||
titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun(
|
||||
ctx,
|
||||
chat,
|
||||
modelConfig,
|
||||
keys,
|
||||
messages,
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
title, usage, err := generateManualTitle(titleCtx, messages, titleModel)
|
||||
finishDebugRun(err)
|
||||
if err != nil {
|
||||
wrappedErr := xerrors.Errorf("generate manual title: %w", err)
|
||||
if usage == (fantasy.Usage{}) {
|
||||
@@ -2096,6 +2160,177 @@ func (p *Server) regenerateChatTitleWithStore(
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
func (p *Server) prepareManualTitleDebugRun(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
modelConfig database.ChatModelConfig,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
messages []database.ChatMessage,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
) (context.Context, fantasy.LanguageModel, func(error)) {
|
||||
titleCtx := ctx
|
||||
titleModel := fallbackModel
|
||||
finishDebugRun := func(error) {}
|
||||
|
||||
httpClient := &http.Client{Transport: &chatdebug.RecordingTransport{}}
|
||||
debugModel, debugModelErr := chatprovider.ModelFromConfig(
|
||||
modelConfig.Provider,
|
||||
modelConfig.Model,
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
httpClient,
|
||||
)
|
||||
switch {
|
||||
case debugModelErr != nil:
|
||||
p.logger.Warn(ctx, "failed to create debug-aware manual title model",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
slog.Error(debugModelErr),
|
||||
)
|
||||
case debugModel == nil:
|
||||
p.logger.Warn(ctx, "manual title debug model creation returned nil",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
)
|
||||
default:
|
||||
titleModel = chatdebug.WrapModel(debugModel, p.debugSvc, chatdebug.RecorderOptions{
|
||||
ChatID: chat.ID,
|
||||
OwnerID: chat.OwnerID,
|
||||
Provider: modelConfig.Provider,
|
||||
Model: modelConfig.Model,
|
||||
})
|
||||
}
|
||||
|
||||
var historyTipMessageID int64
|
||||
if len(messages) > 0 {
|
||||
historyTipMessageID = messages[len(messages)-1].ID
|
||||
}
|
||||
|
||||
// Derive a first_message label from the first user message.
|
||||
var firstUserLabel string
|
||||
for _, msg := range messages {
|
||||
if msg.Role == database.ChatMessageRoleUser {
|
||||
if parts, parseErr := chatprompt.ParseContent(msg); parseErr == nil {
|
||||
firstUserLabel = contentBlocksToText(parts)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstUserLabel == "" {
|
||||
firstUserLabel = "Title generation"
|
||||
}
|
||||
seedSummary := chatdebug.SeedSummary(
|
||||
chatdebug.TruncateLabel(firstUserLabel, chatdebug.MaxLabelLength),
|
||||
)
|
||||
|
||||
createRunCtx, createRunCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
debugRun, createRunErr := p.debugSvc.CreateRun(createRunCtx, chatdebug.CreateRunParams{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: modelConfig.ID,
|
||||
Provider: modelConfig.Provider,
|
||||
Model: modelConfig.Model,
|
||||
Kind: chatdebug.KindTitleGeneration,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
TriggerMessageID: 0,
|
||||
Summary: seedSummary,
|
||||
})
|
||||
createRunCancel()
|
||||
if createRunErr != nil {
|
||||
p.logger.Warn(ctx, "failed to create manual title debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
slog.Error(createRunErr),
|
||||
)
|
||||
return titleCtx, titleModel, finishDebugRun
|
||||
}
|
||||
|
||||
runContext := chatdebugRunContext(debugRun)
|
||||
titleCtx = chatdebug.ContextWithRun(titleCtx, &runContext)
|
||||
finishDebugRun = func(generateErr error) {
|
||||
status := chatdebug.StatusCompleted
|
||||
switch {
|
||||
case generateErr == nil:
|
||||
// keep completed
|
||||
case errors.Is(generateErr, context.Canceled):
|
||||
status = chatdebug.StatusInterrupted
|
||||
default:
|
||||
status = chatdebug.StatusError
|
||||
}
|
||||
|
||||
finalSummary := seedSummary
|
||||
aggCtx, aggCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer aggCancel()
|
||||
if aggregated, aggErr := p.debugSvc.AggregateRunSummary(
|
||||
aggCtx,
|
||||
debugRun.ID,
|
||||
seedSummary,
|
||||
); aggErr != nil {
|
||||
p.logger.Warn(ctx, "failed to aggregate debug run summary",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(aggErr),
|
||||
)
|
||||
} else {
|
||||
finalSummary = aggregated
|
||||
}
|
||||
|
||||
updateRunCtx, updateRunCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer updateRunCancel()
|
||||
_, updateRunErr := p.debugSvc.UpdateRun(updateRunCtx, chatdebug.UpdateRunParams{
|
||||
ID: debugRun.ID,
|
||||
ChatID: debugRun.ChatID,
|
||||
Status: status,
|
||||
Summary: finalSummary,
|
||||
FinishedAt: time.Now(),
|
||||
})
|
||||
if updateRunErr != nil {
|
||||
p.logger.Warn(ctx, "failed to finalize manual title debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(updateRunErr),
|
||||
)
|
||||
}
|
||||
chatdebug.CleanupStepCounter(debugRun.ID)
|
||||
}
|
||||
|
||||
return titleCtx, titleModel, finishDebugRun
|
||||
}
|
||||
|
||||
func chatdebugRunContext(run database.ChatDebugRun) chatdebug.RunContext {
|
||||
runContext := chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: run.ChatID,
|
||||
Kind: chatdebug.RunKind(run.Kind),
|
||||
}
|
||||
if run.RootChatID.Valid {
|
||||
runContext.RootChatID = run.RootChatID.UUID
|
||||
}
|
||||
if run.ParentChatID.Valid {
|
||||
runContext.ParentChatID = run.ParentChatID.UUID
|
||||
}
|
||||
if run.ModelConfigID.Valid {
|
||||
runContext.ModelConfigID = run.ModelConfigID.UUID
|
||||
}
|
||||
if run.TriggerMessageID.Valid {
|
||||
runContext.TriggerMessageID = run.TriggerMessageID.Int64
|
||||
}
|
||||
if run.HistoryTipMessageID.Valid {
|
||||
runContext.HistoryTipMessageID = run.HistoryTipMessageID.Int64
|
||||
}
|
||||
if run.Provider.Valid {
|
||||
runContext.Provider = run.Provider.String
|
||||
}
|
||||
if run.Model.Valid {
|
||||
runContext.Model = run.Model.String
|
||||
}
|
||||
return runContext
|
||||
}
|
||||
|
||||
func (p *Server) resolveManualTitleModel(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
@@ -2122,6 +2357,7 @@ func (p *Server) resolveManualTitleModel(
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
p.logger.Debug(ctx, "manual title preferred model unavailable",
|
||||
@@ -2154,6 +2390,7 @@ func (p *Server) resolveFallbackManualTitleModel(
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, database.ChatModelConfig{}, xerrors.Errorf(
|
||||
@@ -2688,6 +2925,7 @@ type Config struct {
|
||||
StartWorkspace chattool.StartWorkspaceFn
|
||||
Pubsub pubsub.Pubsub
|
||||
ProviderAPIKeys chatprovider.ProviderAPIKeys
|
||||
AlwaysEnableDebugLogs bool
|
||||
WebpushDispatcher webpush.Dispatcher
|
||||
UsageTracker *workspacestats.UsageTracker
|
||||
Clock quartz.Clock
|
||||
@@ -2734,6 +2972,14 @@ func New(cfg Config) *Server {
|
||||
workerID = uuid.New()
|
||||
}
|
||||
|
||||
debugSvc := chatdebug.NewService(
|
||||
cfg.Database,
|
||||
cfg.Logger.Named("chatdebug"),
|
||||
cfg.Pubsub,
|
||||
chatdebug.WithAlwaysEnable(cfg.AlwaysEnableDebugLogs),
|
||||
)
|
||||
debugSvc.SetStaleAfter(inFlightChatStaleAfter)
|
||||
|
||||
p := &Server{
|
||||
cancel: cancel,
|
||||
closed: make(chan struct{}),
|
||||
@@ -2749,6 +2995,7 @@ func New(cfg Config) *Server {
|
||||
pubsub: cfg.Pubsub,
|
||||
webpushDispatcher: cfg.WebpushDispatcher,
|
||||
providerAPIKeys: cfg.ProviderAPIKeys,
|
||||
debugSvc: debugSvc,
|
||||
pendingChatAcquireInterval: pendingChatAcquireInterval,
|
||||
maxChatsPerAcquire: maxChatsPerAcquire,
|
||||
inFlightChatStaleAfter: inFlightChatStaleAfter,
|
||||
@@ -2797,6 +3044,12 @@ func (p *Server) start(ctx context.Context) {
|
||||
// Recover stale chats on startup and periodically thereafter
|
||||
// to handle chats orphaned by crashed or redeployed workers.
|
||||
p.recoverStaleChats(ctx)
|
||||
if p.debugSvc != nil {
|
||||
_, err := p.debugSvc.FinalizeStale(ctx)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "failed to finalize stale chat debug rows", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Single heartbeat loop for all chats on this replica.
|
||||
go p.heartbeatLoop(ctx)
|
||||
@@ -2826,6 +3079,11 @@ func (p *Server) start(ctx context.Context) {
|
||||
p.processOnce(ctx)
|
||||
case <-staleTicker.C:
|
||||
p.recoverStaleChats(ctx)
|
||||
if p.debugSvc != nil {
|
||||
if _, err := p.debugSvc.FinalizeStale(ctx); err != nil {
|
||||
p.logger.Warn(ctx, "failed to finalize stale chat debug rows", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4337,6 +4595,10 @@ type runChatResult struct {
|
||||
PushSummaryModel fantasy.LanguageModel
|
||||
ProviderKeys chatprovider.ProviderAPIKeys
|
||||
PendingDynamicToolCalls []chatloop.PendingToolCall
|
||||
FallbackProvider string
|
||||
FallbackModel string
|
||||
TriggerMessageID int64
|
||||
HistoryTipMessageID int64
|
||||
}
|
||||
|
||||
func (p *Server) runChat(
|
||||
@@ -4347,11 +4609,14 @@ func (p *Server) runChat(
|
||||
) (runChatResult, error) {
|
||||
result := runChatResult{}
|
||||
var (
|
||||
model fantasy.LanguageModel
|
||||
modelConfig database.ChatModelConfig
|
||||
providerKeys chatprovider.ProviderAPIKeys
|
||||
callConfig codersdk.ChatModelCallConfig
|
||||
messages []database.ChatMessage
|
||||
model fantasy.LanguageModel
|
||||
modelConfig database.ChatModelConfig
|
||||
providerKeys chatprovider.ProviderAPIKeys
|
||||
callConfig codersdk.ChatModelCallConfig
|
||||
messages []database.ChatMessage
|
||||
debugEnabled bool
|
||||
debugProvider string
|
||||
debugModel string
|
||||
)
|
||||
|
||||
// Load MCP server configs and user tokens in parallel with
|
||||
@@ -4364,7 +4629,7 @@ func (p *Server) runChat(
|
||||
var g errgroup.Group
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
model, modelConfig, providerKeys, err = p.resolveChatModel(ctx, chat)
|
||||
model, modelConfig, providerKeys, debugEnabled, debugProvider, debugModel, err = p.resolveChatModel(ctx, chat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4422,23 +4687,31 @@ func (p *Server) runChat(
|
||||
chainInfo := resolveChainMode(messages)
|
||||
result.PushSummaryModel = model
|
||||
result.ProviderKeys = providerKeys
|
||||
result.FallbackProvider = modelConfig.Provider
|
||||
result.FallbackModel = modelConfig.Model
|
||||
// Fire title generation asynchronously so it doesn't block the
|
||||
// chat response. It uses a detached context so it can finish
|
||||
// even after the chat processing context is canceled.
|
||||
// Snapshot the original chat model so the goroutine doesn't
|
||||
// race with the model = cuModel reassignment below.
|
||||
// Snapshot ctx before the goroutine to avoid a data race with
|
||||
// the ctx = runCtx reassignment later in the main goroutine.
|
||||
titleModel := result.PushSummaryModel
|
||||
titleCtx := context.WithoutCancel(ctx)
|
||||
p.inflight.Add(1)
|
||||
go func() {
|
||||
defer p.inflight.Done()
|
||||
p.maybeGenerateChatTitle(
|
||||
context.WithoutCancel(ctx),
|
||||
titleCtx,
|
||||
chat,
|
||||
messages,
|
||||
modelConfig.Provider,
|
||||
modelConfig.Model,
|
||||
titleModel,
|
||||
providerKeys,
|
||||
generatedTitle,
|
||||
logger,
|
||||
p.debugSvc,
|
||||
)
|
||||
}()
|
||||
|
||||
@@ -4677,6 +4950,13 @@ func (p *Server) runChat(
|
||||
var finalAssistantText string
|
||||
var pendingDynamicCalls []chatloop.PendingToolCall
|
||||
|
||||
compactionHistoryTipMessageID := int64(0)
|
||||
if len(messages) > 0 {
|
||||
compactionHistoryTipMessageID = messages[len(messages)-1].ID
|
||||
}
|
||||
|
||||
var compactionOptions *chatloop.CompactionOptions
|
||||
|
||||
persistStep := func(persistCtx context.Context, step chatloop.PersistedStep) error {
|
||||
// If the chat context has been canceled, bail out before
|
||||
// inserting any messages. We distinguish the cause so that
|
||||
@@ -4889,6 +5169,12 @@ func (p *Server) runChat(
|
||||
for _, msg := range insertedMessages {
|
||||
p.publishMessage(chat.ID, msg)
|
||||
}
|
||||
if len(insertedMessages) > 0 {
|
||||
compactionHistoryTipMessageID = insertedMessages[len(insertedMessages)-1].ID
|
||||
if compactionOptions != nil {
|
||||
compactionOptions.HistoryTipMessageID = compactionHistoryTipMessageID
|
||||
}
|
||||
}
|
||||
|
||||
// Do NOT clear the stream buffer here. Cross-replica
|
||||
// relay subscribers may still need to snapshot buffered
|
||||
@@ -4918,9 +5204,10 @@ func (p *Server) runChat(
|
||||
effectiveThreshold = override
|
||||
thresholdSource = "user_override"
|
||||
}
|
||||
compactionOptions := &chatloop.CompactionOptions{
|
||||
ThresholdPercent: effectiveThreshold,
|
||||
ContextLimit: modelConfig.ContextLimit,
|
||||
compactionOptions = &chatloop.CompactionOptions{
|
||||
ThresholdPercent: effectiveThreshold,
|
||||
ContextLimit: modelConfig.ContextLimit,
|
||||
HistoryTipMessageID: compactionHistoryTipMessageID,
|
||||
Persist: func(
|
||||
persistCtx context.Context,
|
||||
result chatloop.CompactionResult,
|
||||
@@ -4956,7 +5243,16 @@ func (p *Server) runChat(
|
||||
|
||||
if isComputerUse {
|
||||
// Override model for computer use subagent.
|
||||
cuModel, cuErr := chatprovider.ModelFromConfig(
|
||||
resolvedProvider, resolvedModel, resolveErr := chatprovider.ResolveModelWithProviderHint(
|
||||
chattool.ComputerUseModelName,
|
||||
chattool.ComputerUseModelProvider,
|
||||
)
|
||||
if resolveErr != nil {
|
||||
return result, xerrors.Errorf("resolve computer use model metadata: %w", resolveErr)
|
||||
}
|
||||
cuModel, cuDebugEnabled, cuErr := p.newDebugAwareModelFromConfig(
|
||||
ctx,
|
||||
chat,
|
||||
chattool.ComputerUseModelProvider,
|
||||
chattool.ComputerUseModelName,
|
||||
providerKeys,
|
||||
@@ -4967,6 +5263,13 @@ func (p *Server) runChat(
|
||||
return result, xerrors.Errorf("resolve computer use model: %w", cuErr)
|
||||
}
|
||||
model = cuModel
|
||||
debugEnabled = cuDebugEnabled
|
||||
debugProvider = resolvedProvider
|
||||
debugModel = resolvedModel
|
||||
}
|
||||
if debugEnabled {
|
||||
compactionOptions.DebugSvc = p.debugSvc
|
||||
compactionOptions.ChatID = chat.ID
|
||||
}
|
||||
|
||||
tools := []fantasy.AgentTool{
|
||||
@@ -5183,7 +5486,132 @@ func (p *Server) runChat(
|
||||
)
|
||||
prompt = filterPromptForChainMode(prompt, chainInfo)
|
||||
}
|
||||
err = chatloop.Run(ctx, chatloop.RunOptions{
|
||||
|
||||
var loopErr error
|
||||
triggerMessageID := int64(0)
|
||||
var triggerLabel string
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == database.ChatMessageRoleUser {
|
||||
triggerMessageID = messages[i].ID
|
||||
if parts, parseErr := chatprompt.ParseContent(messages[i]); parseErr == nil {
|
||||
triggerLabel = contentBlocksToText(parts)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
historyTipMessageID := int64(0)
|
||||
if len(messages) > 0 {
|
||||
historyTipMessageID = messages[len(messages)-1].ID
|
||||
}
|
||||
result.TriggerMessageID = triggerMessageID
|
||||
result.HistoryTipMessageID = historyTipMessageID
|
||||
if debugEnabled {
|
||||
seedSummary := chatdebug.SeedSummary(
|
||||
chatdebug.TruncateLabel(triggerLabel, chatdebug.MaxLabelLength),
|
||||
)
|
||||
rootChatID := uuid.Nil
|
||||
if chat.RootChatID.Valid {
|
||||
rootChatID = chat.RootChatID.UUID
|
||||
}
|
||||
parentChatID := uuid.Nil
|
||||
if chat.ParentChatID.Valid {
|
||||
parentChatID = chat.ParentChatID.UUID
|
||||
}
|
||||
run, createRunErr := p.debugSvc.CreateRun(ctx, chatdebug.CreateRunParams{
|
||||
ChatID: chat.ID,
|
||||
RootChatID: rootChatID,
|
||||
ParentChatID: parentChatID,
|
||||
ModelConfigID: modelConfig.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: debugProvider,
|
||||
Model: debugModel,
|
||||
Summary: seedSummary,
|
||||
})
|
||||
if createRunErr != nil {
|
||||
logger.Warn(ctx, "failed to create chat debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(createRunErr),
|
||||
)
|
||||
} else {
|
||||
runCtx := chatdebug.ContextWithRun(ctx, &chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: chat.ID,
|
||||
RootChatID: rootChatID,
|
||||
ParentChatID: parentChatID,
|
||||
ModelConfigID: modelConfig.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Provider: debugProvider,
|
||||
Model: debugModel,
|
||||
})
|
||||
defer func() {
|
||||
panicValue := recover()
|
||||
var status chatdebug.Status
|
||||
switch {
|
||||
case panicValue != nil:
|
||||
status = chatdebug.StatusError
|
||||
case loopErr == nil:
|
||||
status = chatdebug.StatusCompleted
|
||||
case errors.Is(loopErr, chatloop.ErrInterrupted),
|
||||
errors.Is(loopErr, context.Canceled):
|
||||
status = chatdebug.StatusInterrupted
|
||||
case errors.Is(loopErr, chatloop.ErrDynamicToolCall):
|
||||
// Dynamic tool calls are a successful pause;
|
||||
// the run completed its model round-trip.
|
||||
status = chatdebug.StatusCompleted
|
||||
default:
|
||||
status = chatdebug.StatusError
|
||||
}
|
||||
|
||||
finalSummary := seedSummary
|
||||
aggCtx, aggCancel := context.WithTimeout(context.WithoutCancel(runCtx), 5*time.Second)
|
||||
defer aggCancel()
|
||||
if aggregated, aggErr := p.debugSvc.AggregateRunSummary(
|
||||
aggCtx,
|
||||
run.ID,
|
||||
seedSummary,
|
||||
); aggErr != nil {
|
||||
logger.Warn(ctx, "failed to aggregate debug run summary",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", run.ID),
|
||||
slog.Error(aggErr),
|
||||
)
|
||||
} else {
|
||||
finalSummary = aggregated
|
||||
}
|
||||
|
||||
updateRunCtx, updateRunCancel := context.WithTimeout(context.WithoutCancel(runCtx), 5*time.Second)
|
||||
defer updateRunCancel()
|
||||
if _, updateRunErr := p.debugSvc.UpdateRun(
|
||||
updateRunCtx,
|
||||
chatdebug.UpdateRunParams{
|
||||
ID: run.ID,
|
||||
ChatID: chat.ID,
|
||||
Status: status,
|
||||
Summary: finalSummary,
|
||||
FinishedAt: time.Now(),
|
||||
},
|
||||
); updateRunErr != nil {
|
||||
logger.Warn(ctx, "failed to finalize chat debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", run.ID),
|
||||
slog.Error(updateRunErr),
|
||||
)
|
||||
}
|
||||
chatdebug.CleanupStepCounter(run.ID)
|
||||
if panicValue != nil {
|
||||
panic(panicValue)
|
||||
}
|
||||
}()
|
||||
ctx = runCtx
|
||||
}
|
||||
}
|
||||
|
||||
loopErr = chatloop.Run(ctx, chatloop.RunOptions{
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools, MaxSteps: maxChatSteps,
|
||||
@@ -5215,6 +5643,13 @@ func (p *Server) runChat(
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("reload chat messages: %w", err)
|
||||
}
|
||||
compactionHistoryTipMessageID = 0
|
||||
if len(reloadedMsgs) > 0 {
|
||||
compactionHistoryTipMessageID = reloadedMsgs[len(reloadedMsgs)-1].ID
|
||||
}
|
||||
if compactionOptions != nil {
|
||||
compactionOptions.HistoryTipMessageID = compactionHistoryTipMessageID
|
||||
}
|
||||
reloadedPrompt, err := chatprompt.ConvertMessagesWithFiles(reloadCtx, reloadedMsgs, p.chatFileResolver(), logger)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert reloaded messages: %w", err)
|
||||
@@ -5271,7 +5706,7 @@ func (p *Server) runChat(
|
||||
p.logger.Warn(ctx, "failed to persist interrupted chat step", slog.Error(err))
|
||||
},
|
||||
})
|
||||
if errors.Is(err, chatloop.ErrDynamicToolCall) {
|
||||
if errors.Is(loopErr, chatloop.ErrDynamicToolCall) {
|
||||
// The stream event is published in processChat's
|
||||
// defer after the DB status transitions to
|
||||
// requires_action, preventing a race where a fast
|
||||
@@ -5280,9 +5715,9 @@ func (p *Server) runChat(
|
||||
result.PendingDynamicToolCalls = pendingDynamicCalls
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
classified := chaterror.Classify(err).WithProvider(model.Provider())
|
||||
return result, chaterror.WithClassification(err, classified)
|
||||
if loopErr != nil {
|
||||
classified := chaterror.Classify(loopErr).WithProvider(model.Provider())
|
||||
return result, chaterror.WithClassification(loopErr, classified)
|
||||
}
|
||||
result.FinalAssistantText = finalAssistantText
|
||||
return result, nil
|
||||
@@ -5446,10 +5881,15 @@ func (p *Server) persistChatContextSummary(
|
||||
func (p *Server) resolveChatModel(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
) (fantasy.LanguageModel, database.ChatModelConfig, chatprovider.ProviderAPIKeys, error) {
|
||||
var dbConfig database.ChatModelConfig
|
||||
var keys chatprovider.ProviderAPIKeys
|
||||
|
||||
) (
|
||||
model fantasy.LanguageModel,
|
||||
dbConfig database.ChatModelConfig,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
debugEnabled bool,
|
||||
resolvedProvider string,
|
||||
resolvedModel string,
|
||||
err error,
|
||||
) {
|
||||
var g errgroup.Group
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
@@ -5468,19 +5908,34 @@ func (p *Server) resolveChatModel(
|
||||
return nil
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, err
|
||||
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, false, "", "", err
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig(
|
||||
dbConfig.Provider, dbConfig.Model, keys, chatprovider.UserAgent(),
|
||||
resolvedProvider, resolvedModel, err = chatprovider.ResolveModelWithProviderHint(
|
||||
dbConfig.Model,
|
||||
dbConfig.Provider,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, false, "", "", xerrors.Errorf(
|
||||
"resolve model metadata: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
model, debugEnabled, err = p.newDebugAwareModelFromConfig(
|
||||
ctx,
|
||||
chat,
|
||||
dbConfig.Provider,
|
||||
dbConfig.Model,
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, xerrors.Errorf(
|
||||
return nil, database.ChatModelConfig{}, chatprovider.ProviderAPIKeys{}, false, "", "", xerrors.Errorf(
|
||||
"create model: %w", err,
|
||||
)
|
||||
}
|
||||
return model, dbConfig, keys, nil
|
||||
return model, dbConfig, keys, debugEnabled, resolvedProvider, resolvedModel, nil
|
||||
}
|
||||
|
||||
func (p *Server) resolveUserProviderAPIKeys(
|
||||
@@ -6162,9 +6617,14 @@ func (p *Server) maybeSendPushNotification(
|
||||
pushCtx,
|
||||
chat,
|
||||
assistantText,
|
||||
runResult.FallbackProvider,
|
||||
runResult.FallbackModel,
|
||||
runResult.PushSummaryModel,
|
||||
runResult.ProviderKeys,
|
||||
logger,
|
||||
p.debugSvc,
|
||||
runResult.TriggerMessageID,
|
||||
runResult.HistoryTipMessageID,
|
||||
); summary != "" {
|
||||
pushBody = summary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||||
)
|
||||
|
||||
func (p *Server) newDebugAwareModelFromConfig(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
providerHint string,
|
||||
modelName string,
|
||||
providerKeys chatprovider.ProviderAPIKeys,
|
||||
userAgent string,
|
||||
extraHeaders map[string]string,
|
||||
) (fantasy.LanguageModel, bool, error) {
|
||||
provider, resolvedModel, err := chatprovider.ResolveModelWithProviderHint(modelName, providerHint)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
debugEnabled := p.debugSvc != nil && p.debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
||||
|
||||
var httpClient *http.Client
|
||||
if debugEnabled {
|
||||
httpClient = &http.Client{Transport: &chatdebug.RecordingTransport{}}
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig(
|
||||
provider,
|
||||
resolvedModel,
|
||||
providerKeys,
|
||||
userAgent,
|
||||
extraHeaders,
|
||||
httpClient,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, debugEnabled, err
|
||||
}
|
||||
if model == nil {
|
||||
return nil, debugEnabled, xerrors.Errorf(
|
||||
"create model for %s/%s returned nil",
|
||||
provider,
|
||||
resolvedModel,
|
||||
)
|
||||
}
|
||||
if !debugEnabled {
|
||||
return model, false, nil
|
||||
}
|
||||
|
||||
return chatdebug.WrapModel(model, p.debugSvc, chatdebug.RecorderOptions{
|
||||
ChatID: chat.ID,
|
||||
OwnerID: chat.OwnerID,
|
||||
Provider: provider,
|
||||
Model: resolvedModel,
|
||||
}), true, nil
|
||||
}
|
||||
@@ -33,6 +33,15 @@ import (
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// TestWaitForActiveChatStop and TestWaitForActiveChatStop_WaitsForReplacementRun
|
||||
// were removed along with the process-local activeChats mechanism.
|
||||
// Debug cleanup is now best-effort; stale finalization handles orphaned rows.
|
||||
|
||||
// TestArchiveChatWaitsForActiveChatStop and
|
||||
// TestArchiveChatWaitsForEveryInterruptedChat were removed along with
|
||||
// the process-local activeChats mechanism. Archive cleanup is now
|
||||
// best-effort; stale finalization handles any orphaned rows.
|
||||
|
||||
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ func (s *Service) CreateStep(
|
||||
}
|
||||
|
||||
return database.ChatDebugStep{}, xerrors.Errorf(
|
||||
"failed to create debug step after %d attempts (run_id=%s)",
|
||||
"chatdebug: failed to create step after %d retries (run %s)",
|
||||
maxCreateStepRetries, params.RunID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
||||
@@ -368,7 +369,8 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
}
|
||||
|
||||
var result stepResult
|
||||
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
||||
stepCtx := chatdebug.ReuseStep(ctx)
|
||||
err := chatretry.Retry(stepCtx, func(retryCtx context.Context) error {
|
||||
attempt, streamErr := guardedStream(
|
||||
retryCtx,
|
||||
opts.Model.Provider(),
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -46,6 +48,9 @@ type CompactionOptions struct {
|
||||
SystemSummaryPrefix string
|
||||
Timeout time.Duration
|
||||
Persist func(context.Context, CompactionResult) error
|
||||
DebugSvc *chatdebug.Service
|
||||
ChatID uuid.UUID
|
||||
HistoryTipMessageID int64
|
||||
|
||||
// ToolCallID and ToolName identify the synthetic tool call
|
||||
// used to represent compaction in the message stream.
|
||||
@@ -269,6 +274,92 @@ func shouldCompact(contextTokens, contextLimit int64, thresholdPercent int32) (f
|
||||
return usagePercent, usagePercent >= float64(thresholdPercent)
|
||||
}
|
||||
|
||||
func startCompactionDebugRun(
|
||||
ctx context.Context,
|
||||
options CompactionOptions,
|
||||
) (context.Context, func(error)) {
|
||||
if options.DebugSvc == nil || options.ChatID == uuid.Nil {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
parentRun, ok := chatdebug.RunFromContext(ctx)
|
||||
if !ok {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
historyTipMessageID := options.HistoryTipMessageID
|
||||
if historyTipMessageID == 0 {
|
||||
historyTipMessageID = parentRun.HistoryTipMessageID
|
||||
}
|
||||
|
||||
run, err := options.DebugSvc.CreateRun(ctx, chatdebug.CreateRunParams{
|
||||
ChatID: options.ChatID,
|
||||
RootChatID: parentRun.RootChatID,
|
||||
ParentChatID: parentRun.ParentChatID,
|
||||
ModelConfigID: parentRun.ModelConfigID,
|
||||
TriggerMessageID: parentRun.TriggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindCompaction,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: parentRun.Provider,
|
||||
Model: parentRun.Model,
|
||||
})
|
||||
if err != nil {
|
||||
// Debug instrumentation must not surface as a compaction failure.
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
compactionCtx := chatdebug.ContextWithRun(ctx, &chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: options.ChatID,
|
||||
RootChatID: parentRun.RootChatID,
|
||||
ParentChatID: parentRun.ParentChatID,
|
||||
ModelConfigID: parentRun.ModelConfigID,
|
||||
TriggerMessageID: parentRun.TriggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindCompaction,
|
||||
Provider: parentRun.Provider,
|
||||
Model: parentRun.Model,
|
||||
})
|
||||
|
||||
return compactionCtx, func(runErr error) {
|
||||
status := chatdebug.StatusCompleted
|
||||
if runErr != nil {
|
||||
status = chatdebug.StatusError
|
||||
if xerrors.Is(runErr, ErrInterrupted) || xerrors.Is(runErr, context.Canceled) {
|
||||
status = chatdebug.StatusInterrupted
|
||||
}
|
||||
}
|
||||
finalizeCtx, finalizeCancel := context.WithTimeout(
|
||||
context.WithoutCancel(compactionCtx),
|
||||
5*time.Second,
|
||||
)
|
||||
defer finalizeCancel()
|
||||
|
||||
finalSummary := map[string]any(nil)
|
||||
if aggregated, aggErr := options.DebugSvc.AggregateRunSummary(
|
||||
finalizeCtx,
|
||||
run.ID,
|
||||
nil,
|
||||
); aggErr == nil {
|
||||
finalSummary = aggregated
|
||||
}
|
||||
|
||||
// Debug instrumentation must not surface as a compaction failure.
|
||||
_, _ = options.DebugSvc.UpdateRun(
|
||||
finalizeCtx,
|
||||
chatdebug.UpdateRunParams{
|
||||
ID: run.ID,
|
||||
ChatID: options.ChatID,
|
||||
Status: status,
|
||||
Summary: finalSummary,
|
||||
FinishedAt: time.Now(),
|
||||
},
|
||||
)
|
||||
chatdebug.CleanupStepCounter(run.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// generateCompactionSummary asks the model to summarize the
|
||||
// conversation so far. The provided messages should contain the
|
||||
// complete history (system prompt, user/assistant turns, tool
|
||||
@@ -279,7 +370,7 @@ func generateCompactionSummary(
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
options CompactionOptions,
|
||||
) (string, error) {
|
||||
) (summary string, err error) {
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+1)
|
||||
summaryPrompt = append(summaryPrompt, messages...)
|
||||
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
||||
@@ -293,6 +384,11 @@ func generateCompactionSummary(
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
summaryCtx, finishDebugRun := startCompactionDebugRun(summaryCtx, options)
|
||||
defer func() {
|
||||
finishDebugRun(err)
|
||||
}()
|
||||
|
||||
response, err := model.Generate(summaryCtx, fantasy.Call{
|
||||
Prompt: summaryPrompt,
|
||||
ToolChoice: &toolChoice,
|
||||
|
||||
@@ -2,17 +2,168 @@ package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestStartCompactionDebugRun_DoesNotReportDebugErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newParentContext := func(chatID uuid.UUID) context.Context {
|
||||
return chatdebug.ContextWithRun(context.Background(), &chatdebug.RunContext{
|
||||
RunID: uuid.New(),
|
||||
ChatID: chatID,
|
||||
RootChatID: uuid.New(),
|
||||
ParentChatID: uuid.New(),
|
||||
ModelConfigID: uuid.New(),
|
||||
TriggerMessageID: 41,
|
||||
HistoryTipMessageID: 42,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Provider: "fake-provider",
|
||||
Model: "fake-model",
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("CreateRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
reportedErr := make(chan error, 1)
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{}, xerrors.New("insert compaction debug run"))
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
OnError: func(err error) {
|
||||
reportedErr <- err
|
||||
},
|
||||
})
|
||||
require.Same(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
select {
|
||||
case err := <-reportedErr:
|
||||
t.Fatalf("unexpected OnError callback: %v", err)
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FinalizeRunAggregatesSummary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
runID := uuid.New()
|
||||
usageJSON, err := json.Marshal(fantasy.Usage{InputTokens: 7, OutputTokens: 3})
|
||||
require.NoError(t, err)
|
||||
attemptsJSON, err := json.Marshal([]chatdebug.Attempt{{
|
||||
Status: "completed",
|
||||
Method: "POST",
|
||||
Path: "/v1/messages",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{ //nolint:exhaustruct // Test only needs IDs.
|
||||
ID: runID,
|
||||
ChatID: chatID,
|
||||
}, nil)
|
||||
db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return([]database.ChatDebugStep{{
|
||||
ID: uuid.New(),
|
||||
RunID: runID,
|
||||
ChatID: chatID,
|
||||
Status: string(chatdebug.StatusCompleted),
|
||||
Usage: pqtype.NullRawMessage{RawMessage: usageJSON, Valid: true},
|
||||
Attempts: attemptsJSON,
|
||||
}}, nil)
|
||||
db.EXPECT().UpdateChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.UpdateChatDebugRunParams{}),
|
||||
).DoAndReturn(func(_ context.Context, params database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
|
||||
require.Equal(t, chatID, params.ChatID)
|
||||
require.Equal(t, runID, params.ID)
|
||||
require.True(t, params.Summary.Valid)
|
||||
require.JSONEq(t, `{"endpoint_label":"POST /v1/messages","step_count":1,"total_input_tokens":7,"total_output_tokens":3}`,
|
||||
string(params.Summary.RawMessage))
|
||||
return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil
|
||||
})
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
})
|
||||
require.NotSame(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
})
|
||||
|
||||
t.Run("FinalizeRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
reportedErr := make(chan error, 1)
|
||||
runID := uuid.New()
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{ //nolint:exhaustruct // Test only needs IDs.
|
||||
ID: runID,
|
||||
ChatID: chatID,
|
||||
}, nil)
|
||||
db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, xerrors.New("aggregate compaction debug run"))
|
||||
db.EXPECT().UpdateChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.UpdateChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{}, xerrors.New("finalize compaction debug run"))
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
OnError: func(err error) {
|
||||
reportedErr <- err
|
||||
},
|
||||
})
|
||||
require.NotSame(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
select {
|
||||
case err := <-reportedErr:
|
||||
t.Fatalf("unexpected OnError callback: %v", err)
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRun_Compaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package chatprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -1114,13 +1115,15 @@ func CoderHeadersFromIDs(
|
||||
// language model client using the provided provider credentials. The
|
||||
// userAgent is sent as the User-Agent header on every outgoing LLM
|
||||
// API request. extraHeaders, when non-nil, are sent as additional
|
||||
// HTTP headers on every request.
|
||||
// HTTP headers on every request. httpClient, when non-nil, is used for
|
||||
// all provider HTTP requests.
|
||||
func ModelFromConfig(
|
||||
providerHint string,
|
||||
modelName string,
|
||||
providerKeys ProviderAPIKeys,
|
||||
userAgent string,
|
||||
extraHeaders map[string]string,
|
||||
httpClient *http.Client,
|
||||
) (fantasy.LanguageModel, error) {
|
||||
provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint)
|
||||
if err != nil {
|
||||
@@ -1146,6 +1149,9 @@ func ModelFromConfig(
|
||||
if baseURL != "" {
|
||||
options = append(options, fantasyanthropic.WithBaseURL(baseURL))
|
||||
}
|
||||
if httpClient != nil {
|
||||
options = append(options, fantasyanthropic.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyanthropic.New(options...)
|
||||
case fantasyazure.Name:
|
||||
if baseURL == "" {
|
||||
@@ -1160,6 +1166,9 @@ func ModelFromConfig(
|
||||
if len(extraHeaders) > 0 {
|
||||
azureOpts = append(azureOpts, fantasyazure.WithHeaders(extraHeaders))
|
||||
}
|
||||
if httpClient != nil {
|
||||
azureOpts = append(azureOpts, fantasyazure.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyazure.New(azureOpts...)
|
||||
case fantasybedrock.Name:
|
||||
bedrockOpts := []fantasybedrock.Option{
|
||||
@@ -1169,6 +1178,9 @@ func ModelFromConfig(
|
||||
if len(extraHeaders) > 0 {
|
||||
bedrockOpts = append(bedrockOpts, fantasybedrock.WithHeaders(extraHeaders))
|
||||
}
|
||||
if httpClient != nil {
|
||||
bedrockOpts = append(bedrockOpts, fantasybedrock.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasybedrock.New(bedrockOpts...)
|
||||
case fantasygoogle.Name:
|
||||
options := []fantasygoogle.Option{
|
||||
@@ -1181,6 +1193,9 @@ func ModelFromConfig(
|
||||
if baseURL != "" {
|
||||
options = append(options, fantasygoogle.WithBaseURL(baseURL))
|
||||
}
|
||||
if httpClient != nil {
|
||||
options = append(options, fantasygoogle.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasygoogle.New(options...)
|
||||
case fantasyopenai.Name:
|
||||
options := []fantasyopenai.Option{
|
||||
@@ -1194,6 +1209,9 @@ func ModelFromConfig(
|
||||
if baseURL != "" {
|
||||
options = append(options, fantasyopenai.WithBaseURL(baseURL))
|
||||
}
|
||||
if httpClient != nil {
|
||||
options = append(options, fantasyopenai.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyopenai.New(options...)
|
||||
case fantasyopenaicompat.Name:
|
||||
options := []fantasyopenaicompat.Option{
|
||||
@@ -1206,6 +1224,9 @@ func ModelFromConfig(
|
||||
if baseURL != "" {
|
||||
options = append(options, fantasyopenaicompat.WithBaseURL(baseURL))
|
||||
}
|
||||
if httpClient != nil {
|
||||
options = append(options, fantasyopenaicompat.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyopenaicompat.New(options...)
|
||||
case fantasyopenrouter.Name:
|
||||
routerOpts := []fantasyopenrouter.Option{
|
||||
@@ -1215,6 +1236,9 @@ func ModelFromConfig(
|
||||
if len(extraHeaders) > 0 {
|
||||
routerOpts = append(routerOpts, fantasyopenrouter.WithHeaders(extraHeaders))
|
||||
}
|
||||
if httpClient != nil {
|
||||
routerOpts = append(routerOpts, fantasyopenrouter.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyopenrouter.New(routerOpts...)
|
||||
case fantasyvercel.Name:
|
||||
options := []fantasyvercel.Option{
|
||||
@@ -1227,6 +1251,9 @@ func ModelFromConfig(
|
||||
if baseURL != "" {
|
||||
options = append(options, fantasyvercel.WithBaseURL(baseURL))
|
||||
}
|
||||
if httpClient != nil {
|
||||
options = append(options, fantasyvercel.WithHTTPClient(httpClient))
|
||||
}
|
||||
providerClient, err = fantasyvercel.New(options...)
|
||||
default:
|
||||
return nil, xerrors.Errorf("unsupported model provider %q", provider)
|
||||
|
||||
@@ -181,6 +181,12 @@ func TestResolveUserProviderKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestReasoningEffortFromChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -777,7 +783,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -808,7 +814,7 @@ func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers)
|
||||
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -844,7 +850,7 @@ func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
@@ -859,6 +865,48 @@ func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
|
||||
_ = testutil.TryReceive(ctx, t, called)
|
||||
}
|
||||
|
||||
func TestModelFromConfig_HTTPClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
called := make(chan struct{})
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
assert.Equal(t, "true", req.Header.Get("X-Test-Transport"))
|
||||
close(called)
|
||||
return chattest.OpenAINonStreamingResponse("hello")
|
||||
})
|
||||
|
||||
keys := chatprovider.ProviderAPIKeys{
|
||||
ByProvider: map[string]string{"openai": "test-key"},
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
client := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
cloned := req.Clone(req.Context())
|
||||
cloned.Header = req.Header.Clone()
|
||||
cloned.Header.Set("X-Test-Transport", "true")
|
||||
return http.DefaultTransport.RoundTrip(cloned)
|
||||
})}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig(
|
||||
"openai",
|
||||
"gpt-4",
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
nil,
|
||||
client,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(ctx, fantasy.Call{
|
||||
Prompt: []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_ = testutil.TryReceive(ctx, t, called)
|
||||
}
|
||||
|
||||
func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestModelFromConfig_UserAgent(t *testing.T) {
|
||||
BaseURLByProvider: map[string]string{"openai": serverURL},
|
||||
}
|
||||
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil)
|
||||
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, expectedUA, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make a real call so Fantasy sends an HTTP request to the
|
||||
|
||||
+317
-11
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
||||
@@ -105,35 +107,173 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
messages []database.ChatMessage,
|
||||
fallbackProvider string,
|
||||
fallbackModelName string,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
generatedTitle *generatedChatTitle,
|
||||
logger slog.Logger,
|
||||
debugSvc *chatdebug.Service,
|
||||
) {
|
||||
input, ok := titleInput(chat, messages)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
||||
|
||||
titleCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type candidateDescriptor struct {
|
||||
provider string
|
||||
model string
|
||||
lm fantasy.LanguageModel
|
||||
}
|
||||
|
||||
// Build candidate list: preferred lightweight models first,
|
||||
// then the user's chat model as last resort.
|
||||
candidates := make([]fantasy.LanguageModel, 0, len(preferredTitleModels)+1)
|
||||
candidates := make([]candidateDescriptor, 0, len(preferredTitleModels)+1)
|
||||
for _, c := range preferredTitleModels {
|
||||
m, err := chatprovider.ModelFromConfig(
|
||||
c.provider, c.model, keys, chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
candidates = append(candidates, m)
|
||||
candidates = append(candidates, candidateDescriptor{
|
||||
provider: c.provider,
|
||||
model: c.model,
|
||||
lm: m,
|
||||
})
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, fallbackModel)
|
||||
candidates = append(candidates, candidateDescriptor{
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModelName,
|
||||
lm: fallbackModel,
|
||||
})
|
||||
|
||||
var historyTipMessageID int64
|
||||
if len(messages) > 0 {
|
||||
historyTipMessageID = messages[len(messages)-1].ID
|
||||
}
|
||||
|
||||
var triggerMessageID int64
|
||||
for _, message := range messages {
|
||||
if message.Role == database.ChatMessageRoleUser {
|
||||
triggerMessageID = message.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
seedSummary := chatdebug.SeedSummary(
|
||||
chatdebug.TruncateLabel(input, chatdebug.MaxLabelLength),
|
||||
)
|
||||
|
||||
var lastErr error
|
||||
for _, model := range candidates {
|
||||
title, err := generateTitle(titleCtx, model, input)
|
||||
for _, candidate := range candidates {
|
||||
candidateModel := candidate.lm
|
||||
candidateCtx := titleCtx
|
||||
var debugRun *database.ChatDebugRun
|
||||
if debugEnabled {
|
||||
run, err := debugSvc.CreateRun(titleCtx, chatdebug.CreateRunParams{
|
||||
ChatID: chat.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindTitleGeneration,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: candidate.provider,
|
||||
Model: candidate.model,
|
||||
Summary: seedSummary,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to create title debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", candidate.provider),
|
||||
slog.F("model", candidate.model),
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
debugRun = &run
|
||||
candidateCtx = chatdebug.ContextWithRun(
|
||||
candidateCtx,
|
||||
&chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: chat.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindTitleGeneration,
|
||||
Provider: candidate.provider,
|
||||
Model: candidate.model,
|
||||
},
|
||||
)
|
||||
debugModel, err := newQuickgenDebugModel(
|
||||
chat,
|
||||
keys,
|
||||
debugSvc,
|
||||
candidate.provider,
|
||||
candidate.model,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to build title debug model",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", candidate.provider),
|
||||
slog.F("model", candidate.model),
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
candidateModel = debugModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title, err := generateTitle(candidateCtx, candidateModel, input)
|
||||
if debugRun != nil {
|
||||
status := chatdebug.StatusCompleted
|
||||
switch {
|
||||
case err == nil:
|
||||
// keep completed
|
||||
case errors.Is(err, context.Canceled):
|
||||
status = chatdebug.StatusInterrupted
|
||||
default:
|
||||
status = chatdebug.StatusError
|
||||
}
|
||||
finalizeCtx, finalizeCancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), 10*time.Second,
|
||||
)
|
||||
finalSummary := seedSummary
|
||||
if aggregated, aggErr := debugSvc.AggregateRunSummary(
|
||||
finalizeCtx,
|
||||
debugRun.ID,
|
||||
seedSummary,
|
||||
); aggErr != nil {
|
||||
logger.Warn(ctx, "failed to aggregate debug run summary",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(aggErr),
|
||||
)
|
||||
} else {
|
||||
finalSummary = aggregated
|
||||
}
|
||||
if _, updateErr := debugSvc.UpdateRun(
|
||||
finalizeCtx,
|
||||
chatdebug.UpdateRunParams{
|
||||
ID: debugRun.ID,
|
||||
ChatID: chat.ID,
|
||||
Status: status,
|
||||
Summary: finalSummary,
|
||||
FinishedAt: time.Now(),
|
||||
},
|
||||
); updateErr != nil {
|
||||
logger.Warn(ctx, "failed to finalize title debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(updateErr),
|
||||
)
|
||||
}
|
||||
chatdebug.CleanupStepCounter(debugRun.ID)
|
||||
finalizeCancel()
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug(ctx, "title model candidate failed",
|
||||
@@ -171,6 +311,41 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
}
|
||||
}
|
||||
|
||||
func newQuickgenDebugModel(
|
||||
chat database.Chat,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
debugSvc *chatdebug.Service,
|
||||
provider string,
|
||||
model string,
|
||||
) (fantasy.LanguageModel, error) {
|
||||
httpClient := &http.Client{Transport: &chatdebug.RecordingTransport{}}
|
||||
debugModel, err := chatprovider.ModelFromConfig(
|
||||
provider,
|
||||
model,
|
||||
keys,
|
||||
chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
httpClient,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if debugModel == nil {
|
||||
return nil, xerrors.Errorf(
|
||||
"create model for %s/%s returned nil",
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
return chatdebug.WrapModel(debugModel, debugSvc, chatdebug.RecorderOptions{
|
||||
ChatID: chat.ID,
|
||||
OwnerID: chat.OwnerID,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// generateTitle calls the model with a title-generation system prompt
|
||||
// and returns the normalized result. It retries transient LLM errors
|
||||
// (rate limits, overloaded, etc.) with exponential backoff.
|
||||
@@ -571,30 +746,160 @@ func generatePushSummary(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
assistantText string,
|
||||
fallbackProvider string,
|
||||
fallbackModelName string,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
logger slog.Logger,
|
||||
debugSvc *chatdebug.Service,
|
||||
triggerMessageID int64,
|
||||
historyTipMessageID int64,
|
||||
) string {
|
||||
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
||||
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
assistantText = truncateRunes(assistantText, maxConversationContextRunes)
|
||||
input := "Chat title: " + chat.Title + "\n\nAgent's last message:\n" + assistantText
|
||||
|
||||
candidates := make([]fantasy.LanguageModel, 0, len(preferredTitleModels)+1)
|
||||
type candidateDescriptor struct {
|
||||
provider string
|
||||
model string
|
||||
lm fantasy.LanguageModel
|
||||
}
|
||||
|
||||
candidates := make([]candidateDescriptor, 0, len(preferredTitleModels)+1)
|
||||
for _, c := range preferredTitleModels {
|
||||
m, err := chatprovider.ModelFromConfig(
|
||||
c.provider, c.model, keys, chatprovider.UserAgent(),
|
||||
chatprovider.CoderHeaders(chat),
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
candidates = append(candidates, m)
|
||||
candidates = append(candidates, candidateDescriptor{
|
||||
provider: c.provider,
|
||||
model: c.model,
|
||||
lm: m,
|
||||
})
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, fallbackModel)
|
||||
candidates = append(candidates, candidateDescriptor{
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModelName,
|
||||
lm: fallbackModel,
|
||||
})
|
||||
|
||||
for _, model := range candidates {
|
||||
summary, err := generateShortText(summaryCtx, model, pushSummaryPrompt, input)
|
||||
pushSeedSummary := chatdebug.SeedSummary("Push summary")
|
||||
|
||||
for _, candidate := range candidates {
|
||||
candidateModel := candidate.lm
|
||||
candidateCtx := summaryCtx
|
||||
var debugRun *database.ChatDebugRun
|
||||
if debugEnabled {
|
||||
run, err := debugSvc.CreateRun(summaryCtx, chatdebug.CreateRunParams{
|
||||
ChatID: chat.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindQuickgen,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: candidate.provider,
|
||||
Model: candidate.model,
|
||||
Summary: pushSeedSummary,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to create quickgen debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", candidate.provider),
|
||||
slog.F("model", candidate.model),
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
debugRun = &run
|
||||
candidateCtx = chatdebug.ContextWithRun(
|
||||
candidateCtx,
|
||||
&chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: chat.ID,
|
||||
TriggerMessageID: triggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindQuickgen,
|
||||
Provider: candidate.provider,
|
||||
Model: candidate.model,
|
||||
},
|
||||
)
|
||||
debugModel, err := newQuickgenDebugModel(
|
||||
chat,
|
||||
keys,
|
||||
debugSvc,
|
||||
candidate.provider,
|
||||
candidate.model,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to build quickgen debug model",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", candidate.provider),
|
||||
slog.F("model", candidate.model),
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
candidateModel = debugModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := generateShortText(
|
||||
candidateCtx,
|
||||
candidateModel,
|
||||
pushSummaryPrompt,
|
||||
input,
|
||||
)
|
||||
if debugRun != nil {
|
||||
status := chatdebug.StatusCompleted
|
||||
switch {
|
||||
case err == nil:
|
||||
// keep completed
|
||||
case errors.Is(err, context.Canceled):
|
||||
status = chatdebug.StatusInterrupted
|
||||
default:
|
||||
status = chatdebug.StatusError
|
||||
}
|
||||
finalizeCtx, finalizeCancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), 10*time.Second,
|
||||
)
|
||||
finalSummary := pushSeedSummary
|
||||
if aggregated, aggErr := debugSvc.AggregateRunSummary(
|
||||
finalizeCtx,
|
||||
debugRun.ID,
|
||||
pushSeedSummary,
|
||||
); aggErr != nil {
|
||||
logger.Warn(ctx, "failed to aggregate debug run summary",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(aggErr),
|
||||
)
|
||||
} else {
|
||||
finalSummary = aggregated
|
||||
}
|
||||
if _, updateErr := debugSvc.UpdateRun(
|
||||
finalizeCtx,
|
||||
chatdebug.UpdateRunParams{
|
||||
ID: debugRun.ID,
|
||||
ChatID: chat.ID,
|
||||
Status: status,
|
||||
Summary: finalSummary,
|
||||
FinishedAt: time.Now(),
|
||||
},
|
||||
); updateErr != nil {
|
||||
logger.Warn(ctx, "failed to finalize quickgen debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("run_id", debugRun.ID),
|
||||
slog.Error(updateErr),
|
||||
)
|
||||
}
|
||||
chatdebug.CleanupStepCounter(debugRun.ID)
|
||||
finalizeCancel()
|
||||
}
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "push summary model candidate failed",
|
||||
slog.Error(err),
|
||||
@@ -610,7 +915,8 @@ func generatePushSummary(
|
||||
|
||||
// generateShortText calls a model with a system prompt and user
|
||||
// input, returning a cleaned-up short text response. It reuses the
|
||||
// same retry logic as title generation.
|
||||
// same retry logic as title generation. Retries can therefore
|
||||
// produce multiple debug steps for a single quickgen run.
|
||||
func generateShortText(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
|
||||
@@ -2210,6 +2210,92 @@ 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 debug run for a chat.
|
||||
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)
|
||||
|
||||
+5
-1
@@ -7,7 +7,11 @@
|
||||
"./test/**/*.ts",
|
||||
"./e2e/**/*.ts"
|
||||
],
|
||||
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
|
||||
"ignore": [
|
||||
"**/*Generated.ts",
|
||||
"src/api/chatModelOptions.ts",
|
||||
"src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts"
|
||||
],
|
||||
"ignoreBinaries": ["protoc"],
|
||||
"ignoreDependencies": [
|
||||
"@babel/plugin-syntax-typescript",
|
||||
|
||||
@@ -3242,6 +3242,58 @@ class ExperimentalApiMethods {
|
||||
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
|
||||
};
|
||||
|
||||
getChatDebugLogging =
|
||||
async (): Promise<TypesGen.ChatDebugLoggingAdminSettings> => {
|
||||
const response =
|
||||
await this.axios.get<TypesGen.ChatDebugLoggingAdminSettings>(
|
||||
"/api/experimental/chats/config/debug-logging",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatDebugLogging = async (
|
||||
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put("/api/experimental/chats/config/debug-logging", req);
|
||||
};
|
||||
|
||||
getUserChatDebugLogging =
|
||||
async (): Promise<TypesGen.UserChatDebugLoggingSettings> => {
|
||||
const response =
|
||||
await this.axios.get<TypesGen.UserChatDebugLoggingSettings>(
|
||||
"/api/experimental/chats/config/user-debug-logging",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateUserChatDebugLogging = async (
|
||||
req: TypesGen.UpdateUserChatDebugLoggingRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put(
|
||||
"/api/experimental/chats/config/user-debug-logging",
|
||||
req,
|
||||
);
|
||||
};
|
||||
|
||||
getChatDebugRuns = async (
|
||||
chatId: string,
|
||||
): Promise<TypesGen.ChatDebugRunSummary[]> => {
|
||||
const response = await this.axios.get<TypesGen.ChatDebugRunSummary[]>(
|
||||
`/api/experimental/chats/${chatId}/debug/runs`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatDebugRun = async (
|
||||
chatId: string,
|
||||
runId: string,
|
||||
): Promise<TypesGen.ChatDebugRun> => {
|
||||
const response = await this.axios.get<TypesGen.ChatDebugRun>(
|
||||
`/api/experimental/chats/${chatId}/debug/runs/${runId}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatDesktopEnabled =
|
||||
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
|
||||
const response =
|
||||
|
||||
@@ -679,6 +679,8 @@ describe("mutation invalidation scope", () => {
|
||||
queryClient.setQueryData(chatKey(chatId), makeChat(chatId));
|
||||
// Messages: ["chats", chatId, "messages"]
|
||||
queryClient.setQueryData(chatMessagesKey(chatId), []);
|
||||
// Debug runs: ["chats", chatId, "debug-runs"]
|
||||
queryClient.setQueryData(chatDebugRunsTestKey(chatId), []);
|
||||
// Diff contents: ["chats", chatId, "diff-contents"]
|
||||
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
|
||||
// Cost summary: ["chats", "costSummary", "me", undefined]
|
||||
@@ -688,6 +690,9 @@ describe("mutation invalidation scope", () => {
|
||||
);
|
||||
};
|
||||
|
||||
const chatDebugRunsTestKey = (chatId: string) =>
|
||||
["chats", chatId, "debug-runs"] as const;
|
||||
|
||||
/** Keys that should NEVER be invalidated by chat message mutations
|
||||
* because they are completely unrelated to the message flow. */
|
||||
const unrelatedKeys = (chatId: string) => [
|
||||
@@ -700,13 +705,9 @@ describe("mutation invalidation scope", () => {
|
||||
const chatId = "chat-1";
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
|
||||
// createChatMessage has no onSuccess handler — the WebSocket
|
||||
// stream covers all real-time updates. Verify that constructing
|
||||
// the mutation config does not define one.
|
||||
const mutation = createChatMessage(queryClient, chatId);
|
||||
expect(mutation).not.toHaveProperty("onSuccess");
|
||||
await mutation.onSuccess?.();
|
||||
|
||||
// Since there is no onSuccess, no queries should be invalidated.
|
||||
for (const { label, key } of unrelatedKeys(chatId)) {
|
||||
const state = queryClient.getQueryState(key);
|
||||
expect(
|
||||
@@ -716,14 +717,18 @@ describe("mutation invalidation scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("createChatMessage does not invalidate chat detail or messages (WebSocket handles these)", async () => {
|
||||
it("createChatMessage invalidates only debug runs, not chat detail or messages", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
|
||||
// No onSuccess handler exists.
|
||||
const mutation = createChatMessage(queryClient, chatId);
|
||||
expect(mutation).not.toHaveProperty("onSuccess");
|
||||
await mutation.onSuccess?.();
|
||||
|
||||
expect(
|
||||
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
|
||||
"chatDebugRunsKey should be invalidated",
|
||||
).toBe(true);
|
||||
|
||||
const chatState = queryClient.getQueryState(chatKey(chatId));
|
||||
expect(
|
||||
@@ -757,7 +762,7 @@ describe("mutation invalidation scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("editChatMessage invalidates only chat detail and messages", async () => {
|
||||
it("editChatMessage invalidates chat detail, messages, and debug runs", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
@@ -767,8 +772,9 @@ describe("mutation invalidation scope", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// These two should still be invalidated — editing changes
|
||||
// message content and potentially the chat's updated_at.
|
||||
// These queries should be invalidated — editing changes
|
||||
// message content, may update the chat record, and can start
|
||||
// a new debug run.
|
||||
const chatState = queryClient.getQueryState(chatKey(chatId));
|
||||
expect(chatState?.isInvalidated, "chatKey should be invalidated").toBe(
|
||||
true,
|
||||
@@ -779,6 +785,11 @@ describe("mutation invalidation scope", () => {
|
||||
messagesState?.isInvalidated,
|
||||
"chatMessagesKey should be invalidated",
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
|
||||
"chatDebugRunsKey should be invalidated",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Shared type for the infinite messages cache shape used by
|
||||
@@ -1131,13 +1142,18 @@ describe("mutation invalidation scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("promoteChatQueuedMessage does not invalidate unrelated queries", async () => {
|
||||
it("promoteChatQueuedMessage invalidates debug runs without touching unrelated queries", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
|
||||
const mutation = promoteChatQueuedMessage(queryClient, chatId);
|
||||
expect(mutation).not.toHaveProperty("onSuccess");
|
||||
await mutation.onSuccess?.();
|
||||
|
||||
expect(
|
||||
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
|
||||
"chatDebugRunsKey should be invalidated",
|
||||
).toBe(true);
|
||||
|
||||
for (const { label, key } of unrelatedKeys(chatId)) {
|
||||
const state = queryClient.getQueryState(key);
|
||||
|
||||
@@ -581,6 +581,15 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatDebugRunsKey = (chatId: string) =>
|
||||
["chats", chatId, "debug-runs"] as const;
|
||||
|
||||
const invalidateChatDebugRuns = (queryClient: QueryClient, chatId: string) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: chatDebugRunsKey(chatId),
|
||||
});
|
||||
};
|
||||
|
||||
export const createChat = (queryClient: QueryClient) => ({
|
||||
mutationFn: (req: TypesGen.CreateChatRequest) =>
|
||||
API.experimental.createChat(req),
|
||||
@@ -593,14 +602,17 @@ export const createChat = (queryClient: QueryClient) => ({
|
||||
});
|
||||
|
||||
export const createChatMessage = (
|
||||
_queryClient: QueryClient,
|
||||
queryClient: QueryClient,
|
||||
chatId: string,
|
||||
) => ({
|
||||
mutationFn: (req: TypesGen.CreateChatMessageRequest) =>
|
||||
API.experimental.createChatMessage(chatId, req),
|
||||
// No onSuccess invalidation needed: the per-chat WebSocket delivers
|
||||
// the response message via upsertDurableMessage, and the global
|
||||
// watchChats() WebSocket updates the sidebar sort order.
|
||||
onSuccess: async () => {
|
||||
await invalidateChatDebugRuns(queryClient, chatId);
|
||||
},
|
||||
// The per-chat and sidebar WebSockets cover message/status updates,
|
||||
// but the Debug panel uses polling. Kick its list query immediately
|
||||
// so newly-started runs appear without tab switching.
|
||||
});
|
||||
|
||||
type EditChatMessageMutationArgs = {
|
||||
@@ -684,6 +696,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
|
||||
queryKey: chatMessagesKey(chatId),
|
||||
exact: true,
|
||||
});
|
||||
void invalidateChatDebugRuns(queryClient, chatId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -713,14 +726,16 @@ export const deleteChatQueuedMessage = (
|
||||
});
|
||||
|
||||
export const promoteChatQueuedMessage = (
|
||||
_queryClient: QueryClient,
|
||||
queryClient: QueryClient,
|
||||
chatId: string,
|
||||
) => ({
|
||||
mutationFn: (queuedMessageId: number) =>
|
||||
API.experimental.promoteChatQueuedMessage(chatId, queuedMessageId),
|
||||
// No onSuccess invalidation needed: the caller upserts the
|
||||
// promoted message from the response, and the per-chat
|
||||
// WebSocket delivers queue and status updates in real-time.
|
||||
onSuccess: async () => {
|
||||
await invalidateChatDebugRuns(queryClient, chatId);
|
||||
},
|
||||
// The caller still upserts the promoted message directly, but the
|
||||
// Debug panel needs an explicit refresh to discover the new run.
|
||||
});
|
||||
|
||||
export const chatDiffContentsKey = (chatId: string) =>
|
||||
@@ -764,6 +779,40 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatDebugLoggingKey = ["chat-debug-logging"] as const;
|
||||
const userChatDebugLoggingKey = ["user-chat-debug-logging"] as const;
|
||||
|
||||
export const chatDebugLogging = () => ({
|
||||
queryKey: chatDebugLoggingKey,
|
||||
queryFn: () => API.experimental.getChatDebugLogging(),
|
||||
});
|
||||
|
||||
export const userChatDebugLogging = () => ({
|
||||
queryKey: userChatDebugLoggingKey,
|
||||
queryFn: () => API.experimental.getUserChatDebugLogging(),
|
||||
});
|
||||
|
||||
export const updateChatDebugLogging = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.experimental.updateChatDebugLogging,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatDebugLoggingKey,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: userChatDebugLoggingKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const updateUserChatDebugLogging = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.experimental.updateUserChatDebugLogging,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: userChatDebugLoggingKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const;
|
||||
|
||||
export const chatWorkspaceTTL = () => ({
|
||||
|
||||
@@ -130,6 +130,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
|
||||
diffStatusData: undefined as ComponentProps<
|
||||
typeof AgentChatPageView
|
||||
>["diffStatusData"],
|
||||
debugLoggingEnabled: false,
|
||||
gitWatcher: buildGitWatcher(),
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
|
||||
@@ -41,7 +41,9 @@ import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent";
|
||||
import { ChatScrollContainer } from "./components/ChatScrollContainer";
|
||||
import { ChatTopBar } from "./components/ChatTopBar";
|
||||
import { GitPanel } from "./components/GitPanel/GitPanel";
|
||||
import { DebugPanel } from "./components/RightPanel/DebugPanel/DebugPanel";
|
||||
import { RightPanel } from "./components/RightPanel/RightPanel";
|
||||
import { getEffectiveTabId } from "./components/Sidebar/getEffectiveTabId";
|
||||
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
|
||||
import { TerminalPanel } from "./components/TerminalPanel";
|
||||
import type { ChatDetailError } from "./utils/usageLimitMessage";
|
||||
@@ -267,6 +269,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
onOpenDesktop: desktopChatId ? handleOpenDesktop : undefined,
|
||||
};
|
||||
|
||||
const shouldShowSidebar = showSidebarPanel;
|
||||
|
||||
// Compute local diff stats from git watcher unified diffs.
|
||||
|
||||
const workspaceRoute = workspace
|
||||
@@ -300,14 +304,70 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
};
|
||||
})();
|
||||
|
||||
const sidebarTabIds = [
|
||||
"git",
|
||||
...(workspace && workspaceAgent ? ["terminal"] : []),
|
||||
"debug",
|
||||
];
|
||||
const effectiveSidebarTabId = getEffectiveTabId(
|
||||
sidebarTabIds,
|
||||
sidebarTabId,
|
||||
desktopChatId,
|
||||
);
|
||||
const sidebarTabs = [
|
||||
{
|
||||
id: "git",
|
||||
label: "Git",
|
||||
content: (
|
||||
<GitPanel
|
||||
prTab={
|
||||
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusData}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(workspace && workspaceAgent
|
||||
? [
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
content: (
|
||||
<TerminalPanel
|
||||
chatId={agentId}
|
||||
isVisible={
|
||||
shouldShowSidebar && effectiveSidebarTabId === "terminal"
|
||||
}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "debug",
|
||||
label: "Debug",
|
||||
content: (
|
||||
<DebugPanel
|
||||
chatId={agentId}
|
||||
enabled={shouldShowSidebar && effectiveSidebarTabId === "debug"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const titleElement = (
|
||||
<title>
|
||||
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
|
||||
</title>
|
||||
);
|
||||
|
||||
const shouldShowSidebar = showSidebarPanel;
|
||||
|
||||
return (
|
||||
<DesktopPanelContext value={desktopPanelCtx}>
|
||||
<div
|
||||
@@ -445,45 +505,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
<SidebarTabView
|
||||
activeTabId={sidebarTabId}
|
||||
onActiveTabChange={setSidebarTabId}
|
||||
tabs={[
|
||||
{
|
||||
id: "git",
|
||||
label: "Git",
|
||||
content: (
|
||||
<GitPanel
|
||||
prTab={
|
||||
prNumber && agentId
|
||||
? { prNumber, chatId: agentId }
|
||||
: undefined
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusData}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(workspace && workspaceAgent
|
||||
? [
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
content: (
|
||||
<TerminalPanel
|
||||
chatId={agentId}
|
||||
isVisible={
|
||||
shouldShowSidebar && sidebarTabId === "terminal"
|
||||
}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
tabs={sidebarTabs}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
chatDebugLogging,
|
||||
chatDesktopEnabled,
|
||||
chatModelConfigs,
|
||||
chatRetentionDays,
|
||||
@@ -8,12 +9,15 @@ import {
|
||||
chatUserCustomPrompt,
|
||||
chatWorkspaceTTL,
|
||||
deleteUserCompactionThreshold,
|
||||
updateChatDebugLogging,
|
||||
updateChatDesktopEnabled,
|
||||
updateChatRetentionDays,
|
||||
updateChatSystemPrompt,
|
||||
updateChatWorkspaceTTL,
|
||||
updateUserChatCustomPrompt,
|
||||
updateUserChatDebugLogging,
|
||||
updateUserCompactionThreshold,
|
||||
userChatDebugLogging,
|
||||
userCompactionThresholds,
|
||||
} from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
@@ -41,6 +45,19 @@ const AgentSettingsBehaviorPage: FC = () => {
|
||||
updateChatDesktopEnabled(queryClient),
|
||||
);
|
||||
|
||||
const debugLoggingQuery = useQuery({
|
||||
...chatDebugLogging(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const saveDebugLoggingMutation = useMutation(
|
||||
updateChatDebugLogging(queryClient),
|
||||
);
|
||||
|
||||
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
|
||||
const saveUserDebugLoggingMutation = useMutation(
|
||||
updateUserChatDebugLogging(queryClient),
|
||||
);
|
||||
|
||||
const workspaceTTLQuery = useQuery(chatWorkspaceTTL());
|
||||
const saveWorkspaceTTLMutation = useMutation(
|
||||
updateChatWorkspaceTTL(queryClient),
|
||||
@@ -79,6 +96,8 @@ const AgentSettingsBehaviorPage: FC = () => {
|
||||
systemPromptData={systemPromptQuery.data}
|
||||
userPromptData={userPromptQuery.data}
|
||||
desktopEnabledData={desktopEnabledQuery.data}
|
||||
debugLoggingData={debugLoggingQuery.data}
|
||||
userDebugLoggingData={userDebugLoggingQuery.data}
|
||||
workspaceTTLData={workspaceTTLQuery.data}
|
||||
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
|
||||
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
|
||||
@@ -99,6 +118,12 @@ const AgentSettingsBehaviorPage: FC = () => {
|
||||
onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate}
|
||||
isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending}
|
||||
isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError}
|
||||
onSaveDebugLogging={saveDebugLoggingMutation.mutate}
|
||||
isSavingDebugLogging={saveDebugLoggingMutation.isPending}
|
||||
isSaveDebugLoggingError={saveDebugLoggingMutation.isError}
|
||||
onSaveUserDebugLogging={saveUserDebugLoggingMutation.mutate}
|
||||
isSavingUserDebugLogging={saveUserDebugLoggingMutation.isPending}
|
||||
isSaveUserDebugLoggingError={saveUserDebugLoggingMutation.isError}
|
||||
onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate}
|
||||
isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending}
|
||||
isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { DebugLoggingSettings } from "./components/DebugLoggingSettings";
|
||||
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
|
||||
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
@@ -20,6 +21,8 @@ interface AgentSettingsBehaviorPageViewProps {
|
||||
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
|
||||
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
|
||||
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
|
||||
debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined;
|
||||
userDebugLoggingData: TypesGen.UserChatDebugLoggingSettings | undefined;
|
||||
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
|
||||
isWorkspaceTTLLoading: boolean;
|
||||
isWorkspaceTTLLoadError: boolean;
|
||||
@@ -62,6 +65,20 @@ interface AgentSettingsBehaviorPageViewProps {
|
||||
isSavingDesktopEnabled: boolean;
|
||||
isSaveDesktopEnabledError: boolean;
|
||||
|
||||
onSaveDebugLogging: (
|
||||
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingDebugLogging: boolean;
|
||||
isSaveDebugLoggingError: boolean;
|
||||
|
||||
onSaveUserDebugLogging: (
|
||||
req: TypesGen.UpdateUserChatDebugLoggingRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingUserDebugLogging: boolean;
|
||||
isSaveUserDebugLoggingError: boolean;
|
||||
|
||||
onSaveWorkspaceTTL: (
|
||||
req: TypesGen.UpdateChatWorkspaceTTLRequest,
|
||||
options?: MutationCallbacks,
|
||||
@@ -84,6 +101,8 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
systemPromptData,
|
||||
userPromptData,
|
||||
desktopEnabledData,
|
||||
debugLoggingData,
|
||||
userDebugLoggingData,
|
||||
workspaceTTLData,
|
||||
isWorkspaceTTLLoading,
|
||||
isWorkspaceTTLLoadError,
|
||||
@@ -107,6 +126,12 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
onSaveDesktopEnabled,
|
||||
isSavingDesktopEnabled,
|
||||
isSaveDesktopEnabledError,
|
||||
onSaveDebugLogging,
|
||||
isSavingDebugLogging,
|
||||
isSaveDebugLoggingError,
|
||||
onSaveUserDebugLogging,
|
||||
isSavingUserDebugLogging,
|
||||
isSaveUserDebugLoggingError,
|
||||
onSaveWorkspaceTTL,
|
||||
isSavingWorkspaceTTL,
|
||||
isSaveWorkspaceTTLError,
|
||||
@@ -120,7 +145,7 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
<>
|
||||
<SectionHeader
|
||||
label="Behavior"
|
||||
description="Custom instructions that shape how the agent responds in your conversations."
|
||||
description="Custom instructions and debug controls that shape how the agent responds in your conversations."
|
||||
/>
|
||||
|
||||
<PersonalInstructionsSettings
|
||||
@@ -131,6 +156,19 @@ export const AgentSettingsBehaviorPageView: FC<
|
||||
isAnyPromptSaving={isAnyPromptSaving}
|
||||
/>
|
||||
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<DebugLoggingSettings
|
||||
canManageAdminSetting={canSetSystemPrompt}
|
||||
adminSettings={debugLoggingData}
|
||||
userSettings={userDebugLoggingData}
|
||||
onSaveAdminSetting={onSaveDebugLogging}
|
||||
isSavingAdminSetting={isSavingDebugLogging}
|
||||
isSaveAdminSettingError={isSaveDebugLoggingError}
|
||||
onSaveUserSetting={onSaveUserDebugLogging}
|
||||
isSavingUserSetting={isSavingUserDebugLogging}
|
||||
isSaveUserSettingError={isSaveUserDebugLoggingError}
|
||||
/>
|
||||
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<UserCompactionThresholdSettings
|
||||
modelConfigs={modelConfigsData ?? []}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type InfiniteData, useQueryClient } from "react-query";
|
||||
import { watchChat } from "#/api/api";
|
||||
import { chatMessagesKey, updateInfiniteChatsCache } from "#/api/queries/chats";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
|
||||
import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket";
|
||||
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
|
||||
import type { ChatDetailError } from "../../utils/usageLimitMessage";
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface DebugLoggingSettingsProps {
|
||||
canManageAdminSetting: boolean;
|
||||
adminSettings: TypesGen.ChatDebugLoggingAdminSettings | undefined;
|
||||
userSettings: TypesGen.UserChatDebugLoggingSettings | undefined;
|
||||
onSaveAdminSetting: (
|
||||
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingAdminSetting: boolean;
|
||||
isSaveAdminSettingError: boolean;
|
||||
onSaveUserSetting: (
|
||||
req: TypesGen.UpdateUserChatDebugLoggingRequest,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSavingUserSetting: boolean;
|
||||
isSaveUserSettingError: boolean;
|
||||
}
|
||||
|
||||
export const DebugLoggingSettings: FC<DebugLoggingSettingsProps> = ({
|
||||
canManageAdminSetting,
|
||||
adminSettings,
|
||||
userSettings,
|
||||
onSaveAdminSetting,
|
||||
isSavingAdminSetting,
|
||||
isSaveAdminSettingError,
|
||||
onSaveUserSetting,
|
||||
isSavingUserSetting,
|
||||
isSaveUserSettingError,
|
||||
}) => {
|
||||
const forcedByDeployment =
|
||||
userSettings?.forced_by_deployment ??
|
||||
adminSettings?.forced_by_deployment ??
|
||||
false;
|
||||
const adminAllowsUsers = adminSettings?.allow_users ?? false;
|
||||
const userDebugLoggingEnabled = userSettings?.debug_logging_enabled ?? false;
|
||||
const userToggleAllowed = userSettings?.user_toggle_allowed ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canManageAdminSetting && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Allow User Debug Logs
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
|
||||
{forcedByDeployment ? (
|
||||
<p className="m-0">
|
||||
Deployment configuration already forces chat debug logging on
|
||||
for every chat. This runtime user opt-in setting is currently
|
||||
ignored.
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0">
|
||||
Allow users to opt into normalized model state and raw
|
||||
provider request/response logging from their personal Behavior
|
||||
settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={adminAllowsUsers}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveAdminSetting({ allow_users: checked })
|
||||
}
|
||||
aria-label="Allow users to enable chat debug logging"
|
||||
disabled={forcedByDeployment || isSavingAdminSetting}
|
||||
/>
|
||||
</div>
|
||||
{isSaveAdminSettingError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save the admin debug logging setting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Personal Chat Debug Logs
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
|
||||
{forcedByDeployment ? (
|
||||
<p className="m-0">
|
||||
Deployment configuration forces chat debug logging on for every
|
||||
chat. Your personal toggle is read-only while this is enabled.
|
||||
</p>
|
||||
) : userToggleAllowed ? (
|
||||
<p className="m-0">
|
||||
Capture normalized model state and raw provider request/response
|
||||
payloads for your own chats.
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0">
|
||||
An administrator has not enabled user-controlled chat debug
|
||||
logging yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={userDebugLoggingEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onSaveUserSetting({ debug_logging_enabled: checked })
|
||||
}
|
||||
aria-label="Enable personal chat debug logging"
|
||||
disabled={
|
||||
forcedByDeployment || !userToggleAllowed || isSavingUserSetting
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isSaveUserSettingError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save your chat debug logging preference.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "#/components/Collapsible/Collapsible";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { DATE_FORMAT, formatDateTime, humanDuration } from "#/utils/time";
|
||||
import {
|
||||
DEBUG_PANEL_METADATA_CLASS_NAME,
|
||||
DebugCodeBlock,
|
||||
DebugDataSection,
|
||||
EmptyHelper,
|
||||
} from "./DebugPanelPrimitives";
|
||||
import {
|
||||
computeDurationMs,
|
||||
getStatusBadgeVariant,
|
||||
type NormalizedAttempt,
|
||||
safeJsonStringify,
|
||||
} from "./debugPanelUtils";
|
||||
|
||||
interface DebugAttemptAccordionProps {
|
||||
attempts: NormalizedAttempt[];
|
||||
rawFallback?: string;
|
||||
}
|
||||
|
||||
interface JsonBlockProps {
|
||||
value: unknown;
|
||||
fallbackCopy: string;
|
||||
}
|
||||
|
||||
const JsonBlock: FC<JsonBlockProps> = ({ value, fallbackCopy }) => {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(typeof value === "string" && value.length === 0) ||
|
||||
(typeof value === "object" && Object.keys(value as object).length === 0)
|
||||
) {
|
||||
return <EmptyHelper message={fallbackCopy} />;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return <DebugCodeBlock code={value} />;
|
||||
}
|
||||
|
||||
return <DebugCodeBlock code={safeJsonStringify(value)} />;
|
||||
};
|
||||
|
||||
const getAttemptTimingLabel = (attempt: NormalizedAttempt): string => {
|
||||
const startedLabel = attempt.started_at
|
||||
? formatDateTime(attempt.started_at, DATE_FORMAT.TIME_24H)
|
||||
: "—";
|
||||
const finishedLabel = attempt.finished_at
|
||||
? formatDateTime(attempt.finished_at, DATE_FORMAT.TIME_24H)
|
||||
: "in progress";
|
||||
|
||||
const durationMs =
|
||||
attempt.duration_ms ??
|
||||
(attempt.started_at
|
||||
? computeDurationMs(attempt.started_at, attempt.finished_at)
|
||||
: null);
|
||||
const durationLabel =
|
||||
durationMs !== null ? humanDuration(durationMs) : "Duration unavailable";
|
||||
|
||||
return `${startedLabel} → ${finishedLabel} • ${durationLabel}`;
|
||||
};
|
||||
|
||||
export const DebugAttemptAccordion: FC<DebugAttemptAccordionProps> = ({
|
||||
attempts,
|
||||
rawFallback,
|
||||
}) => {
|
||||
if (rawFallback) {
|
||||
// No DebugDataSection wrapper here — the parent already
|
||||
// wraps us in <DebugDataSection title="Raw attempts">.
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-xs text-content-secondary">
|
||||
Unable to parse raw attempts. Showing the original payload exactly as
|
||||
it was captured.
|
||||
</p>
|
||||
<DebugCodeBlock code={rawFallback} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (attempts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-content-secondary">No attempts captured.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{attempts.map((attempt, index) => (
|
||||
<Collapsible
|
||||
key={`${attempt.attempt_number}-${attempt.started_at ?? index}`}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="border-l border-l-border-default/50">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-start gap-3 border-0 bg-transparent px-4 py-3 text-left transition-colors hover:bg-surface-secondary/20"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content-primary">
|
||||
Attempt {attempt.attempt_number}
|
||||
</span>
|
||||
{attempt.method || attempt.path ? (
|
||||
<span className="truncate font-mono text-xs font-medium text-content-secondary">
|
||||
{[attempt.method, attempt.path]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</span>
|
||||
) : null}
|
||||
{attempt.response_status ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={
|
||||
attempt.response_status < 400
|
||||
? "green"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{attempt.response_status}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={getStatusBadgeVariant(attempt.status)}
|
||||
className="shrink-0 sm:hidden"
|
||||
>
|
||||
{attempt.status || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className={DEBUG_PANEL_METADATA_CLASS_NAME}>
|
||||
<span>{getAttemptTimingLabel(attempt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={getStatusBadgeVariant(attempt.status)}
|
||||
className="hidden shrink-0 sm:inline-flex"
|
||||
>
|
||||
{attempt.status || "unknown"}
|
||||
</Badge>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0 text-content-secondary transition-transform",
|
||||
"group-data-[state=open]:rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
<DebugDataSection title="Raw request">
|
||||
<JsonBlock
|
||||
value={attempt.raw_request}
|
||||
fallbackCopy="No raw request captured."
|
||||
/>
|
||||
</DebugDataSection>
|
||||
<DebugDataSection title="Raw response">
|
||||
<JsonBlock
|
||||
value={attempt.raw_response}
|
||||
fallbackCopy="No raw response captured."
|
||||
/>
|
||||
</DebugDataSection>
|
||||
<DebugDataSection title="Error">
|
||||
<JsonBlock
|
||||
value={attempt.error}
|
||||
fallbackCopy="No error captured."
|
||||
/>
|
||||
</DebugDataSection>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import { Alert } from "#/components/Alert/Alert";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { DebugRunList } from "./DebugRunList";
|
||||
import { chatDebugRuns } from "./debugQueries";
|
||||
|
||||
interface DebugPanelProps {
|
||||
chatId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const DebugPanel: FC<DebugPanelProps> = ({
|
||||
chatId,
|
||||
enabled = false,
|
||||
}) => {
|
||||
const runsQuery = useQuery({
|
||||
...chatDebugRuns(chatId),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const sortedRuns = [...(runsQuery.data ?? [])].sort((left, right) => {
|
||||
const rightTime = Date.parse(right.started_at || right.updated_at) || 0;
|
||||
const leftTime = Date.parse(left.started_at || left.updated_at) || 0;
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
let content: ReactNode;
|
||||
if (runsQuery.isError) {
|
||||
content = (
|
||||
<div className="p-4">
|
||||
<Alert severity="error" prominent>
|
||||
<p className="text-sm text-content-primary">
|
||||
{getErrorMessage(
|
||||
runsQuery.error,
|
||||
"Unable to load debug panel data.",
|
||||
)}
|
||||
</p>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
} else if (runsQuery.isLoading) {
|
||||
content = (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-content-secondary">
|
||||
<Spinner size="sm" loading />
|
||||
Loading debug runs...
|
||||
</div>
|
||||
);
|
||||
} else if (sortedRuns.length === 0) {
|
||||
content = (
|
||||
<div className="flex flex-col gap-2 p-4 text-sm text-content-secondary">
|
||||
<p className="font-medium text-content-primary">
|
||||
No debug runs recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Debug logging captures LLM request/response data for each chat turn,
|
||||
title generation, and compaction operation.
|
||||
</p>
|
||||
<p>
|
||||
Enable it from <strong>Settings → Behavior</strong> if your admin
|
||||
allows user-controlled debug logging, or ask an admin to turn it on
|
||||
globally.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<DebugRunList runs={sortedRuns} chatId={chatId} enabled={enabled} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
viewportClassName="h-full [&>div]:!block [&>div]:!w-full"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<div className="min-h-full w-full min-w-0 overflow-x-hidden">
|
||||
{content}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getRoleBadgeVariant } from "./debugPanelUtils";
|
||||
|
||||
const DEBUG_PANEL_SECTION_TITLE_CLASS_NAME =
|
||||
"text-xs font-medium text-content-secondary";
|
||||
|
||||
export const DEBUG_PANEL_METADATA_CLASS_NAME =
|
||||
"flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-content-secondary";
|
||||
|
||||
const DEBUG_PANEL_SECTION_CLASS_NAME = "space-y-1.5";
|
||||
|
||||
const DEBUG_PANEL_CODE_BLOCK_CLASS_NAME =
|
||||
"w-full max-w-full max-h-[28rem] overflow-auto rounded-lg bg-surface-tertiary/60 px-3 py-2.5 font-mono text-[12px] leading-5 text-content-primary shadow-inner";
|
||||
|
||||
interface DebugDataSectionProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DebugDataSection: FC<DebugDataSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<section className={cn(DEBUG_PANEL_SECTION_CLASS_NAME, className)}>
|
||||
<h4 className={DEBUG_PANEL_SECTION_TITLE_CLASS_NAME}>{title}</h4>
|
||||
{description ? (
|
||||
<p className="text-xs leading-5 text-content-tertiary">{description}</p>
|
||||
) : null}
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface DebugCodeBlockProps {
|
||||
code: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DebugCodeBlock: FC<DebugCodeBlockProps> = ({
|
||||
code,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<pre className={cn(DEBUG_PANEL_CODE_BLOCK_CLASS_NAME, className)}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copyable code block – code block with an inline copy button.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CopyableCodeBlockProps {
|
||||
code: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CopyableCodeBlock: FC<CopyableCodeBlockProps> = ({
|
||||
code,
|
||||
label,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<CopyButton text={code} label={label} />
|
||||
</div>
|
||||
<DebugCodeBlock code={code} className={cn("pr-10", className)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pill toggle – compact toggle button for optional metadata sections.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PillToggleProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PillToggle: FC<PillToggleProps> = ({
|
||||
label,
|
||||
count,
|
||||
isActive,
|
||||
onToggle,
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border-0 px-2.5 py-0.5 text-2xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-surface-secondary text-content-primary"
|
||||
: "bg-transparent text-content-secondary hover:text-content-primary hover:bg-surface-secondary/50",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{count !== undefined && count > 0 ? ` (${count})` : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role badge – role-colored badge for message transcripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RoleBadgeProps {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const RoleBadge: FC<RoleBadgeProps> = ({ role }) => {
|
||||
return (
|
||||
<Badge size="xs" variant={getRoleBadgeVariant(role)}>
|
||||
{role}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty helper – fallback message for absent data sections.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EmptyHelperProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const EmptyHelper: FC<EmptyHelperProps> = ({ message }) => {
|
||||
return <p className="text-sm leading-6 text-content-secondary">{message}</p>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key-value grid – shared definition list for Options/Usage/Policy sections.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KeyValueGridProps {
|
||||
entries: Record<string, unknown>;
|
||||
/** Format value for display. Defaults to String(value). */
|
||||
formatValue?: (value: unknown) => string;
|
||||
}
|
||||
|
||||
export const KeyValueGrid: FC<KeyValueGridProps> = ({
|
||||
entries,
|
||||
formatValue,
|
||||
}) => {
|
||||
const fmt =
|
||||
formatValue ??
|
||||
((v: unknown) =>
|
||||
typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
|
||||
{Object.entries(entries).map(([key, value]) => (
|
||||
<div key={key} className="contents">
|
||||
<dt className="text-content-tertiary">{key}</dt>
|
||||
<dd className="break-words font-medium text-content-primary">
|
||||
{fmt(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata item – compact label : value pair for metadata bars.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MetadataItemProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
export const MetadataItem: FC<MetadataItemProps> = ({ label, value }) => {
|
||||
return (
|
||||
<span className="text-xs text-content-secondary">
|
||||
<span className="text-content-tertiary">{label}:</span>{" "}
|
||||
<span className="font-medium text-content-primary">{value}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type { ChatDebugRunSummary } from "#/api/typesGenerated";
|
||||
import { Alert } from "#/components/Alert/Alert";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "#/components/Collapsible/Collapsible";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { DebugStepCard } from "./DebugStepCard";
|
||||
import {
|
||||
clampContent,
|
||||
coerceRunSummary,
|
||||
compactDuration,
|
||||
computeDurationMs,
|
||||
formatTokenSummary,
|
||||
getRunKindLabel,
|
||||
getStatusBadgeVariant,
|
||||
isActiveStatus,
|
||||
} from "./debugPanelUtils";
|
||||
import { chatDebugRun } from "./debugQueries";
|
||||
|
||||
interface DebugRunCardProps {
|
||||
run: ChatDebugRunSummary;
|
||||
chatId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const getDurationLabel = (startedAt: string, finishedAt?: string): string => {
|
||||
const durationMs = computeDurationMs(startedAt, finishedAt);
|
||||
return durationMs !== null ? compactDuration(durationMs) : "—";
|
||||
};
|
||||
|
||||
export const DebugRunCard: FC<DebugRunCardProps> = ({
|
||||
run,
|
||||
chatId,
|
||||
enabled = true,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const runDetailQuery = useQuery({
|
||||
...chatDebugRun(chatId, run.id),
|
||||
enabled: enabled && isExpanded,
|
||||
});
|
||||
|
||||
const steps = runDetailQuery.data?.steps ?? [];
|
||||
|
||||
// Coerce summary from detail (preferred) → props → empty.
|
||||
const summaryVm = coerceRunSummary(
|
||||
runDetailQuery.data?.summary ?? run.summary,
|
||||
);
|
||||
const modelLabel = summaryVm.model?.trim() || run.model?.trim() || "";
|
||||
|
||||
// Primary label fallback chain: firstMessage → kind.
|
||||
const primaryLabel = clampContent(
|
||||
summaryVm.primaryLabel.trim() || getRunKindLabel(run.kind),
|
||||
80,
|
||||
);
|
||||
|
||||
// Token summary for the header.
|
||||
const tokenLabel = formatTokenSummary(
|
||||
summaryVm.totalInputTokens,
|
||||
summaryVm.totalOutputTokens,
|
||||
);
|
||||
|
||||
// Step count from detail or summary.
|
||||
const stepCount = steps.length > 0 ? steps.length : summaryVm.stepCount;
|
||||
const durationLabel = getDurationLabel(run.started_at, run.finished_at);
|
||||
const metadataItems = [
|
||||
modelLabel || undefined,
|
||||
stepCount !== undefined && stepCount > 0
|
||||
? `${stepCount} ${stepCount === 1 ? "step" : "steps"}`
|
||||
: undefined,
|
||||
durationLabel,
|
||||
tokenLabel || undefined,
|
||||
].filter((item): item is string => item !== undefined);
|
||||
const running = isActiveStatus(run.status);
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<div>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-1.5 text-left transition-colors hover:bg-surface-secondary/20"
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2.5 overflow-hidden">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold text-content-primary">
|
||||
{primaryLabel}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs leading-5 text-content-secondary">
|
||||
{metadataItems.map((item, index) => (
|
||||
<span
|
||||
key={`${item}-${index}`}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{running ? <Spinner size="sm" loading /> : null}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={getStatusBadgeVariant(run.status)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{run.status || "unknown"}
|
||||
</Badge>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-content-secondary transition-transform",
|
||||
"group-data-[state=open]:rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4 pt-2">
|
||||
{runDetailQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
<Spinner size="sm" loading />
|
||||
Loading run details...
|
||||
</div>
|
||||
) : runDetailQuery.isError ? (
|
||||
<Alert severity="error" prominent>
|
||||
<p className="text-sm text-content-primary">
|
||||
{getErrorMessage(
|
||||
runDetailQuery.error,
|
||||
"Unable to load debug run details.",
|
||||
)}
|
||||
</p>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<DebugStepCard key={step.id} step={step} defaultOpen={false} />
|
||||
))}
|
||||
{steps.length === 0 ? (
|
||||
<p className="text-sm text-content-secondary">
|
||||
No steps recorded.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FC } from "react";
|
||||
import type { ChatDebugRunSummary } from "#/api/typesGenerated";
|
||||
import { DebugRunCard } from "./DebugRunCard";
|
||||
|
||||
interface DebugRunListProps {
|
||||
runs: ChatDebugRunSummary[];
|
||||
chatId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const DebugRunList: FC<DebugRunListProps> = ({
|
||||
runs,
|
||||
chatId,
|
||||
enabled = true,
|
||||
}) => {
|
||||
// Empty state is handled by DebugPanel before rendering this
|
||||
// component. No guard here to avoid duplicated copy that drifts.
|
||||
return (
|
||||
<div className="w-full max-w-full min-w-0">
|
||||
{runs.map((run) => (
|
||||
<DebugRunCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
chatId={chatId}
|
||||
enabled={enabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,446 @@
|
||||
import { ChevronDownIcon, WrenchIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { ChatDebugStep } from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "#/components/Collapsible/Collapsible";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { DebugAttemptAccordion } from "./DebugAttemptAccordion";
|
||||
import {
|
||||
CopyableCodeBlock,
|
||||
DEBUG_PANEL_METADATA_CLASS_NAME,
|
||||
DebugDataSection,
|
||||
EmptyHelper,
|
||||
KeyValueGrid,
|
||||
MetadataItem,
|
||||
PillToggle,
|
||||
} from "./DebugPanelPrimitives";
|
||||
import {
|
||||
MessageRow,
|
||||
ToolBadge,
|
||||
ToolEventCard,
|
||||
ToolPayloadDisclosure,
|
||||
} from "./DebugStepCardTooling";
|
||||
import {
|
||||
coerceStepRequest,
|
||||
coerceStepResponse,
|
||||
coerceUsageRecord,
|
||||
compactDuration,
|
||||
computeDurationMs,
|
||||
extractTokenCounts,
|
||||
formatTokenSummary,
|
||||
getStatusBadgeVariant,
|
||||
normalizeAttempts,
|
||||
safeJsonStringify,
|
||||
TRANSCRIPT_PREVIEW_COUNT,
|
||||
} from "./debugPanelUtils";
|
||||
|
||||
interface DebugStepCardProps {
|
||||
step: ChatDebugStep;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
type SectionKey = "tools" | "options" | "usage" | "policy";
|
||||
|
||||
export const DebugStepCard: FC<DebugStepCardProps> = ({
|
||||
step,
|
||||
defaultOpen = false,
|
||||
}) => {
|
||||
// Single active metadata pill – only one section open at a time.
|
||||
const [activeSection, setActiveSection] = useState<SectionKey | null>(null);
|
||||
|
||||
// Transcript preview – show last N messages by default.
|
||||
const [showAllMessages, setShowAllMessages] = useState(false);
|
||||
|
||||
const toggleSection = (key: SectionKey) => {
|
||||
setActiveSection((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
// Coerce payloads defensively.
|
||||
const request = coerceStepRequest(step.normalized_request);
|
||||
const response = coerceStepResponse(step.normalized_response);
|
||||
const stepUsage = coerceUsageRecord(step.usage);
|
||||
const mergedUsage =
|
||||
Object.keys(stepUsage).length > 0 ? stepUsage : response.usage;
|
||||
const tokenCounts = extractTokenCounts(mergedUsage);
|
||||
const tokenLabel = formatTokenSummary(tokenCounts.input, tokenCounts.output);
|
||||
const normalizedAttempts = normalizeAttempts(step.attempts);
|
||||
const attemptCount = normalizedAttempts.parsed.length;
|
||||
|
||||
const durationMs = computeDurationMs(step.started_at, step.finished_at);
|
||||
const durationLabel = durationMs !== null ? compactDuration(durationMs) : "—";
|
||||
|
||||
// Model: prefer request model, then response model.
|
||||
const model = request.model ?? response.model;
|
||||
|
||||
// Counts for pill badges.
|
||||
const toolCount = request.tools.length;
|
||||
const optionCount = Object.keys(request.options).length;
|
||||
const usageEntryCount = Object.keys(mergedUsage).length;
|
||||
const policyCount = Object.keys(request.policy).length;
|
||||
const hasPills =
|
||||
toolCount > 0 || optionCount > 0 || usageEntryCount > 0 || policyCount > 0;
|
||||
|
||||
// Transcript preview slicing.
|
||||
const totalMessages = request.messages.length;
|
||||
const isTruncated =
|
||||
!showAllMessages && totalMessages > TRANSCRIPT_PREVIEW_COUNT;
|
||||
const visibleMessages = isTruncated
|
||||
? request.messages.slice(-TRANSCRIPT_PREVIEW_COUNT)
|
||||
: request.messages;
|
||||
const hiddenCount = totalMessages - visibleMessages.length;
|
||||
|
||||
// Detect whether there is meaningful output.
|
||||
const hasOutput =
|
||||
!!response.content ||
|
||||
response.toolCalls.length > 0 ||
|
||||
response.warnings.length > 0 ||
|
||||
!!response.finishReason;
|
||||
|
||||
// Detect whether there is an error payload.
|
||||
const stringError =
|
||||
typeof step.error === "string" ? (step.error as string) : undefined;
|
||||
const hasError =
|
||||
(stringError !== undefined && stringError.trim().length > 0) ||
|
||||
(!!step.error &&
|
||||
typeof step.error === "object" &&
|
||||
Object.keys(step.error).length > 0);
|
||||
const errorCode = stringError ?? safeJsonStringify(step.error);
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen={defaultOpen}>
|
||||
<div className="overflow-hidden rounded-lg border border-solid border-border-default/40 bg-surface-secondary/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-2 text-left transition-colors hover:bg-surface-secondary/25"
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<span className="shrink-0 text-xs font-medium text-content-tertiary">
|
||||
Step {step.step_number}
|
||||
</span>
|
||||
{model ? (
|
||||
<span className="min-w-0 truncate text-xs text-content-secondary">
|
||||
{model}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="shrink-0 whitespace-nowrap text-xs text-content-tertiary">
|
||||
{durationLabel}
|
||||
</span>
|
||||
{tokenLabel ? (
|
||||
<span className="shrink-0 whitespace-nowrap text-xs text-content-tertiary">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={getStatusBadgeVariant(step.status)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{step.status || "unknown"}
|
||||
</Badge>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-content-secondary transition-transform",
|
||||
"group-data-[state=open]:rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-3 border-t border-solid border-border-default/30 bg-surface-primary/10 px-3 pb-3 pt-3">
|
||||
{/* ── Metadata bar ────────────────────────────── */}
|
||||
<div className={DEBUG_PANEL_METADATA_CLASS_NAME}>
|
||||
{model ? <MetadataItem label="Model" value={model} /> : null}
|
||||
{request.options.max_output_tokens !== undefined ||
|
||||
request.options.maxOutputTokens !== undefined ||
|
||||
request.options.max_tokens !== undefined ||
|
||||
request.options.maxTokens !== undefined ? (
|
||||
<MetadataItem
|
||||
label="Max tokens"
|
||||
value={String(
|
||||
request.options.max_output_tokens ??
|
||||
request.options.maxOutputTokens ??
|
||||
request.options.max_tokens ??
|
||||
request.options.maxTokens,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{request.policy.tool_choice !== undefined ||
|
||||
request.policy.toolChoice !== undefined ? (
|
||||
<MetadataItem
|
||||
label="Tool choice"
|
||||
value={(() => {
|
||||
const tc =
|
||||
request.policy.tool_choice ?? request.policy.toolChoice;
|
||||
if (tc == null) return "";
|
||||
if (typeof tc === "string") return tc;
|
||||
try {
|
||||
return JSON.stringify(tc);
|
||||
} catch {
|
||||
return String(tc);
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
) : null}
|
||||
{attemptCount > 0 ? (
|
||||
<span className="text-xs text-content-tertiary">
|
||||
{attemptCount} {attemptCount === 1 ? "attempt" : "attempts"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Pill toggles (single active) ───────────── */}
|
||||
{hasPills ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{toolCount > 0 ? (
|
||||
<PillToggle
|
||||
label="Tools"
|
||||
count={toolCount}
|
||||
isActive={activeSection === "tools"}
|
||||
onToggle={() => toggleSection("tools")}
|
||||
icon={<WrenchIcon className="size-3" />}
|
||||
/>
|
||||
) : null}
|
||||
{optionCount > 0 ? (
|
||||
<PillToggle
|
||||
label="Options"
|
||||
count={optionCount}
|
||||
isActive={activeSection === "options"}
|
||||
onToggle={() => toggleSection("options")}
|
||||
/>
|
||||
) : null}
|
||||
{usageEntryCount > 0 ? (
|
||||
<PillToggle
|
||||
label="Usage"
|
||||
count={usageEntryCount}
|
||||
isActive={activeSection === "usage"}
|
||||
onToggle={() => toggleSection("usage")}
|
||||
/>
|
||||
) : null}
|
||||
{policyCount > 0 ? (
|
||||
<PillToggle
|
||||
label="Policy"
|
||||
count={policyCount}
|
||||
isActive={activeSection === "policy"}
|
||||
onToggle={() => toggleSection("policy")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Active metadata section ────────────────── */}
|
||||
{activeSection === "tools" && toolCount > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{request.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="rounded-md border border-solid border-border-default/40 bg-surface-secondary/10 p-2.5"
|
||||
>
|
||||
<ToolBadge label={tool.name} />
|
||||
{tool.description ? (
|
||||
<p className="mt-1 break-words text-2xs leading-4 text-content-secondary">
|
||||
{tool.description}
|
||||
</p>
|
||||
) : null}
|
||||
<ToolPayloadDisclosure
|
||||
label="JSON schema"
|
||||
code={tool.inputSchema}
|
||||
copyLabel={`Copy ${tool.name} JSON schema`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSection === "options" && optionCount > 0 ? (
|
||||
<DebugDataSection title="Options">
|
||||
<KeyValueGrid entries={request.options} />
|
||||
</DebugDataSection>
|
||||
) : null}
|
||||
|
||||
{activeSection === "usage" && usageEntryCount > 0 ? (
|
||||
<DebugDataSection title="Usage">
|
||||
<KeyValueGrid
|
||||
entries={mergedUsage}
|
||||
formatValue={(v) =>
|
||||
typeof v === "number" ? v.toLocaleString("en-US") : String(v)
|
||||
}
|
||||
/>
|
||||
</DebugDataSection>
|
||||
) : null}
|
||||
|
||||
{activeSection === "policy" && policyCount > 0 ? (
|
||||
<DebugDataSection title="Policy">
|
||||
<KeyValueGrid entries={request.policy} />
|
||||
</DebugDataSection>
|
||||
) : null}
|
||||
|
||||
{/* ── Input / Output sections ──────────────────── */}
|
||||
<div className="grid gap-4">
|
||||
{/* ── Input column ────────────────────────── */}
|
||||
<DebugDataSection title="Input">
|
||||
{totalMessages > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllMessages(true)}
|
||||
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
|
||||
>
|
||||
Show all {totalMessages} messages
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{showAllMessages &&
|
||||
totalMessages > TRANSCRIPT_PREVIEW_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllMessages(false)}
|
||||
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
|
||||
>
|
||||
Show last {TRANSCRIPT_PREVIEW_COUNT} only
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{visibleMessages.map((msg, idx) => (
|
||||
<MessageRow
|
||||
key={hiddenCount + idx}
|
||||
msg={msg}
|
||||
clamp={!showAllMessages}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyHelper message="No input messages captured." />
|
||||
)}
|
||||
</DebugDataSection>
|
||||
|
||||
{/* ── Output column ───────────────────────── */}
|
||||
<DebugDataSection title="Output">
|
||||
{hasOutput ? (
|
||||
<div className="space-y-2">
|
||||
{/* Primary response content – visually prominent. */}
|
||||
{response.content ? (
|
||||
<p className="max-h-[28rem] overflow-auto whitespace-pre-wrap text-sm font-medium leading-6 text-content-primary">
|
||||
{response.content}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Tool calls – structured cards with arguments. */}
|
||||
{response.toolCalls.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{response.toolCalls.map((tc, idx) => (
|
||||
<ToolEventCard
|
||||
key={tc.id ?? `${tc.name}-${idx}`}
|
||||
badgeLabel={tc.name}
|
||||
toolCallId={tc.id}
|
||||
payloadLabel="Arguments"
|
||||
payload={tc.arguments}
|
||||
copyLabel={`Copy ${tc.name} arguments`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Secondary metadata: finish reason + warnings. */}
|
||||
{response.finishReason ? (
|
||||
<span className="block text-2xs text-content-tertiary">
|
||||
Finish: {response.finishReason}
|
||||
</span>
|
||||
) : null}
|
||||
{response.warnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{response.warnings.map((w, idx) => (
|
||||
<p key={idx} className="text-xs text-content-warning">
|
||||
<span aria-hidden="true">⚠</span>{" "}
|
||||
<span className="sr-only">Warning: </span>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyHelper message="No output captured." />
|
||||
)}
|
||||
</DebugDataSection>
|
||||
</div>
|
||||
|
||||
{/* ── Error ───────────────────────────────────── */}
|
||||
{hasError ? (
|
||||
<DebugDataSection title="Error">
|
||||
<CopyableCodeBlock
|
||||
code={errorCode}
|
||||
label={
|
||||
stringError !== undefined
|
||||
? "Copy error text"
|
||||
: "Copy error JSON"
|
||||
}
|
||||
/>
|
||||
</DebugDataSection>
|
||||
) : null}
|
||||
|
||||
{/* ── Request body JSON (lower priority) ─────── */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/raw flex items-center gap-1.5 border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronDownIcon className="size-3 transition-transform group-data-[state=open]/raw:rotate-180" />
|
||||
Request body
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1.5">
|
||||
<CopyableCodeBlock
|
||||
code={safeJsonStringify(step.normalized_request)}
|
||||
label="Copy request body JSON"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ── Response body JSON ──────────────────────── */}
|
||||
{step.normalized_response ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/raw flex items-center gap-1.5 border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronDownIcon className="size-3 transition-transform group-data-[state=open]/raw:rotate-180" />
|
||||
Response body
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1.5">
|
||||
<CopyableCodeBlock
|
||||
code={safeJsonStringify(step.normalized_response)}
|
||||
label="Copy response body JSON"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
|
||||
{/* ── Raw HTTP attempts ───────────────────────── */}
|
||||
{attemptCount > 0 ||
|
||||
(normalizedAttempts.rawFallback &&
|
||||
normalizedAttempts.rawFallback !== "{}" &&
|
||||
normalizedAttempts.rawFallback !== "[]") ? (
|
||||
<DebugDataSection title="Raw attempts">
|
||||
<DebugAttemptAccordion
|
||||
attempts={normalizedAttempts.parsed}
|
||||
rawFallback={normalizedAttempts.rawFallback}
|
||||
/>
|
||||
</DebugDataSection>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { WrenchIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { CopyableCodeBlock, RoleBadge } from "./DebugPanelPrimitives";
|
||||
import {
|
||||
clampContent,
|
||||
MESSAGE_CONTENT_CLAMP_CHARS,
|
||||
type MessagePart,
|
||||
} from "./debugPanelUtils";
|
||||
|
||||
interface MessageRowProps {
|
||||
msg: MessagePart;
|
||||
clamp: boolean;
|
||||
}
|
||||
|
||||
interface ToolPayloadDisclosureProps {
|
||||
label: string;
|
||||
code?: string;
|
||||
copyLabel: string;
|
||||
}
|
||||
|
||||
export const ToolPayloadDisclosure: FC<ToolPayloadDisclosureProps> = ({
|
||||
label,
|
||||
code,
|
||||
copyLabel,
|
||||
}) => {
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-2xs font-medium uppercase tracking-wide text-content-tertiary">
|
||||
{label}
|
||||
</p>
|
||||
<CopyableCodeBlock code={code} label={copyLabel} className="max-h-56" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolBadge: FC<{ label: string }> = ({ label }) => {
|
||||
return (
|
||||
<Badge size="sm" variant="purple" className="max-w-full">
|
||||
<WrenchIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolEventCardProps {
|
||||
badgeLabel: string;
|
||||
toolCallId?: string;
|
||||
payloadLabel?: string;
|
||||
payload?: string;
|
||||
copyLabel?: string;
|
||||
}
|
||||
|
||||
export const ToolEventCard: FC<ToolEventCardProps> = ({
|
||||
badgeLabel,
|
||||
toolCallId,
|
||||
payloadLabel,
|
||||
payload,
|
||||
copyLabel,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-md border border-solid border-border-default/40 bg-surface-secondary/10 p-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ToolBadge label={badgeLabel} />
|
||||
{toolCallId ? (
|
||||
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
|
||||
{toolCallId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payloadLabel && payload && copyLabel ? (
|
||||
<ToolPayloadDisclosure
|
||||
label={payloadLabel}
|
||||
code={payload}
|
||||
copyLabel={copyLabel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TranscriptToolRow: FC<{ msg: MessagePart }> = ({ msg }) => {
|
||||
const isToolCall = msg.kind === "tool-call";
|
||||
const badgeLabel = msg.toolName ?? (isToolCall ? "Tool call" : "Tool result");
|
||||
const payloadLabel = isToolCall ? "Arguments" : "Result";
|
||||
const payload = isToolCall ? msg.arguments : msg.result;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={msg.role} />
|
||||
</div>
|
||||
<ToolEventCard
|
||||
badgeLabel={badgeLabel}
|
||||
toolCallId={msg.toolCallId}
|
||||
payloadLabel={payloadLabel}
|
||||
payload={payload}
|
||||
copyLabel={`Copy ${badgeLabel} ${payloadLabel}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TranscriptTextRow: FC<MessageRowProps> = ({ msg, clamp }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const needsClamp = clamp && msg.content.length > MESSAGE_CONTENT_CLAMP_CHARS;
|
||||
const showClamped = needsClamp && !expanded;
|
||||
const displayContent = showClamped
|
||||
? clampContent(msg.content, MESSAGE_CONTENT_CLAMP_CHARS)
|
||||
: msg.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={msg.role} />
|
||||
{msg.toolName ? (
|
||||
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
|
||||
{msg.toolName}
|
||||
</span>
|
||||
) : null}
|
||||
{msg.toolCallId && !msg.toolName ? (
|
||||
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
|
||||
{msg.toolCallId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{displayContent ? (
|
||||
<>
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-xs leading-5 text-content-primary",
|
||||
showClamped && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{displayContent}
|
||||
</p>
|
||||
{needsClamp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
|
||||
aria-label={`See ${expanded ? "less" : "more"} of ${msg.role} message`}
|
||||
>
|
||||
{expanded ? "see less" : "see more"}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageRow: FC<MessageRowProps> = ({ msg, clamp }) => {
|
||||
if (msg.kind === "tool-call" || msg.kind === "tool-result") {
|
||||
return <TranscriptToolRow msg={msg} />;
|
||||
}
|
||||
|
||||
return <TranscriptTextRow msg={msg} clamp={clamp} />;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { coerceStepResponse } from "./debugPanelUtils";
|
||||
|
||||
describe("coerceStepResponse", () => {
|
||||
it("keeps tool-result content emitted in normalized response parts", () => {
|
||||
const response = coerceStepResponse({
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
tool_call_id: "call-1",
|
||||
tool_name: "search_docs",
|
||||
result: {
|
||||
matches: ["model.go", "debugPanelUtils.ts"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(response.content);
|
||||
expect(parsed).toEqual({
|
||||
matches: ["model.go", "debugPanelUtils.ts"],
|
||||
});
|
||||
expect(response.toolCalls).toEqual([]);
|
||||
expect(response.usage).toEqual({});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
// Debug-specific query factories live here rather than in the
|
||||
// shared site/src/api/queries/chats.ts to keep the main chat
|
||||
// queries module focused on core chat operations.
|
||||
|
||||
import { API } from "#/api/api";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal status detection (shared by list and detail queries).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const debugRunTerminalStatuses = new Set(["completed", "error", "interrupted"]);
|
||||
|
||||
const debugRunRefetchInterval = (
|
||||
run: Pick<TypesGen.ChatDebugRun, "status"> | undefined,
|
||||
hasError?: boolean,
|
||||
): number | false => {
|
||||
if (hasError) {
|
||||
return false;
|
||||
}
|
||||
if (run?.status && debugRunTerminalStatuses.has(run.status.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return 5_000;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query factories.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const chatDebugRunsKey = (chatId: string) =>
|
||||
["chats", chatId, "debug-runs"] as const;
|
||||
|
||||
export const chatDebugRuns = (chatId: string) => ({
|
||||
queryKey: chatDebugRunsKey(chatId),
|
||||
queryFn: () => API.experimental.getChatDebugRuns(chatId),
|
||||
refetchInterval: ({
|
||||
state,
|
||||
}: {
|
||||
state: {
|
||||
data?: TypesGen.ChatDebugRunSummary[] | undefined;
|
||||
status: string;
|
||||
};
|
||||
}): number | false => {
|
||||
if (state.status === "error") {
|
||||
return false;
|
||||
}
|
||||
// Keep polling at a consistent foreground cadence while the
|
||||
// Debug tab is open. A slower terminal-state interval delays
|
||||
// discovery of newly-started runs until the user switches tabs.
|
||||
return 5_000;
|
||||
},
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
export const chatDebugRun = (chatId: string, runId: string) => ({
|
||||
queryKey: [...chatDebugRunsKey(chatId), runId] as const,
|
||||
queryFn: () => API.experimental.getChatDebugRun(chatId, runId),
|
||||
refetchInterval: ({
|
||||
state,
|
||||
}: {
|
||||
state: { data: TypesGen.ChatDebugRun | undefined; status: string };
|
||||
}) => debugRunRefetchInterval(state.data, state.status === "error"),
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { type FC, useEffect, useId, useRef, useState } from "react";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { DesktopPanel } from "../RightPanel/DesktopPanel";
|
||||
import { getEffectiveTabId } from "./getEffectiveTabId";
|
||||
|
||||
/** A single tab definition for the sidebar panel. */
|
||||
export interface SidebarTab {
|
||||
@@ -115,23 +116,8 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
onActiveTabChange,
|
||||
}) => {
|
||||
const tabIdPrefix = useId();
|
||||
// Build the full list of tab IDs including the desktop tab
|
||||
// so that effectiveTabId validation covers it.
|
||||
const allTabIds = new Set(tabs.map((t) => t.id));
|
||||
if (desktopChatId) {
|
||||
allTabIds.add("desktop");
|
||||
}
|
||||
|
||||
// Derive the effective tab. Fall back to the first tab if
|
||||
// the stored activeTabId no longer matches any tab in the list.
|
||||
const effectiveTabId =
|
||||
activeTabId !== null && allTabIds.has(activeTabId)
|
||||
? activeTabId
|
||||
: tabs.length > 0
|
||||
? tabs[0].id
|
||||
: desktopChatId
|
||||
? "desktop"
|
||||
: null;
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const effectiveTabId = getEffectiveTabId(tabIds, activeTabId, desktopChatId);
|
||||
|
||||
// Unified list of panels for rendering. Includes the desktop
|
||||
// tab when available so we don't need to special-case it.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Resolves which sidebar tab should be active given the set of
|
||||
* available tab IDs, the currently stored selection, and whether
|
||||
* the desktop chat tab is available.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `activeTabId` when it matches a known tab.
|
||||
* 2. The first entry in `tabIds` (ordered array, not a Set).
|
||||
* 3. `"desktop"` when `desktopChatId` is truthy.
|
||||
* 4. `null` (no valid tab available).
|
||||
*
|
||||
* This function is shared between AgentChatPageView (which needs
|
||||
* the effective tab before constructing tab content) and
|
||||
* SidebarTabView (which needs it to drive CSS visibility and the
|
||||
* active indicator). Keeping one implementation prevents the
|
||||
* fallback logic from drifting between the two call sites.
|
||||
*/
|
||||
export function getEffectiveTabId(
|
||||
tabIds: readonly string[],
|
||||
activeTabId: string | null,
|
||||
desktopChatId: string | undefined,
|
||||
): string | null {
|
||||
const allIds = new Set(tabIds);
|
||||
if (desktopChatId) {
|
||||
allIds.add("desktop");
|
||||
}
|
||||
|
||||
if (activeTabId !== null && allIds.has(activeTabId)) {
|
||||
return activeTabId;
|
||||
}
|
||||
|
||||
return tabIds[0] ?? (desktopChatId ? "desktop" : null);
|
||||
}
|
||||
Reference in New Issue
Block a user