Compare commits

...

9 Commits

Author SHA1 Message Date
Thomas Kosiewski 01358ab917 test(site/AgentsPage): add Debug panel Storybook stories
Add comprehensive Storybook stories covering the Debug panel in
various states: loading, empty, single/multiple runs, expanded steps,
tool calls, error states, and streaming indicators.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:36 +02:00
Thomas Kosiewski 5282a77b41 feat(site/AgentsPage): add Debug panel components and settings
Add the Debug panel UI components: run list, run cards, step cards
with transcript and tool-call rendering, attempt accordions, and
shared primitives. Wire the panel into the AgentChatPage sidebar and
add the per-chat debug logging toggle in the behavior settings page.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:29 +02:00
Thomas Kosiewski 1c5ad426f2 feat(site): add chat debug API layer and panel utilities
Add API client methods, React Query builders, and unit tests for the
chat debug endpoints. Add debugPanelUtils with coercion helpers that
transform raw debug step data into structured display models for the
Debug panel, and wire debug run streaming into the chat store.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:21 +02:00
Thomas Kosiewski 1d1128127c feat(coderd): add chat debug HTTP handlers and API docs
Add REST endpoints for listing debug runs by chat, fetching debug
steps by run, toggling per-chat debug log overrides, and managing the
deployment-wide debug logging setting. Register routes and generate
swagger documentation.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:21 +02:00
Thomas Kosiewski 656ab1bccd feat(coderd/x/chatd): wire debug logging into chat lifecycle
Instrument the chat server, chat loop, compaction, quickgen, and chat
provider with debug run recording. Each chat turn, compaction, and
title generation now creates debug runs with steps that capture
normalized request/response data for diagnostics.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:20 +02:00
Thomas Kosiewski 3977c015c0 feat(coderd/x/chatd/chatdebug): add service and summary aggregation
Add the debug service that orchestrates run/step creation, updates, and
finalization against the database, and the summary aggregation layer
that computes first-message labels and run-level statistics from
completed debug steps.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:20 +02:00
Thomas Kosiewski 0c8b810af6 feat(coderd/x/chatd/chatdebug): add recorder, transport, and redaction
Add the recording transport that wraps HTTP round-trips to capture
LLM request/response pairs, the step recorder that manages debug step
lifecycle, and the redaction layer that strips sensitive headers and
truncates large payloads before persistence.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:20 +02:00
Thomas Kosiewski 9020947a45 feat(coderd/x/chatd/chatdebug): add types, context, and model normalization
Add the chatdebug package foundations: shared types for debug runs and
steps, context helpers for propagating debug state through the request
chain, and the model normalization layer that converts provider-specific
LLM request/response formats into a canonical bounded representation.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:20 +02:00
Thomas Kosiewski d9bc758184 feat(coderd/database): add chat debug log tables, queries, and SDK types
Add database persistence for chat debug logging, including migration
for chat_debug_runs and chat_debug_steps tables, SQLC queries, dbauthz
wrappers, site config for the debug logging toggle, and SDK types for
the chat debug API.

Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-01 15:24:19 +02:00
70 changed files with 12590 additions and 116 deletions
+414
View File
@@ -612,6 +612,130 @@ const docTemplate = `{
}
}
},
"/chats/config/debug-logging": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Get chat debug logging setting",
"operationId": "get-chat-debug-logging",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugSettings"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"put": {
"consumes": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Update chat debug logging setting",
"operationId": "update-chat-debug-logging",
"parameters": [
{
"description": "Update request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatDebugLoggingRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/config/user-debug-logging": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Get user chat debug logging setting",
"operationId": "get-user-chat-debug-logging",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugSettings"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"put": {
"consumes": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Update user chat debug logging setting",
"operationId": "update-user-chat-debug-logging",
"parameters": [
{
"description": "Update request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatDebugLoggingRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/insights/pull-requests": {
"get": {
"produces": [
@@ -656,6 +780,90 @@ const docTemplate = `{
}
}
},
"/chats/{chat}/debug/runs": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "List chat debug runs",
"operationId": "list-chat-debug-runs",
"parameters": [
{
"type": "string",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatDebugRunSummary"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/{chat}/debug/runs/{run}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Get chat debug run",
"operationId": "get-chat-debug-run",
"parameters": [
{
"type": "string",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Run ID",
"name": "run",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugRun"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/connectionlog": {
"get": {
"produces": [
@@ -14377,6 +14585,204 @@ const docTemplate = `{
}
}
},
"codersdk.ChatDebugRun": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"format": "uuid"
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"history_tip_message_id": {
"type": "integer"
},
"id": {
"type": "string",
"format": "uuid"
},
"kind": {
"type": "string"
},
"model": {
"type": "string"
},
"model_config_id": {
"type": "string",
"format": "uuid"
},
"parent_chat_id": {
"type": "string",
"format": "uuid"
},
"provider": {
"type": "string"
},
"root_chat_id": {
"type": "string",
"format": "uuid"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatDebugStep"
}
},
"summary": {
"type": "array",
"items": {
"type": "integer"
}
},
"trigger_message_id": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.ChatDebugRunSummary": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"format": "uuid"
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"kind": {
"type": "string"
},
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"summary": {
"type": "array",
"items": {
"type": "integer"
}
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.ChatDebugSettings": {
"type": "object",
"properties": {
"debug_logging_enabled": {
"type": "boolean"
}
}
},
"codersdk.ChatDebugStep": {
"type": "object",
"properties": {
"assistant_message_id": {
"type": "integer"
},
"attempts": {
"type": "array",
"items": {
"type": "integer"
}
},
"chat_id": {
"type": "string",
"format": "uuid"
},
"error": {
"type": "array",
"items": {
"type": "integer"
}
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"history_tip_message_id": {
"type": "integer"
},
"id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "array",
"items": {
"type": "integer"
}
},
"normalized_request": {
"type": "array",
"items": {
"type": "integer"
}
},
"normalized_response": {
"type": "array",
"items": {
"type": "integer"
}
},
"operation": {
"type": "string"
},
"run_id": {
"type": "string",
"format": "uuid"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"step_number": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"usage": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
@@ -20892,6 +21298,14 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateChatDebugLoggingRequest": {
"type": "object",
"properties": {
"debug_logging_enabled": {
"type": "boolean"
}
}
},
"codersdk.UpdateCheckResponse": {
"type": "object",
"properties": {
+390
View File
@@ -529,6 +529,114 @@
}
}
},
"/chats/config/debug-logging": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get chat debug logging setting",
"operationId": "get-chat-debug-logging",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugSettings"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"put": {
"consumes": ["application/json"],
"tags": ["Chats"],
"summary": "Update chat debug logging setting",
"operationId": "update-chat-debug-logging",
"parameters": [
{
"description": "Update request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatDebugLoggingRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/config/user-debug-logging": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get user chat debug logging setting",
"operationId": "get-user-chat-debug-logging",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugSettings"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"put": {
"consumes": ["application/json"],
"tags": ["Chats"],
"summary": "Update user chat debug logging setting",
"operationId": "update-user-chat-debug-logging",
"parameters": [
{
"description": "Update request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatDebugLoggingRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/insights/pull-requests": {
"get": {
"produces": ["application/json"],
@@ -569,6 +677,82 @@
}
}
},
"/chats/{chat}/debug/runs": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "List chat debug runs",
"operationId": "list-chat-debug-runs",
"parameters": [
{
"type": "string",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatDebugRunSummary"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/chats/{chat}/debug/runs/{run}": {
"get": {
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get chat debug run",
"operationId": "get-chat-debug-run",
"parameters": [
{
"type": "string",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Run ID",
"name": "run",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatDebugRun"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/connectionlog": {
"get": {
"produces": ["application/json"],
@@ -12920,6 +13104,204 @@
}
}
},
"codersdk.ChatDebugRun": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"format": "uuid"
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"history_tip_message_id": {
"type": "integer"
},
"id": {
"type": "string",
"format": "uuid"
},
"kind": {
"type": "string"
},
"model": {
"type": "string"
},
"model_config_id": {
"type": "string",
"format": "uuid"
},
"parent_chat_id": {
"type": "string",
"format": "uuid"
},
"provider": {
"type": "string"
},
"root_chat_id": {
"type": "string",
"format": "uuid"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatDebugStep"
}
},
"summary": {
"type": "array",
"items": {
"type": "integer"
}
},
"trigger_message_id": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.ChatDebugRunSummary": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"format": "uuid"
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"kind": {
"type": "string"
},
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"summary": {
"type": "array",
"items": {
"type": "integer"
}
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.ChatDebugSettings": {
"type": "object",
"properties": {
"debug_logging_enabled": {
"type": "boolean"
}
}
},
"codersdk.ChatDebugStep": {
"type": "object",
"properties": {
"assistant_message_id": {
"type": "integer"
},
"attempts": {
"type": "array",
"items": {
"type": "integer"
}
},
"chat_id": {
"type": "string",
"format": "uuid"
},
"error": {
"type": "array",
"items": {
"type": "integer"
}
},
"finished_at": {
"type": "string",
"format": "date-time"
},
"history_tip_message_id": {
"type": "integer"
},
"id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "array",
"items": {
"type": "integer"
}
},
"normalized_request": {
"type": "array",
"items": {
"type": "integer"
}
},
"normalized_response": {
"type": "array",
"items": {
"type": "integer"
}
},
"operation": {
"type": "string"
},
"run_id": {
"type": "string",
"format": "uuid"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"status": {
"type": "string"
},
"step_number": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"usage": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"codersdk.ConnectionLatency": {
"type": "object",
"properties": {
@@ -19183,6 +19565,14 @@
}
}
},
"codersdk.UpdateChatDebugLoggingRequest": {
"type": "object",
"properties": {
"debug_logging_enabled": {
"type": "boolean"
}
}
},
"codersdk.UpdateCheckResponse": {
"type": "object",
"properties": {
+8
View File
@@ -1180,6 +1180,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.getChatDebugLoggingEnabled)
r.Put("/debug-logging", api.putChatDebugLoggingEnabled)
r.Get("/user-debug-logging", api.getUserChatDebugLoggingEnabled)
r.Put("/user-debug-logging", api.putUserChatDebugLoggingEnabled)
r.Get("/user-prompt", api.getUserChatCustomPrompt)
r.Put("/user-prompt", api.putUserChatCustomPrompt)
r.Get("/user-compaction-thresholds", api.getUserChatCompactionThresholds)
@@ -1240,6 +1244,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/{run}", api.getChatDebugRun)
})
})
})
+69
View File
@@ -1516,6 +1516,30 @@ func nullInt64Ptr(v sql.NullInt64) *int64 {
return &value
}
func nullStringPtr(v sql.NullString) *string {
if !v.Valid {
return nil
}
value := v.String
return &value
}
func nullTimePtr(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
value := v.Time
return &value
}
func nullRawMessagePtr(v pqtype.NullRawMessage) *json.RawMessage {
if !v.Valid {
return nil
}
value := v.RawMessage
return &value
}
// Chat converts a database.Chat to a codersdk.Chat. It coalesces
// nil slices and maps to empty values for JSON serialization and
// derives RootChatID from the parent chain when not explicitly set.
@@ -1559,6 +1583,10 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
rootChatID := c.ID
chat.RootChatID = &rootChatID
}
if c.DebugLogsEnabledOverride.Valid {
val := c.DebugLogsEnabledOverride.Bool
chat.DebugLogsEnabledOverride = &val
}
if c.WorkspaceID.Valid {
chat.WorkspaceID = &c.WorkspaceID.UUID
}
@@ -1586,6 +1614,47 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
return chat
}
// ChatDebugRunSummary converts a database.ChatDebugRun to a
// codersdk.ChatDebugRunSummary.
func ChatDebugRunSummary(r database.ChatDebugRun) codersdk.ChatDebugRunSummary {
return codersdk.ChatDebugRunSummary{
ID: r.ID,
ChatID: r.ChatID,
Kind: r.Kind,
Status: r.Status,
Provider: nullStringPtr(r.Provider),
Model: nullStringPtr(r.Model),
Summary: r.Summary,
StartedAt: r.StartedAt,
UpdatedAt: r.UpdatedAt,
FinishedAt: nullTimePtr(r.FinishedAt),
}
}
// ChatDebugStep converts a database.ChatDebugStep to a
// codersdk.ChatDebugStep.
func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep {
return codersdk.ChatDebugStep{
ID: s.ID,
RunID: s.RunID,
ChatID: s.ChatID,
StepNumber: s.StepNumber,
Operation: s.Operation,
Status: s.Status,
HistoryTipMessageID: nullInt64Ptr(s.HistoryTipMessageID),
AssistantMessageID: nullInt64Ptr(s.AssistantMessageID),
NormalizedRequest: s.NormalizedRequest,
NormalizedResponse: nullRawMessagePtr(s.NormalizedResponse),
Usage: nullRawMessagePtr(s.Usage),
Attempts: s.Attempts,
Error: nullRawMessagePtr(s.Error),
Metadata: s.Metadata,
StartedAt: s.StartedAt,
UpdatedAt: s.UpdatedAt,
FinishedAt: nullTimePtr(s.FinishedAt),
}
}
// 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,
+154
View File
@@ -1842,6 +1842,28 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u
return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
}
func (q *querier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.DeleteChatDebugDataAfterMessageID(ctx, arg)
}
func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) {
chat, err := q.db.GetChatByID(ctx, chatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.DeleteChatDebugDataByChatID(ctx, chatID)
}
func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
@@ -2309,6 +2331,15 @@ func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context,
return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
}
func (q *querier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
// FinalizeStaleChatDebugRows is a system-level recovery operation used by
// chat debug cleanup workers.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return database.FinalizeStaleChatDebugRowsRow{}, err
}
return q.db.FinalizeStaleChatDebugRows(ctx, updatedBefore)
}
func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) {
_, err := q.GetTemplateVersionByID(ctx, arg.TemplateVersionID)
if err != nil {
@@ -2513,6 +2544,45 @@ func (q *querier) GetChatCostSummary(ctx context.Context, arg database.GetChatCo
return q.db.GetChatCostSummary(ctx, arg)
}
func (q *querier) GetChatDebugLoggingEnabled(ctx context.Context) (bool, error) {
// The debug-logging flag is a deployment-wide setting read by authenticated
// chat users and background chat services. Require an explicit actor so
// unauthenticated calls fail closed.
if _, ok := ActorFromContext(ctx); !ok {
return false, ErrNoActor
}
return q.db.GetChatDebugLoggingEnabled(ctx)
}
func (q *querier) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) {
run, err := q.db.GetChatDebugRunByID(ctx, id)
if err != nil {
return database.ChatDebugRun{}, err
}
if _, err := q.GetChatByID(ctx, run.ChatID); err != nil {
return database.ChatDebugRun{}, err
}
return run, nil
}
func (q *querier) GetChatDebugRunsByChat(ctx context.Context, chatID uuid.UUID) ([]database.ChatDebugRun, error) {
if _, err := q.GetChatByID(ctx, chatID); err != nil {
return nil, err
}
return q.db.GetChatDebugRunsByChat(ctx, chatID)
}
func (q *querier) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) {
run, err := q.db.GetChatDebugRunByID(ctx, runID)
if err != nil {
return nil, err
}
if _, err := q.GetChatByID(ctx, run.ChatID); err != nil {
return nil, err
}
return q.db.GetChatDebugStepsByRunID(ctx, runID)
}
func (q *querier) GetChatDesktopEnabled(ctx context.Context) (bool, error) {
// The desktop-enabled flag is a deployment-wide setting read by any
// authenticated chat user and by chatd when deciding whether to expose
@@ -4024,6 +4094,17 @@ func (q *querier) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID)
return q.db.GetUserChatCustomPrompt(ctx, userID)
}
func (q *querier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
u, err := q.db.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil {
return false, err
}
return q.db.GetUserChatDebugLoggingEnabled(ctx, userID)
}
func (q *querier) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
return 0, err
@@ -4772,6 +4853,28 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams)
return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg)
}
func (q *querier) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return database.ChatDebugRun{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.ChatDebugRun{}, err
}
return q.db.InsertChatDebugRun(ctx, arg)
}
func (q *querier) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return database.ChatDebugStep{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.ChatDebugStep{}, err
}
return q.db.InsertChatDebugStep(ctx, arg)
}
func (q *querier) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
// Authorize create on chat resource scoped to the owner and org.
return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChatFile)(ctx, arg)
@@ -5738,6 +5841,39 @@ func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByI
return q.db.UpdateChatByID(ctx, arg)
}
func (q *querier) UpdateChatDebugLogsEnabledOverride(ctx context.Context, arg database.UpdateChatDebugLogsEnabledOverrideParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatDebugLogsEnabledOverride(ctx, arg)
}
func (q *querier) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return database.ChatDebugRun{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.ChatDebugRun{}, err
}
return q.db.UpdateChatDebugRun(ctx, arg)
}
func (q *querier) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return database.ChatDebugStep{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.ChatDebugStep{}, err
}
return q.db.UpdateChatDebugStep(ctx, arg)
}
func (q *querier) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
@@ -6951,6 +7087,13 @@ func (q *querier) UpsertBoundaryUsageStats(ctx context.Context, arg database.Ups
return q.db.UpsertBoundaryUsageStats(ctx, arg)
}
func (q *querier) UpsertChatDebugLoggingEnabled(ctx context.Context, debugLoggingEnabled bool) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatDebugLoggingEnabled(ctx, debugLoggingEnabled)
}
func (q *querier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
@@ -7181,6 +7324,17 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
return q.db.UpsertTemplateUsageStats(ctx)
}
func (q *querier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error {
u, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil {
return err
}
return q.db.UpsertUserChatDebugLoggingEnabled(ctx, arg)
}
func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
+120
View File
@@ -400,6 +400,22 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C
return r0
}
func (m queryMetricsStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteChatDebugDataAfterMessageID(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteChatDebugDataAfterMessageID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatDebugDataAfterMessageID").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.DeleteChatDebugDataByChatID(ctx, chatID)
m.queryLatencies.WithLabelValues("DeleteChatDebugDataByChatID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatDebugDataByChatID").Inc()
return r0, r1
}
func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteChatModelConfigByID(ctx, id)
@@ -832,6 +848,14 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.
return r0, r1
}
func (m queryMetricsStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
start := time.Now()
r0, r1 := m.s.FinalizeStaleChatDebugRows(ctx, updatedBefore)
m.queryLatencies.WithLabelValues("FinalizeStaleChatDebugRows").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "FinalizeStaleChatDebugRows").Inc()
return r0, r1
}
func (m queryMetricsStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) {
start := time.Now()
r0, r1 := m.s.FindMatchingPresetID(ctx, arg)
@@ -1080,6 +1104,38 @@ func (m queryMetricsStore) GetChatCostSummary(ctx context.Context, arg database.
return r0, r1
}
func (m queryMetricsStore) GetChatDebugLoggingEnabled(ctx context.Context) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetChatDebugLoggingEnabled(ctx)
m.queryLatencies.WithLabelValues("GetChatDebugLoggingEnabled").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugLoggingEnabled").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) {
start := time.Now()
r0, r1 := m.s.GetChatDebugRunByID(ctx, id)
m.queryLatencies.WithLabelValues("GetChatDebugRunByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugRunByID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDebugRunsByChat(ctx context.Context, chatID uuid.UUID) ([]database.ChatDebugRun, error) {
start := time.Now()
r0, r1 := m.s.GetChatDebugRunsByChat(ctx, chatID)
m.queryLatencies.WithLabelValues("GetChatDebugRunsByChat").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugRunsByChat").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) {
start := time.Now()
r0, r1 := m.s.GetChatDebugStepsByRunID(ctx, runID)
m.queryLatencies.WithLabelValues("GetChatDebugStepsByRunID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugStepsByRunID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDesktopEnabled(ctx context.Context) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetChatDesktopEnabled(ctx)
@@ -2528,6 +2584,14 @@ func (m queryMetricsStore) GetUserChatCustomPrompt(ctx context.Context, userID u
return r0, r1
}
func (m queryMetricsStore) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetUserChatDebugLoggingEnabled(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserChatDebugLoggingEnabled").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserChatDebugLoggingEnabled").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetUserChatSpendInPeriod(ctx, arg)
@@ -3224,6 +3288,22 @@ func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertCh
return r0, r1
}
func (m queryMetricsStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) {
start := time.Now()
r0, r1 := m.s.InsertChatDebugRun(ctx, arg)
m.queryLatencies.WithLabelValues("InsertChatDebugRun").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatDebugRun").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
start := time.Now()
r0, r1 := m.s.InsertChatDebugStep(ctx, arg)
m.queryLatencies.WithLabelValues("InsertChatDebugStep").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatDebugStep").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
start := time.Now()
r0, r1 := m.s.InsertChatFile(ctx, arg)
@@ -4096,6 +4176,30 @@ func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.Upda
return r0, r1
}
func (m queryMetricsStore) UpdateChatDebugLogsEnabledOverride(ctx context.Context, arg database.UpdateChatDebugLogsEnabledOverrideParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatDebugLogsEnabledOverride(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatDebugLogsEnabledOverride").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatDebugLogsEnabledOverride").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatDebugRun(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatDebugRun").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatDebugRun").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatDebugStep(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatDebugStep").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatDebugStep").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatHeartbeat(ctx, arg)
@@ -4920,6 +5024,14 @@ func (m queryMetricsStore) UpsertBoundaryUsageStats(ctx context.Context, arg dat
return r0, r1
}
func (m queryMetricsStore) UpsertChatDebugLoggingEnabled(ctx context.Context, debugLoggingEnabled bool) error {
start := time.Now()
r0 := m.s.UpsertChatDebugLoggingEnabled(ctx, debugLoggingEnabled)
m.queryLatencies.WithLabelValues("UpsertChatDebugLoggingEnabled").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatDebugLoggingEnabled").Inc()
return r0
}
func (m queryMetricsStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error {
start := time.Now()
r0 := m.s.UpsertChatDesktopEnabled(ctx, enableDesktop)
@@ -5152,6 +5264,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
return r0
}
func (m queryMetricsStore) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error {
start := time.Now()
r0 := m.s.UpsertUserChatDebugLoggingEnabled(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertUserChatDebugLoggingEnabled").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserChatDebugLoggingEnabled").Inc()
return r0
}
func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
start := time.Now()
r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg)
+223
View File
@@ -643,6 +643,36 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID)
}
// DeleteChatDebugDataAfterMessageID mocks base method.
func (m *MockStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatDebugDataAfterMessageID", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteChatDebugDataAfterMessageID indicates an expected call of DeleteChatDebugDataAfterMessageID.
func (mr *MockStoreMockRecorder) DeleteChatDebugDataAfterMessageID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatDebugDataAfterMessageID", reflect.TypeOf((*MockStore)(nil).DeleteChatDebugDataAfterMessageID), ctx, arg)
}
// DeleteChatDebugDataByChatID mocks base method.
func (m *MockStore) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatDebugDataByChatID", ctx, chatID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteChatDebugDataByChatID indicates an expected call of DeleteChatDebugDataByChatID.
func (mr *MockStoreMockRecorder) DeleteChatDebugDataByChatID(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatDebugDataByChatID", reflect.TypeOf((*MockStore)(nil).DeleteChatDebugDataByChatID), ctx, chatID)
}
// DeleteChatModelConfigByID mocks base method.
func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
@@ -1414,6 +1444,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, u
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt)
}
// FinalizeStaleChatDebugRows mocks base method.
func (m *MockStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FinalizeStaleChatDebugRows", ctx, updatedBefore)
ret0, _ := ret[0].(database.FinalizeStaleChatDebugRowsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FinalizeStaleChatDebugRows indicates an expected call of FinalizeStaleChatDebugRows.
func (mr *MockStoreMockRecorder) FinalizeStaleChatDebugRows(ctx, updatedBefore any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalizeStaleChatDebugRows", reflect.TypeOf((*MockStore)(nil).FinalizeStaleChatDebugRows), ctx, updatedBefore)
}
// FindMatchingPresetID mocks base method.
func (m *MockStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) {
m.ctrl.T.Helper()
@@ -1984,6 +2029,66 @@ func (mr *MockStoreMockRecorder) GetChatCostSummary(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostSummary", reflect.TypeOf((*MockStore)(nil).GetChatCostSummary), ctx, arg)
}
// GetChatDebugLoggingEnabled mocks base method.
func (m *MockStore) GetChatDebugLoggingEnabled(ctx context.Context) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatDebugLoggingEnabled", ctx)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatDebugLoggingEnabled indicates an expected call of GetChatDebugLoggingEnabled.
func (mr *MockStoreMockRecorder) GetChatDebugLoggingEnabled(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).GetChatDebugLoggingEnabled), ctx)
}
// GetChatDebugRunByID mocks base method.
func (m *MockStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatDebugRunByID", ctx, id)
ret0, _ := ret[0].(database.ChatDebugRun)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatDebugRunByID indicates an expected call of GetChatDebugRunByID.
func (mr *MockStoreMockRecorder) GetChatDebugRunByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugRunByID", reflect.TypeOf((*MockStore)(nil).GetChatDebugRunByID), ctx, id)
}
// GetChatDebugRunsByChat mocks base method.
func (m *MockStore) GetChatDebugRunsByChat(ctx context.Context, chatID uuid.UUID) ([]database.ChatDebugRun, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatDebugRunsByChat", ctx, chatID)
ret0, _ := ret[0].([]database.ChatDebugRun)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatDebugRunsByChat indicates an expected call of GetChatDebugRunsByChat.
func (mr *MockStoreMockRecorder) GetChatDebugRunsByChat(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugRunsByChat", reflect.TypeOf((*MockStore)(nil).GetChatDebugRunsByChat), ctx, chatID)
}
// GetChatDebugStepsByRunID mocks base method.
func (m *MockStore) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]database.ChatDebugStep, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatDebugStepsByRunID", ctx, runID)
ret0, _ := ret[0].([]database.ChatDebugStep)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatDebugStepsByRunID indicates an expected call of GetChatDebugStepsByRunID.
func (mr *MockStoreMockRecorder) GetChatDebugStepsByRunID(ctx, runID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugStepsByRunID", reflect.TypeOf((*MockStore)(nil).GetChatDebugStepsByRunID), ctx, runID)
}
// GetChatDesktopEnabled mocks base method.
func (m *MockStore) GetChatDesktopEnabled(ctx context.Context) (bool, error) {
m.ctrl.T.Helper()
@@ -4729,6 +4834,21 @@ func (mr *MockStoreMockRecorder) GetUserChatCustomPrompt(ctx, userID any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatCustomPrompt", reflect.TypeOf((*MockStore)(nil).GetUserChatCustomPrompt), ctx, userID)
}
// GetUserChatDebugLoggingEnabled mocks base method.
func (m *MockStore) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserChatDebugLoggingEnabled", ctx, userID)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserChatDebugLoggingEnabled indicates an expected call of GetUserChatDebugLoggingEnabled.
func (mr *MockStoreMockRecorder) GetUserChatDebugLoggingEnabled(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).GetUserChatDebugLoggingEnabled), ctx, userID)
}
// GetUserChatSpendInPeriod mocks base method.
func (m *MockStore) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
m.ctrl.T.Helper()
@@ -6048,6 +6168,36 @@ func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg)
}
// InsertChatDebugRun mocks base method.
func (m *MockStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertChatDebugRun", ctx, arg)
ret0, _ := ret[0].(database.ChatDebugRun)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertChatDebugRun indicates an expected call of InsertChatDebugRun.
func (mr *MockStoreMockRecorder) InsertChatDebugRun(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatDebugRun", reflect.TypeOf((*MockStore)(nil).InsertChatDebugRun), ctx, arg)
}
// InsertChatDebugStep mocks base method.
func (m *MockStore) InsertChatDebugStep(ctx context.Context, arg database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertChatDebugStep", ctx, arg)
ret0, _ := ret[0].(database.ChatDebugStep)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertChatDebugStep indicates an expected call of InsertChatDebugStep.
func (mr *MockStoreMockRecorder) InsertChatDebugStep(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatDebugStep", reflect.TypeOf((*MockStore)(nil).InsertChatDebugStep), ctx, arg)
}
// InsertChatFile mocks base method.
func (m *MockStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
m.ctrl.T.Helper()
@@ -7762,6 +7912,51 @@ func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg)
}
// UpdateChatDebugLogsEnabledOverride mocks base method.
func (m *MockStore) UpdateChatDebugLogsEnabledOverride(ctx context.Context, arg database.UpdateChatDebugLogsEnabledOverrideParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatDebugLogsEnabledOverride", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatDebugLogsEnabledOverride indicates an expected call of UpdateChatDebugLogsEnabledOverride.
func (mr *MockStoreMockRecorder) UpdateChatDebugLogsEnabledOverride(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatDebugLogsEnabledOverride", reflect.TypeOf((*MockStore)(nil).UpdateChatDebugLogsEnabledOverride), ctx, arg)
}
// UpdateChatDebugRun mocks base method.
func (m *MockStore) UpdateChatDebugRun(ctx context.Context, arg database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatDebugRun", ctx, arg)
ret0, _ := ret[0].(database.ChatDebugRun)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatDebugRun indicates an expected call of UpdateChatDebugRun.
func (mr *MockStoreMockRecorder) UpdateChatDebugRun(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatDebugRun", reflect.TypeOf((*MockStore)(nil).UpdateChatDebugRun), ctx, arg)
}
// UpdateChatDebugStep mocks base method.
func (m *MockStore) UpdateChatDebugStep(ctx context.Context, arg database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatDebugStep", ctx, arg)
ret0, _ := ret[0].(database.ChatDebugStep)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatDebugStep indicates an expected call of UpdateChatDebugStep.
func (mr *MockStoreMockRecorder) UpdateChatDebugStep(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatDebugStep", reflect.TypeOf((*MockStore)(nil).UpdateChatDebugStep), ctx, arg)
}
// UpdateChatHeartbeat mocks base method.
func (m *MockStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
m.ctrl.T.Helper()
@@ -9253,6 +9448,20 @@ func (mr *MockStoreMockRecorder) UpsertBoundaryUsageStats(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertBoundaryUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertBoundaryUsageStats), ctx, arg)
}
// UpsertChatDebugLoggingEnabled mocks base method.
func (m *MockStore) UpsertChatDebugLoggingEnabled(ctx context.Context, debugLoggingEnabled bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatDebugLoggingEnabled", ctx, debugLoggingEnabled)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatDebugLoggingEnabled indicates an expected call of UpsertChatDebugLoggingEnabled.
func (mr *MockStoreMockRecorder) UpsertChatDebugLoggingEnabled(ctx, debugLoggingEnabled any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).UpsertChatDebugLoggingEnabled), ctx, debugLoggingEnabled)
}
// UpsertChatDesktopEnabled mocks base method.
func (m *MockStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error {
m.ctrl.T.Helper()
@@ -9671,6 +9880,20 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx)
}
// UpsertUserChatDebugLoggingEnabled mocks base method.
func (m *MockStore) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg database.UpsertUserChatDebugLoggingEnabledParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertUserChatDebugLoggingEnabled", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertUserChatDebugLoggingEnabled indicates an expected call of UpsertUserChatDebugLoggingEnabled.
func (mr *MockStoreMockRecorder) UpsertUserChatDebugLoggingEnabled(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).UpsertUserChatDebugLoggingEnabled), ctx, arg)
}
// UpsertWebpushVAPIDKeys mocks base method.
func (m *MockStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
m.ctrl.T.Helper()
+65 -1
View File
@@ -1238,6 +1238,44 @@ COMMENT ON COLUMN boundary_usage_stats.window_start IS 'Start of the time window
COMMENT ON COLUMN boundary_usage_stats.updated_at IS 'Timestamp of the last update to this row.';
CREATE TABLE chat_debug_runs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
chat_id uuid NOT NULL,
root_chat_id uuid,
parent_chat_id uuid,
model_config_id uuid,
trigger_message_id bigint,
history_tip_message_id bigint,
kind text NOT NULL,
status text NOT NULL,
provider text,
model text,
summary jsonb DEFAULT '{}'::jsonb NOT NULL,
started_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
finished_at timestamp with time zone
);
CREATE TABLE chat_debug_steps (
id uuid DEFAULT gen_random_uuid() NOT NULL,
run_id uuid NOT NULL,
chat_id uuid NOT NULL,
step_number integer NOT NULL,
operation text NOT NULL,
status text NOT NULL,
history_tip_message_id bigint,
assistant_message_id bigint,
normalized_request jsonb NOT NULL,
normalized_response jsonb,
usage jsonb,
attempts jsonb DEFAULT '[]'::jsonb NOT NULL,
error jsonb,
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
started_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
finished_at timestamp with time zone
);
CREATE TABLE chat_diff_statuses (
chat_id uuid NOT NULL,
url text,
@@ -1404,7 +1442,8 @@ CREATE TABLE chats (
agent_id uuid,
pin_order integer DEFAULT 0 NOT NULL,
last_read_message_id bigint,
last_injected_context jsonb
last_injected_context jsonb,
debug_logs_enabled_override boolean
);
CREATE TABLE connection_logs (
@@ -3320,6 +3359,12 @@ ALTER TABLE ONLY audit_logs
ALTER TABLE ONLY boundary_usage_stats
ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id);
ALTER TABLE ONLY chat_debug_runs
ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY chat_debug_steps
ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id);
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);
@@ -3705,6 +3750,16 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id);
CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC);
CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs USING btree (chat_id, started_at DESC);
CREATE INDEX idx_chat_debug_runs_chat_status ON chat_debug_runs USING btree (chat_id, status);
CREATE INDEX idx_chat_debug_steps_chat_started ON chat_debug_steps USING btree (chat_id, started_at DESC);
CREATE INDEX idx_chat_debug_steps_chat_tip ON chat_debug_steps USING btree (chat_id, history_tip_message_id);
CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number);
CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at);
CREATE INDEX idx_chat_files_org ON chat_files USING btree (organization_id);
@@ -4006,6 +4061,15 @@ ALTER TABLE ONLY aibridge_interceptions
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_debug_runs
ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_debug_steps
ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_debug_steps
ADD CONSTRAINT chat_debug_steps_run_id_fkey FOREIGN KEY (run_id) REFERENCES chat_debug_runs(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
@@ -9,6 +9,9 @@ const (
ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatDebugStepsRunID ForeignKeyConstraint = "chat_debug_steps_run_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_run_id_fkey FOREIGN KEY (run_id) REFERENCES chat_debug_runs(id) ON DELETE CASCADE;
ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS chat_debug_steps;
DROP TABLE IF EXISTS chat_debug_runs;
ALTER TABLE chats DROP COLUMN debug_logs_enabled_override;
@@ -0,0 +1,46 @@
ALTER TABLE chats ADD COLUMN debug_logs_enabled_override BOOLEAN NULL;
CREATE TABLE chat_debug_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
root_chat_id UUID,
parent_chat_id UUID,
model_config_id UUID,
trigger_message_id BIGINT,
history_tip_message_id BIGINT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
provider TEXT,
model TEXT,
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs(chat_id, started_at DESC);
CREATE INDEX idx_chat_debug_runs_chat_status ON chat_debug_runs(chat_id, status);
CREATE TABLE chat_debug_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES chat_debug_runs(id) ON DELETE CASCADE,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
step_number INT NOT NULL,
operation TEXT NOT NULL,
status TEXT NOT NULL,
history_tip_message_id BIGINT,
assistant_message_id BIGINT,
normalized_request JSONB NOT NULL,
normalized_response JSONB,
usage JSONB,
attempts JSONB NOT NULL DEFAULT '[]'::jsonb,
error JSONB,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps(run_id, step_number);
CREATE INDEX idx_chat_debug_steps_chat_started ON chat_debug_steps(chat_id, started_at DESC);
CREATE INDEX idx_chat_debug_steps_chat_tip ON chat_debug_steps(chat_id, history_tip_message_id);
@@ -0,0 +1,65 @@
INSERT INTO chat_debug_runs (
id,
chat_id,
model_config_id,
history_tip_message_id,
kind,
status,
provider,
model,
summary,
started_at,
updated_at,
finished_at
) VALUES (
'c98518f8-9fb3-458b-a642-57552af1db63',
'72c0438a-18eb-4688-ab80-e4c6a126ef96',
'9af5f8d5-6a57-4505-8a69-3d6c787b95fd',
(SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'),
'chat_turn',
'completed',
'openai',
'gpt-5.2',
'{"step_count":1,"has_error":false}'::jsonb,
'2024-01-01 00:00:00+00',
'2024-01-01 00:00:01+00',
'2024-01-01 00:00:01+00'
);
INSERT INTO chat_debug_steps (
id,
run_id,
chat_id,
step_number,
operation,
status,
history_tip_message_id,
assistant_message_id,
normalized_request,
normalized_response,
usage,
attempts,
error,
metadata,
started_at,
updated_at,
finished_at
) VALUES (
'59471c60-7851-4fa6-bf05-e21dd939721f',
'c98518f8-9fb3-458b-a642-57552af1db63',
'72c0438a-18eb-4688-ab80-e4c6a126ef96',
1,
'stream',
'completed',
(SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'),
(SELECT MAX(id) FROM chat_messages WHERE chat_id = '72c0438a-18eb-4688-ab80-e4c6a126ef96'),
'{"messages":[]}'::jsonb,
'{"finish_reason":"stop"}'::jsonb,
'{"input_tokens":1,"output_tokens":1}'::jsonb,
'[]'::jsonb,
NULL,
'{"provider":"openai"}'::jsonb,
'2024-01-01 00:00:00+00',
'2024-01-01 00:00:01+00',
'2024-01-01 00:00:01+00'
);
+1
View File
@@ -796,6 +796,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.Chat.DebugLogsEnabledOverride,
&i.HasUnread); err != nil {
return nil, err
}
+60 -21
View File
@@ -4153,29 +4153,68 @@ type BoundaryUsageStat struct {
}
type Chat struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError sql.NullString `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DebugLogsEnabledOverride sql.NullBool `db:"debug_logs_enabled_override" json:"debug_logs_enabled_override"`
}
type ChatDebugRun struct {
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"`
TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
Kind string `db:"kind" json:"kind"`
Status string `db:"status" json:"status"`
Provider sql.NullString `db:"provider" json:"provider"`
Model sql.NullString `db:"model" json:"model"`
Summary json.RawMessage `db:"summary" json:"summary"`
StartedAt time.Time `db:"started_at" json:"started_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
}
type ChatDebugStep struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
RunID uuid.UUID `db:"run_id" json:"run_id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
StepNumber int32 `db:"step_number" json:"step_number"`
Operation string `db:"operation" json:"operation"`
Status string `db:"status" json:"status"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"`
NormalizedRequest json.RawMessage `db:"normalized_request" json:"normalized_request"`
NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"`
Usage pqtype.NullRawMessage `db:"usage" json:"usage"`
Attempts json.RawMessage `db:"attempts" json:"attempts"`
Error pqtype.NullRawMessage `db:"error" json:"error"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
StartedAt time.Time `db:"started_at" json:"started_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError sql.NullString `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
}
type ChatDiffStatus struct {
+15
View File
@@ -100,6 +100,8 @@ type sqlcQuerier interface {
// be recreated.
DeleteAllWebpushSubscriptions(ctx context.Context) error
DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
DeleteChatDebugDataAfterMessageID(ctx context.Context, arg DeleteChatDebugDataAfterMessageIDParams) (int64, error)
DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error)
DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error
DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error
DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error
@@ -175,6 +177,7 @@ type sqlcQuerier interface {
FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error)
FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error)
FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error)
FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error)
// FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters.
// It returns the preset ID if a match is found, or NULL if no match is found.
// The query finds presets where all preset parameters are present in the provided parameters,
@@ -238,6 +241,10 @@ type sqlcQuerier interface {
// Aggregate cost summary for a single user within a date range.
// Only counts assistant-role messages.
GetChatCostSummary(ctx context.Context, arg GetChatCostSummaryParams) (GetChatCostSummaryRow, error)
GetChatDebugLoggingEnabled(ctx context.Context) (bool, error)
GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (ChatDebugRun, error)
GetChatDebugRunsByChat(ctx context.Context, chatID uuid.UUID) ([]ChatDebugRun, error)
GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]ChatDebugStep, error)
GetChatDesktopEnabled(ctx context.Context) (bool, error)
GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error)
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
@@ -577,6 +584,7 @@ type sqlcQuerier interface {
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error)
GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error)
GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error)
GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error)
GetUserCount(ctx context.Context, includeSystem bool) (int64, error)
// Returns the minimum (most restrictive) group limit for a user.
@@ -696,6 +704,8 @@ type sqlcQuerier interface {
InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error)
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error)
InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error)
InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error)
InsertChatFile(ctx context.Context, arg InsertChatFileParams) (InsertChatFileRow, error)
InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error)
InsertChatModelConfig(ctx context.Context, arg InsertChatModelConfigParams) (ChatModelConfig, error)
@@ -854,6 +864,9 @@ type sqlcQuerier interface {
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
UpdateChatDebugLogsEnabledOverride(ctx context.Context, arg UpdateChatDebugLogsEnabledOverrideParams) (Chat, error)
UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error)
UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error)
// Bumps the heartbeat timestamp for a running chat so that other
// replicas know the worker is still alive.
UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error)
@@ -978,6 +991,7 @@ type sqlcQuerier interface {
// cumulative values for unique counts (accurate period totals). Request counts
// are always deltas, accumulated in DB. Returns true if insert, false if update.
UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error)
UpsertChatDebugLoggingEnabled(ctx context.Context, debugLoggingEnabled bool) error
UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
@@ -1015,6 +1029,7 @@ type sqlcQuerier interface {
// used to store the data, and the minutes are summed for each user and template
// combination. The result is stored in the template_usage_stats table.
UpsertTemplateUsageStats(ctx context.Context) error
UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error
UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error)
+716 -20
View File
@@ -2857,6 +2857,555 @@ func (q *sqlQuerier) UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBou
return new_period, err
}
const deleteChatDebugDataAfterMessageID = `-- name: DeleteChatDebugDataAfterMessageID :execrows
WITH affected_runs AS (
SELECT DISTINCT run.id
FROM chat_debug_runs run
WHERE run.chat_id = $1::uuid
AND run.history_tip_message_id > $2::bigint
UNION
SELECT DISTINCT step.run_id AS id
FROM chat_debug_steps step
WHERE step.chat_id = $1::uuid
AND step.assistant_message_id > $2::bigint
)
DELETE FROM chat_debug_runs
WHERE chat_id = $1::uuid
AND id IN (SELECT id FROM affected_runs)
`
type DeleteChatDebugDataAfterMessageIDParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
MessageID int64 `db:"message_id" json:"message_id"`
}
func (q *sqlQuerier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg DeleteChatDebugDataAfterMessageIDParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteChatDebugDataAfterMessageID, arg.ChatID, arg.MessageID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteChatDebugDataByChatID = `-- name: DeleteChatDebugDataByChatID :execrows
DELETE FROM chat_debug_runs
WHERE chat_id = $1::uuid
`
func (q *sqlQuerier) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteChatDebugDataByChatID, chatID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const finalizeStaleChatDebugRows = `-- name: FinalizeStaleChatDebugRows :one
WITH finalized_runs AS (
UPDATE chat_debug_runs
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
WHERE updated_at < $1::timestamptz
AND finished_at IS NULL
AND COALESCE(status, '') NOT IN ('completed', 'error', 'failed', 'interrupted', 'cancelled')
RETURNING 1
), finalized_steps AS (
UPDATE chat_debug_steps
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
WHERE updated_at < $1::timestamptz
AND finished_at IS NULL
AND COALESCE(status, '') NOT IN ('completed', 'error', 'failed', 'interrupted', 'cancelled')
RETURNING 1
)
SELECT
(SELECT COUNT(*) FROM finalized_runs)::bigint AS runs_finalized,
(SELECT COUNT(*) FROM finalized_steps)::bigint AS steps_finalized
`
type FinalizeStaleChatDebugRowsRow struct {
RunsFinalized int64 `db:"runs_finalized" json:"runs_finalized"`
StepsFinalized int64 `db:"steps_finalized" json:"steps_finalized"`
}
func (q *sqlQuerier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error) {
row := q.db.QueryRowContext(ctx, finalizeStaleChatDebugRows, updatedBefore)
var i FinalizeStaleChatDebugRowsRow
err := row.Scan(&i.RunsFinalized, &i.StepsFinalized)
return i, err
}
const getChatDebugRunByID = `-- name: GetChatDebugRunByID :one
SELECT id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at
FROM chat_debug_runs
WHERE id = $1::uuid
`
func (q *sqlQuerier) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (ChatDebugRun, error) {
row := q.db.QueryRowContext(ctx, getChatDebugRunByID, id)
var i ChatDebugRun
err := row.Scan(
&i.ID,
&i.ChatID,
&i.RootChatID,
&i.ParentChatID,
&i.ModelConfigID,
&i.TriggerMessageID,
&i.HistoryTipMessageID,
&i.Kind,
&i.Status,
&i.Provider,
&i.Model,
&i.Summary,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
)
return i, err
}
const getChatDebugRunsByChat = `-- name: GetChatDebugRunsByChat :many
SELECT id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at
FROM chat_debug_runs
WHERE chat_id = $1::uuid
ORDER BY started_at DESC, id DESC
`
func (q *sqlQuerier) GetChatDebugRunsByChat(ctx context.Context, chatID uuid.UUID) ([]ChatDebugRun, error) {
rows, err := q.db.QueryContext(ctx, getChatDebugRunsByChat, chatID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChatDebugRun
for rows.Next() {
var i ChatDebugRun
if err := rows.Scan(
&i.ID,
&i.ChatID,
&i.RootChatID,
&i.ParentChatID,
&i.ModelConfigID,
&i.TriggerMessageID,
&i.HistoryTipMessageID,
&i.Kind,
&i.Status,
&i.Provider,
&i.Model,
&i.Summary,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatDebugStepsByRunID = `-- name: GetChatDebugStepsByRunID :many
SELECT id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at
FROM chat_debug_steps
WHERE run_id = $1::uuid
ORDER BY step_number ASC, started_at ASC
`
func (q *sqlQuerier) GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]ChatDebugStep, error) {
rows, err := q.db.QueryContext(ctx, getChatDebugStepsByRunID, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChatDebugStep
for rows.Next() {
var i ChatDebugStep
if err := rows.Scan(
&i.ID,
&i.RunID,
&i.ChatID,
&i.StepNumber,
&i.Operation,
&i.Status,
&i.HistoryTipMessageID,
&i.AssistantMessageID,
&i.NormalizedRequest,
&i.NormalizedResponse,
&i.Usage,
&i.Attempts,
&i.Error,
&i.Metadata,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertChatDebugRun = `-- name: InsertChatDebugRun :one
INSERT INTO chat_debug_runs (
chat_id,
root_chat_id,
parent_chat_id,
model_config_id,
trigger_message_id,
history_tip_message_id,
kind,
status,
provider,
model,
summary,
started_at,
updated_at,
finished_at
)
VALUES (
$1::uuid,
$2::uuid,
$3::uuid,
$4::uuid,
$5::bigint,
$6::bigint,
$7::text,
$8::text,
$9::text,
$10::text,
COALESCE($11::jsonb, '{}'::jsonb),
COALESCE($12::timestamptz, NOW()),
COALESCE($13::timestamptz, NOW()),
$14::timestamptz
)
RETURNING id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at
`
type InsertChatDebugRunParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"`
TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
Kind string `db:"kind" json:"kind"`
Status string `db:"status" json:"status"`
Provider sql.NullString `db:"provider" json:"provider"`
Model sql.NullString `db:"model" json:"model"`
Summary pqtype.NullRawMessage `db:"summary" json:"summary"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
}
func (q *sqlQuerier) InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) {
row := q.db.QueryRowContext(ctx, insertChatDebugRun,
arg.ChatID,
arg.RootChatID,
arg.ParentChatID,
arg.ModelConfigID,
arg.TriggerMessageID,
arg.HistoryTipMessageID,
arg.Kind,
arg.Status,
arg.Provider,
arg.Model,
arg.Summary,
arg.StartedAt,
arg.UpdatedAt,
arg.FinishedAt,
)
var i ChatDebugRun
err := row.Scan(
&i.ID,
&i.ChatID,
&i.RootChatID,
&i.ParentChatID,
&i.ModelConfigID,
&i.TriggerMessageID,
&i.HistoryTipMessageID,
&i.Kind,
&i.Status,
&i.Provider,
&i.Model,
&i.Summary,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
)
return i, err
}
const insertChatDebugStep = `-- name: InsertChatDebugStep :one
INSERT INTO chat_debug_steps (
run_id,
chat_id,
step_number,
operation,
status,
history_tip_message_id,
assistant_message_id,
normalized_request,
normalized_response,
usage,
attempts,
error,
metadata,
started_at,
updated_at,
finished_at
)
SELECT
$1::uuid,
run.chat_id,
$2::int,
$3::text,
$4::text,
$5::bigint,
$6::bigint,
COALESCE($7::jsonb, '{}'::jsonb),
$8::jsonb,
$9::jsonb,
COALESCE($10::jsonb, '[]'::jsonb),
$11::jsonb,
COALESCE($12::jsonb, '{}'::jsonb),
COALESCE($13::timestamptz, NOW()),
COALESCE($14::timestamptz, NOW()),
$15::timestamptz
FROM chat_debug_runs run
WHERE run.id = $1::uuid
AND run.chat_id = $16::uuid
RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at
`
type InsertChatDebugStepParams struct {
RunID uuid.UUID `db:"run_id" json:"run_id"`
StepNumber int32 `db:"step_number" json:"step_number"`
Operation string `db:"operation" json:"operation"`
Status string `db:"status" json:"status"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"`
NormalizedRequest pqtype.NullRawMessage `db:"normalized_request" json:"normalized_request"`
NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"`
Usage pqtype.NullRawMessage `db:"usage" json:"usage"`
Attempts pqtype.NullRawMessage `db:"attempts" json:"attempts"`
Error pqtype.NullRawMessage `db:"error" json:"error"`
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
func (q *sqlQuerier) InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error) {
row := q.db.QueryRowContext(ctx, insertChatDebugStep,
arg.RunID,
arg.StepNumber,
arg.Operation,
arg.Status,
arg.HistoryTipMessageID,
arg.AssistantMessageID,
arg.NormalizedRequest,
arg.NormalizedResponse,
arg.Usage,
arg.Attempts,
arg.Error,
arg.Metadata,
arg.StartedAt,
arg.UpdatedAt,
arg.FinishedAt,
arg.ChatID,
)
var i ChatDebugStep
err := row.Scan(
&i.ID,
&i.RunID,
&i.ChatID,
&i.StepNumber,
&i.Operation,
&i.Status,
&i.HistoryTipMessageID,
&i.AssistantMessageID,
&i.NormalizedRequest,
&i.NormalizedResponse,
&i.Usage,
&i.Attempts,
&i.Error,
&i.Metadata,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
)
return i, err
}
const updateChatDebugRun = `-- name: UpdateChatDebugRun :one
UPDATE chat_debug_runs
SET
root_chat_id = COALESCE($1::uuid, root_chat_id),
parent_chat_id = COALESCE($2::uuid, parent_chat_id),
model_config_id = COALESCE($3::uuid, model_config_id),
trigger_message_id = COALESCE($4::bigint, trigger_message_id),
history_tip_message_id = COALESCE($5::bigint, history_tip_message_id),
kind = COALESCE($6::text, kind),
status = COALESCE($7::text, status),
provider = COALESCE($8::text, provider),
model = COALESCE($9::text, model),
summary = COALESCE($10::jsonb, summary),
finished_at = COALESCE($11::timestamptz, finished_at),
updated_at = NOW()
WHERE id = $12::uuid
AND chat_id = $13::uuid
RETURNING id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at
`
type UpdateChatDebugRunParams struct {
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"`
TriggerMessageID sql.NullInt64 `db:"trigger_message_id" json:"trigger_message_id"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
Kind sql.NullString `db:"kind" json:"kind"`
Status sql.NullString `db:"status" json:"status"`
Provider sql.NullString `db:"provider" json:"provider"`
Model sql.NullString `db:"model" json:"model"`
Summary pqtype.NullRawMessage `db:"summary" json:"summary"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
func (q *sqlQuerier) UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error) {
row := q.db.QueryRowContext(ctx, updateChatDebugRun,
arg.RootChatID,
arg.ParentChatID,
arg.ModelConfigID,
arg.TriggerMessageID,
arg.HistoryTipMessageID,
arg.Kind,
arg.Status,
arg.Provider,
arg.Model,
arg.Summary,
arg.FinishedAt,
arg.ID,
arg.ChatID,
)
var i ChatDebugRun
err := row.Scan(
&i.ID,
&i.ChatID,
&i.RootChatID,
&i.ParentChatID,
&i.ModelConfigID,
&i.TriggerMessageID,
&i.HistoryTipMessageID,
&i.Kind,
&i.Status,
&i.Provider,
&i.Model,
&i.Summary,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
)
return i, err
}
const updateChatDebugStep = `-- name: UpdateChatDebugStep :one
UPDATE chat_debug_steps
SET
operation = COALESCE($1::text, operation),
status = COALESCE($2::text, status),
history_tip_message_id = COALESCE($3::bigint, history_tip_message_id),
assistant_message_id = COALESCE($4::bigint, assistant_message_id),
normalized_request = COALESCE($5::jsonb, normalized_request),
normalized_response = COALESCE($6::jsonb, normalized_response),
usage = COALESCE($7::jsonb, usage),
attempts = COALESCE($8::jsonb, attempts),
error = COALESCE($9::jsonb, error),
metadata = COALESCE($10::jsonb, metadata),
finished_at = COALESCE($11::timestamptz, finished_at),
updated_at = NOW()
WHERE id = $12::uuid
AND chat_id = $13::uuid
RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at
`
type UpdateChatDebugStepParams struct {
Operation sql.NullString `db:"operation" json:"operation"`
Status sql.NullString `db:"status" json:"status"`
HistoryTipMessageID sql.NullInt64 `db:"history_tip_message_id" json:"history_tip_message_id"`
AssistantMessageID sql.NullInt64 `db:"assistant_message_id" json:"assistant_message_id"`
NormalizedRequest pqtype.NullRawMessage `db:"normalized_request" json:"normalized_request"`
NormalizedResponse pqtype.NullRawMessage `db:"normalized_response" json:"normalized_response"`
Usage pqtype.NullRawMessage `db:"usage" json:"usage"`
Attempts pqtype.NullRawMessage `db:"attempts" json:"attempts"`
Error pqtype.NullRawMessage `db:"error" json:"error"`
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
func (q *sqlQuerier) UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error) {
row := q.db.QueryRowContext(ctx, updateChatDebugStep,
arg.Operation,
arg.Status,
arg.HistoryTipMessageID,
arg.AssistantMessageID,
arg.NormalizedRequest,
arg.NormalizedResponse,
arg.Usage,
arg.Attempts,
arg.Error,
arg.Metadata,
arg.FinishedAt,
arg.ID,
arg.ChatID,
)
var i ChatDebugStep
err := row.Scan(
&i.ID,
&i.RunID,
&i.ChatID,
&i.StepNumber,
&i.Operation,
&i.Status,
&i.HistoryTipMessageID,
&i.AssistantMessageID,
&i.NormalizedRequest,
&i.NormalizedResponse,
&i.Usage,
&i.Attempts,
&i.Error,
&i.Metadata,
&i.StartedAt,
&i.UpdatedAt,
&i.FinishedAt,
)
return i, err
}
const getChatFileByID = `-- name: GetChatFileByID :one
SELECT id, owner_id, organization_id, created_at, name, mimetype, data FROM chat_files WHERE id = $1::uuid
`
@@ -4064,7 +4613,7 @@ WHERE
$3::int
)
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type AcquireChatsParams struct {
@@ -4108,6 +4657,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
); err != nil {
return nil, err
}
@@ -4246,9 +4796,9 @@ WITH chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -4286,6 +4836,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
); err != nil {
return nil, err
}
@@ -4390,7 +4941,7 @@ func (q *sqlQuerier) DeleteChatUsageLimitUserOverride(ctx context.Context, userI
const getChatByID = `-- name: GetChatByID :one
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
FROM
chats
WHERE
@@ -4424,12 +4975,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context FROM chats WHERE id = $1::uuid FOR UPDATE
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override FROM chats WHERE id = $1::uuid FOR UPDATE
`
func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) {
@@ -4459,6 +5011,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -5370,7 +5923,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u
const getChats = `-- name: GetChats :many
SELECT
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context,
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.debug_logs_enabled_override,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats.id
@@ -5478,6 +6031,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.Chat.DebugLogsEnabledOverride,
&i.HasUnread,
); err != nil {
return nil, err
@@ -5494,7 +6048,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
}
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
FROM chats
WHERE archived = false
AND workspace_id = ANY($1::uuid[])
@@ -5534,6 +6088,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
); err != nil {
return nil, err
}
@@ -5599,7 +6154,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
const getStaleChats = `-- name: GetStaleChats :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
FROM
chats
WHERE
@@ -5642,6 +6197,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
); err != nil {
return nil, err
}
@@ -5723,7 +6279,7 @@ INSERT INTO chats (
COALESCE($11::jsonb, '{}'::jsonb)
)
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type InsertChatParams struct {
@@ -5779,6 +6335,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6222,9 +6779,9 @@ WITH chats AS (
UPDATE chats
SET archived = false, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -6262,6 +6819,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
); err != nil {
return nil, err
}
@@ -6342,7 +6900,7 @@ UPDATE chats SET
updated_at = NOW()
WHERE
id = $3::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatBuildAgentBindingParams struct {
@@ -6378,6 +6936,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6391,7 +6950,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatByIDParams struct {
@@ -6426,6 +6985,56 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
const updateChatDebugLogsEnabledOverride = `-- name: UpdateChatDebugLogsEnabledOverride :one
UPDATE
chats
SET
debug_logs_enabled_override = $1,
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatDebugLogsEnabledOverrideParams struct {
DebugLogsEnabledOverride sql.NullBool `db:"debug_logs_enabled_override" json:"debug_logs_enabled_override"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatDebugLogsEnabledOverride(ctx context.Context, arg UpdateChatDebugLogsEnabledOverrideParams) (Chat, error) {
row := q.db.QueryRowContext(ctx, updateChatDebugLogsEnabledOverride, arg.DebugLogsEnabledOverride, arg.ID)
var i Chat
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6465,7 +7074,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatLabelsByIDParams struct {
@@ -6500,6 +7109,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6509,7 +7119,7 @@ UPDATE chats SET
last_injected_context = $1::jsonb
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatLastInjectedContextParams struct {
@@ -6548,6 +7158,7 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6561,7 +7172,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatLastModelConfigByIDParams struct {
@@ -6596,6 +7207,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6627,7 +7239,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatMCPServerIDsParams struct {
@@ -6662,6 +7274,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6797,7 +7410,7 @@ SET
WHERE
id = $6::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatStatusParams struct {
@@ -6843,6 +7456,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6860,7 +7474,7 @@ SET
WHERE
id = $7::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatStatusPreserveUpdatedAtParams struct {
@@ -6908,6 +7522,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -6919,7 +7534,7 @@ UPDATE chats SET
agent_id = $3::uuid,
updated_at = NOW()
WHERE id = $4::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, debug_logs_enabled_override
`
type UpdateChatWorkspaceBindingParams struct {
@@ -6961,6 +7576,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DebugLogsEnabledOverride,
)
return i, err
}
@@ -18526,6 +19142,18 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) {
return value, err
}
const getChatDebugLoggingEnabled = `-- name: GetChatDebugLoggingEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_enabled'), false) :: boolean AS debug_logging_enabled
`
func (q *sqlQuerier) GetChatDebugLoggingEnabled(ctx context.Context) (bool, error) {
row := q.db.QueryRowContext(ctx, getChatDebugLoggingEnabled)
var debug_logging_enabled bool
err := row.Scan(&debug_logging_enabled)
return debug_logging_enabled, err
}
const getChatDesktopEnabled = `-- name: GetChatDesktopEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop
@@ -18818,6 +19446,28 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er
return err
}
const upsertChatDebugLoggingEnabled = `-- name: UpsertChatDebugLoggingEnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_debug_logging_enabled',
CASE
WHEN $1::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN $1::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_debug_logging_enabled'
`
func (q *sqlQuerier) UpsertChatDebugLoggingEnabled(ctx context.Context, debugLoggingEnabled bool) error {
_, err := q.db.ExecContext(ctx, upsertChatDebugLoggingEnabled, debugLoggingEnabled)
return err
}
const upsertChatDesktopEnabled = `-- name: UpsertChatDesktopEnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
@@ -22862,6 +23512,23 @@ func (q *sqlQuerier) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UU
return chat_custom_prompt, err
}
const getUserChatDebugLoggingEnabled = `-- name: GetUserChatDebugLoggingEnabled :one
SELECT
COALESCE((
SELECT value = 'true'
FROM user_configs
WHERE user_id = $1
AND key = 'chat_debug_logging_enabled'
), false) :: boolean AS debug_logging_enabled
`
func (q *sqlQuerier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, getUserChatDebugLoggingEnabled, userID)
var debug_logging_enabled bool
err := row.Scan(&debug_logging_enabled)
return debug_logging_enabled, err
}
const getUserCount = `-- name: GetUserCount :one
SELECT
COUNT(*)
@@ -23856,6 +24523,35 @@ func (q *sqlQuerier) UpdateUserThemePreference(ctx context.Context, arg UpdateUs
return i, err
}
const upsertUserChatDebugLoggingEnabled = `-- name: UpsertUserChatDebugLoggingEnabled :exec
INSERT INTO user_configs (user_id, key, value)
VALUES (
$1,
'chat_debug_logging_enabled',
CASE
WHEN $2::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT ON CONSTRAINT user_configs_pkey
DO UPDATE SET value = CASE
WHEN $2::bool THEN 'true'
ELSE 'false'
END
WHERE user_configs.user_id = $1
AND user_configs.key = 'chat_debug_logging_enabled'
`
type UpsertUserChatDebugLoggingEnabledParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
DebugLoggingEnabled bool `db:"debug_logging_enabled" json:"debug_logging_enabled"`
}
func (q *sqlQuerier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error {
_, err := q.db.ExecContext(ctx, upsertUserChatDebugLoggingEnabled, arg.UserID, arg.DebugLoggingEnabled)
return err
}
const validateUserIDs = `-- name: ValidateUserIDs :one
WITH input AS (
SELECT
+178
View File
@@ -0,0 +1,178 @@
-- name: InsertChatDebugRun :one
INSERT INTO chat_debug_runs (
chat_id,
root_chat_id,
parent_chat_id,
model_config_id,
trigger_message_id,
history_tip_message_id,
kind,
status,
provider,
model,
summary,
started_at,
updated_at,
finished_at
)
VALUES (
@chat_id::uuid,
sqlc.narg('root_chat_id')::uuid,
sqlc.narg('parent_chat_id')::uuid,
sqlc.narg('model_config_id')::uuid,
sqlc.narg('trigger_message_id')::bigint,
sqlc.narg('history_tip_message_id')::bigint,
@kind::text,
@status::text,
sqlc.narg('provider')::text,
sqlc.narg('model')::text,
COALESCE(sqlc.narg('summary')::jsonb, '{}'::jsonb),
COALESCE(sqlc.narg('started_at')::timestamptz, NOW()),
COALESCE(sqlc.narg('updated_at')::timestamptz, NOW()),
sqlc.narg('finished_at')::timestamptz
)
RETURNING *;
-- name: UpdateChatDebugRun :one
UPDATE chat_debug_runs
SET
root_chat_id = COALESCE(sqlc.narg('root_chat_id')::uuid, root_chat_id),
parent_chat_id = COALESCE(sqlc.narg('parent_chat_id')::uuid, parent_chat_id),
model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id),
trigger_message_id = COALESCE(sqlc.narg('trigger_message_id')::bigint, trigger_message_id),
history_tip_message_id = COALESCE(sqlc.narg('history_tip_message_id')::bigint, history_tip_message_id),
kind = COALESCE(sqlc.narg('kind')::text, kind),
status = COALESCE(sqlc.narg('status')::text, status),
provider = COALESCE(sqlc.narg('provider')::text, provider),
model = COALESCE(sqlc.narg('model')::text, model),
summary = COALESCE(sqlc.narg('summary')::jsonb, summary),
finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at),
updated_at = NOW()
WHERE id = @id::uuid
AND chat_id = @chat_id::uuid
RETURNING *;
-- name: InsertChatDebugStep :one
INSERT INTO chat_debug_steps (
run_id,
chat_id,
step_number,
operation,
status,
history_tip_message_id,
assistant_message_id,
normalized_request,
normalized_response,
usage,
attempts,
error,
metadata,
started_at,
updated_at,
finished_at
)
SELECT
@run_id::uuid,
run.chat_id,
@step_number::int,
@operation::text,
@status::text,
sqlc.narg('history_tip_message_id')::bigint,
sqlc.narg('assistant_message_id')::bigint,
COALESCE(sqlc.narg('normalized_request')::jsonb, '{}'::jsonb),
sqlc.narg('normalized_response')::jsonb,
sqlc.narg('usage')::jsonb,
COALESCE(sqlc.narg('attempts')::jsonb, '[]'::jsonb),
sqlc.narg('error')::jsonb,
COALESCE(sqlc.narg('metadata')::jsonb, '{}'::jsonb),
COALESCE(sqlc.narg('started_at')::timestamptz, NOW()),
COALESCE(sqlc.narg('updated_at')::timestamptz, NOW()),
sqlc.narg('finished_at')::timestamptz
FROM chat_debug_runs run
WHERE run.id = @run_id::uuid
AND run.chat_id = @chat_id::uuid
RETURNING *;
-- name: UpdateChatDebugStep :one
UPDATE chat_debug_steps
SET
operation = COALESCE(sqlc.narg('operation')::text, operation),
status = COALESCE(sqlc.narg('status')::text, status),
history_tip_message_id = COALESCE(sqlc.narg('history_tip_message_id')::bigint, history_tip_message_id),
assistant_message_id = COALESCE(sqlc.narg('assistant_message_id')::bigint, assistant_message_id),
normalized_request = COALESCE(sqlc.narg('normalized_request')::jsonb, normalized_request),
normalized_response = COALESCE(sqlc.narg('normalized_response')::jsonb, normalized_response),
usage = COALESCE(sqlc.narg('usage')::jsonb, usage),
attempts = COALESCE(sqlc.narg('attempts')::jsonb, attempts),
error = COALESCE(sqlc.narg('error')::jsonb, error),
metadata = COALESCE(sqlc.narg('metadata')::jsonb, metadata),
finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at),
updated_at = NOW()
WHERE id = @id::uuid
AND chat_id = @chat_id::uuid
RETURNING *;
-- name: GetChatDebugRunsByChat :many
SELECT *
FROM chat_debug_runs
WHERE chat_id = @chat_id::uuid
ORDER BY started_at DESC, id DESC;
-- name: GetChatDebugRunByID :one
SELECT *
FROM chat_debug_runs
WHERE id = @id::uuid;
-- name: GetChatDebugStepsByRunID :many
SELECT *
FROM chat_debug_steps
WHERE run_id = @run_id::uuid
ORDER BY step_number ASC, started_at ASC;
-- name: DeleteChatDebugDataByChatID :execrows
DELETE FROM chat_debug_runs
WHERE chat_id = @chat_id::uuid;
-- name: DeleteChatDebugDataAfterMessageID :execrows
WITH affected_runs AS (
SELECT DISTINCT run.id
FROM chat_debug_runs run
WHERE run.chat_id = @chat_id::uuid
AND run.history_tip_message_id > @message_id::bigint
UNION
SELECT DISTINCT step.run_id AS id
FROM chat_debug_steps step
WHERE step.chat_id = @chat_id::uuid
AND step.assistant_message_id > @message_id::bigint
)
DELETE FROM chat_debug_runs
WHERE chat_id = @chat_id::uuid
AND id IN (SELECT id FROM affected_runs);
-- name: FinalizeStaleChatDebugRows :one
WITH finalized_runs AS (
UPDATE chat_debug_runs
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
WHERE updated_at < @updated_before::timestamptz
AND finished_at IS NULL
AND COALESCE(status, '') NOT IN ('completed', 'error', 'failed', 'interrupted', 'cancelled')
RETURNING 1
), finalized_steps AS (
UPDATE chat_debug_steps
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
WHERE updated_at < @updated_before::timestamptz
AND finished_at IS NULL
AND COALESCE(status, '') NOT IN ('completed', 'error', 'failed', 'interrupted', 'cancelled')
RETURNING 1
)
SELECT
(SELECT COUNT(*) FROM finalized_runs)::bigint AS runs_finalized,
(SELECT COUNT(*) FROM finalized_steps)::bigint AS steps_finalized;
+11
View File
@@ -525,6 +525,17 @@ WHERE
RETURNING
*;
-- name: UpdateChatDebugLogsEnabledOverride :one
UPDATE
chats
SET
debug_logs_enabled_override = @debug_logs_enabled_override,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING
*;
-- name: UpdateChatWorkspaceBinding :one
UPDATE chats SET
workspace_id = sqlc.narg('workspace_id')::uuid,
+20
View File
@@ -179,6 +179,26 @@ SET value = CASE
END
WHERE site_configs.key = 'agents_desktop_enabled';
-- name: GetChatDebugLoggingEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_enabled'), false) :: boolean AS debug_logging_enabled;
-- name: UpsertChatDebugLoggingEnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_debug_logging_enabled',
CASE
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_debug_logging_enabled';
-- GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
-- Returns an empty string when no allowlist has been configured (all templates allowed).
-- name: GetChatTemplateAllowlist :one
+27
View File
@@ -213,6 +213,33 @@ RETURNING *;
-- name: DeleteUserChatCompactionThreshold :exec
DELETE FROM user_configs WHERE user_id = @user_id AND key = @key;
-- name: GetUserChatDebugLoggingEnabled :one
SELECT
COALESCE((
SELECT value = 'true'
FROM user_configs
WHERE user_id = @user_id
AND key = 'chat_debug_logging_enabled'
), false) :: boolean AS debug_logging_enabled;
-- name: UpsertUserChatDebugLoggingEnabled :exec
INSERT INTO user_configs (user_id, key, value)
VALUES (
@user_id,
'chat_debug_logging_enabled',
CASE
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT ON CONSTRAINT user_configs_pkey
DO UPDATE SET value = CASE
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
ELSE 'false'
END
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'chat_debug_logging_enabled';
-- name: GetUserTaskNotificationAlertDismissed :one
SELECT
value::boolean as task_notification_alert_dismissed
+3
View File
@@ -15,6 +15,8 @@ const (
UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id);
UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id);
UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id);
UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id);
UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);
UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id);
UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id);
@@ -125,6 +127,7 @@ const (
UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type);
UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number);
UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree ((1)) WHERE ((is_default = true) AND (deleted = false));
UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name);
UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid));
+285
View File
@@ -1726,9 +1726,162 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
}
}
if req.DebugLogsEnabledOverride != nil {
updatedChat, err := api.Database.UpdateChatDebugLogsEnabledOverride(ctx, database.UpdateChatDebugLogsEnabledOverrideParams{
ID: chat.ID,
DebugLogsEnabledOverride: sql.NullBool{
Bool: *req.DebugLogsEnabledOverride,
Valid: true,
},
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat debug logging override.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary List chat debug runs
// @ID list-chat-debug-runs
// @Security CoderSessionToken
// @Produce json
// @Tags Chats
// @Param chat path string true "Chat ID"
// @Success 200 {array} codersdk.ChatDebugRunSummary
// @Router /chats/{chat}/debug/runs [get]
// @x-apidocgen {"skip": true}
// 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) getChatDebugRuns(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
runs, err := api.Database.GetChatDebugRunsByChat(ctx, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch chat debug runs.",
Detail: err.Error(),
})
return
}
resp := make([]codersdk.ChatDebugRunSummary, 0, len(runs))
for _, run := range runs {
resp = append(resp, db2sdk.ChatDebugRunSummary(run))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get chat debug run
// @ID get-chat-debug-run
// @Security CoderSessionToken
// @Produce json
// @Tags Chats
// @Param chat path string true "Chat ID"
// @Param run path string true "Run ID"
// @Success 200 {object} codersdk.ChatDebugRun
// @Router /chats/{chat}/debug/runs/{run} [get]
// @x-apidocgen {"skip": true}
// 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) getChatDebugRun(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
runID, ok := parseChatDebugRunID(rw, r)
if !ok {
return
}
chat := httpmw.ChatParam(r)
run, err := api.Database.GetChatDebugRunByID(ctx, runID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch chat debug run.",
Detail: err.Error(),
})
return
}
if run.ChatID != chat.ID {
httpapi.ResourceNotFound(rw)
return
}
steps, err := api.Database.GetChatDebugStepsByRunID(ctx, run.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch chat debug steps.",
Detail: err.Error(),
})
return
}
convertedSteps := make([]codersdk.ChatDebugStep, 0, len(steps))
for _, step := range steps {
convertedSteps = append(convertedSteps, db2sdk.ChatDebugStep(step))
}
resp := codersdk.ChatDebugRun{
ID: run.ID,
ChatID: run.ChatID,
Kind: run.Kind,
Status: run.Status,
Summary: run.Summary,
StartedAt: run.StartedAt,
UpdatedAt: run.UpdatedAt,
Steps: convertedSteps,
}
if run.RootChatID.Valid {
rootChatID := run.RootChatID.UUID
resp.RootChatID = &rootChatID
}
if run.ParentChatID.Valid {
parentChatID := run.ParentChatID.UUID
resp.ParentChatID = &parentChatID
}
if run.ModelConfigID.Valid {
modelConfigID := run.ModelConfigID.UUID
resp.ModelConfigID = &modelConfigID
}
if run.TriggerMessageID.Valid {
triggerMessageID := run.TriggerMessageID.Int64
resp.TriggerMessageID = &triggerMessageID
}
if run.HistoryTipMessageID.Valid {
historyTipMessageID := run.HistoryTipMessageID.Int64
resp.HistoryTipMessageID = &historyTipMessageID
}
if run.Provider.Valid {
provider := run.Provider.String
resp.Provider = &provider
}
if run.Model.Valid {
model := run.Model.String
resp.Model = &model
}
if run.FinishedAt.Valid {
finishedAt := run.FinishedAt.Time
resp.FinishedAt = &finishedAt
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -2991,6 +3144,63 @@ func (api *API) putChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat debug logging setting
// @ID get-chat-debug-logging
// @Security CoderSessionToken
// @Produce json
// @Tags Chats
// @Success 200 {object} codersdk.ChatDebugSettings
// @Router /chats/config/debug-logging [get]
// @x-apidocgen {"skip": true}
// 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) getChatDebugLoggingEnabled(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
enabled, err := api.Database.GetChatDebugLoggingEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching debug logging setting.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugSettings{
DebugLoggingEnabled: enabled,
})
}
// @Summary Update chat debug logging setting
// @ID update-chat-debug-logging
// @Security CoderSessionToken
// @Accept json
// @Tags Chats
// @Param request body codersdk.UpdateChatDebugLoggingRequest true "Update request"
// @Success 204
// @Router /chats/config/debug-logging [put]
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatDebugLoggingEnabled(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatDebugLoggingRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertChatDebugLoggingEnabled(ctx, req.DebugLoggingEnabled); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating 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.
@@ -3281,6 +3491,69 @@ func (api *API) putUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request)
})
}
// @Summary Get user chat debug logging setting
// @ID get-user-chat-debug-logging
// @Security CoderSessionToken
// @Produce json
// @Tags Chats
// @Success 200 {object} codersdk.ChatDebugSettings
// @Router /chats/config/user-debug-logging [get]
// @x-apidocgen {"skip": true}
// 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) getUserChatDebugLoggingEnabled(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := httpmw.APIKey(r).UserID
enabled, err := api.Database.GetUserChatDebugLoggingEnabled(ctx, userID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error reading user chat debug logging setting.",
Detail: err.Error(),
})
return
}
enabled = false
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugSettings{
DebugLoggingEnabled: enabled,
})
}
// @Summary Update user chat debug logging setting
// @ID update-user-chat-debug-logging
// @Security CoderSessionToken
// @Accept json
// @Tags Chats
// @Param request body codersdk.UpdateChatDebugLoggingRequest true "Update request"
// @Success 204
// @Router /chats/config/user-debug-logging [put]
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putUserChatDebugLoggingEnabled(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := httpmw.APIKey(r).UserID
var req codersdk.UpdateChatDebugLoggingRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertUserChatDebugLoggingEnabled(ctx, database.UpsertUserChatDebugLoggingEnabledParams{
UserID: userID,
DebugLoggingEnabled: req.DebugLoggingEnabled,
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error updating user chat debug logging setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get user chat compaction thresholds
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -4714,6 +4987,18 @@ func parseChatProviderID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bo
return providerID, true
}
func parseChatDebugRunID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
runID, err := uuid.Parse(chi.URLParam(r, "run"))
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat debug run ID.",
Detail: err.Error(),
})
return uuid.Nil, false
}
return runID, true
}
func parseChatModelConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
modelConfigID, err := uuid.Parse(chi.URLParam(r, "modelConfig"))
if err != nil {
+422 -30
View File
@@ -33,6 +33,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"
@@ -113,6 +114,7 @@ type Server struct {
pubsub pubsub.Pubsub
webpushDispatcher webpush.Dispatcher
providerAPIKeys chatprovider.ProviderAPIKeys
debugSvc *chatdebug.Service
configCache *chatConfigCache
configCacheUnsubscribe func()
@@ -1147,7 +1149,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 {
@@ -1158,17 +1163,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
}
@@ -1195,8 +1200,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)
@@ -1229,6 +1234,20 @@ func (p *Server) EditMessage(
return EditMessageResult{}, txErr
}
if p.debugSvc != nil {
if _, err := p.debugSvc.DeleteAfterMessageID(
ctx,
opts.ChatID,
editedMsg.ID,
); 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.publishEditedMessage(opts.ChatID, result.Message)
p.publishEvent(opts.ChatID, codersdk.ChatStreamEvent{
Type: codersdk.ChatStreamEventTypeQueueUpdate,
@@ -1290,6 +1309,15 @@ func (p *Server) ArchiveChat(ctx context.Context, chat database.Chat) error {
return err
}
if p.debugSvc != nil {
if _, err := p.debugSvc.DeleteByChatID(ctx, chat.ID); err != nil {
p.logger.Warn(ctx, "failed to delete chat debug rows after archive",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
}
if interrupted {
p.publishStatus(chat.ID, statusChat.Status, statusChat.WorkerID)
p.publishChatPubsubEvent(statusChat, coderdpubsub.ChatEventKindStatusChange, nil)
@@ -1497,6 +1525,8 @@ func (p *Server) InterruptChat(
return chat
}
// 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",
@@ -1737,7 +1767,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{}) {
@@ -1775,6 +1821,168 @@ 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,
})
}
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
if generateErr != nil {
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),
)
}
}
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,
@@ -1801,6 +2009,7 @@ func (p *Server) resolveManualTitleModel(
keys,
chatprovider.UserAgent(),
chatprovider.CoderHeaders(chat),
nil,
)
if err != nil {
p.logger.Debug(ctx, "manual title preferred model unavailable",
@@ -1833,6 +2042,7 @@ func (p *Server) resolveFallbackManualTitleModel(
keys,
chatprovider.UserAgent(),
chatprovider.CoderHeaders(chat),
nil,
)
if err != nil {
return nil, database.ChatModelConfig{}, xerrors.Errorf(
@@ -2392,6 +2602,7 @@ func New(cfg Config) *Server {
pubsub: cfg.Pubsub,
webpushDispatcher: cfg.WebpushDispatcher,
providerAPIKeys: cfg.ProviderAPIKeys,
debugSvc: chatdebug.NewService(cfg.Database, cfg.Logger.Named("chatdebug"), cfg.Pubsub),
pendingChatAcquireInterval: pendingChatAcquireInterval,
maxChatsPerAcquire: maxChatsPerAcquire,
inFlightChatStaleAfter: inFlightChatStaleAfter,
@@ -2439,6 +2650,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))
}
}
acquireTicker := p.clock.NewTicker(
p.pendingChatAcquireInterval,
@@ -3807,6 +4024,8 @@ type runChatResult struct {
FinalAssistantText string
PushSummaryModel fantasy.LanguageModel
ProviderKeys chatprovider.ProviderAPIKeys
FallbackProvider string
FallbackModel string
}
func (p *Server) runChat(
@@ -3817,11 +4036,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
@@ -3834,7 +4056,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
}
@@ -3892,6 +4114,8 @@ 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.
@@ -3905,10 +4129,13 @@ func (p *Server) runChat(
context.WithoutCancel(ctx),
chat,
messages,
modelConfig.Provider,
modelConfig.Model,
titleModel,
providerKeys,
generatedTitle,
logger,
p.debugSvc,
)
}()
@@ -4122,6 +4349,13 @@ func (p *Server) runChat(
modelConfigContextLimit := modelConfig.ContextLimit
var finalAssistantText string
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
@@ -4308,6 +4542,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
}
}
// Clear the stream buffer now that the step is
// persisted. Late-joining subscribers will load
@@ -4342,9 +4582,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,
@@ -4380,7 +4621,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,
@@ -4391,6 +4641,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{
@@ -4554,7 +4811,112 @@ func (p *Server) runChat(
prompt = filterPromptForChainMode(prompt, chainInfo.trailingUserCount)
}
err = chatloop.Run(ctx, chatloop.RunOptions{
var loopErr error
if debugEnabled {
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
}
}
seedSummary := chatdebug.SeedSummary(
chatdebug.TruncateLabel(triggerLabel, chatdebug.MaxLabelLength),
)
historyTipMessageID := int64(0)
if len(messages) > 0 {
historyTipMessageID = messages[len(messages)-1].ID
}
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 {
ctx = 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() {
var status chatdebug.Status
switch {
case loopErr == nil:
status = chatdebug.StatusCompleted
case errors.Is(loopErr, chatloop.ErrInterrupted):
status = chatdebug.StatusInterrupted
default:
status = chatdebug.StatusError
}
finalSummary := seedSummary
if aggregated, aggErr := p.debugSvc.AggregateRunSummary(
context.WithoutCancel(ctx),
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
}
if _, updateRunErr := p.debugSvc.UpdateRun(
context.WithoutCancel(ctx),
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),
)
}
}()
}
}
loopErr = chatloop.Run(ctx, chatloop.RunOptions{
Model: model,
Messages: prompt,
Tools: tools, MaxSteps: maxChatSteps,
@@ -4583,6 +4945,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)
@@ -4639,9 +5008,9 @@ func (p *Server) runChat(
p.logger.Warn(ctx, "failed to persist interrupted chat step", slog.Error(err))
},
})
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
@@ -4805,10 +5174,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
@@ -4827,19 +5201,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) resolveProviderAPIKeys(
@@ -5322,9 +5711,12 @@ func (p *Server) maybeSendPushNotification(
pushCtx,
chat,
assistantText,
runResult.FallbackProvider,
runResult.FallbackModel,
runResult.PushSummaryModel,
runResult.ProviderKeys,
logger,
p.debugSvc,
); summary != "" {
pushBody = summary
}
+64
View File
@@ -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
}
+63
View File
@@ -0,0 +1,63 @@
package chatdebug
import (
"context"
"sync"
)
type (
runContextKey struct{}
stepContextKey struct{}
reuseStepKey struct{}
reuseHolder struct {
mu sync.Mutex
handle *stepHandle
}
)
// ContextWithRun stores rc in ctx.
func ContextWithRun(ctx context.Context, rc *RunContext) context.Context {
if rc == nil {
panic("chatdebug: nil RunContext")
}
return context.WithValue(ctx, runContextKey{}, rc)
}
// RunFromContext returns the debug run context stored in ctx.
func RunFromContext(ctx context.Context) (*RunContext, bool) {
rc, ok := ctx.Value(runContextKey{}).(*RunContext)
if !ok {
return nil, false
}
return rc, true
}
// ContextWithStep stores sc in ctx.
func ContextWithStep(ctx context.Context, sc *StepContext) context.Context {
if sc == nil {
panic("chatdebug: nil StepContext")
}
return context.WithValue(ctx, stepContextKey{}, sc)
}
// StepFromContext returns the debug step context stored in ctx.
func StepFromContext(ctx context.Context) (*StepContext, bool) {
sc, ok := ctx.Value(stepContextKey{}).(*StepContext)
if !ok {
return nil, false
}
return sc, true
}
// ReuseStep marks ctx so wrapped model calls under it share one debug step.
func ReuseStep(ctx context.Context) context.Context {
return context.WithValue(ctx, reuseStepKey{}, &reuseHolder{})
}
func reuseHolderFromContext(ctx context.Context) (*reuseHolder, bool) {
holder, ok := ctx.Value(reuseStepKey{}).(*reuseHolder)
if !ok {
return nil, false
}
return holder, true
}
+105
View File
@@ -0,0 +1,105 @@
package chatdebug_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
)
func TestContextWithRunRoundTrip(t *testing.T) {
t.Parallel()
rc := &chatdebug.RunContext{
RunID: uuid.New(),
ChatID: uuid.New(),
RootChatID: uuid.New(),
ParentChatID: uuid.New(),
ModelConfigID: uuid.New(),
TriggerMessageID: 11,
HistoryTipMessageID: 22,
Kind: chatdebug.KindChatTurn,
Provider: "anthropic",
Model: "claude-sonnet",
}
ctx := chatdebug.ContextWithRun(context.Background(), rc)
got, ok := chatdebug.RunFromContext(ctx)
require.True(t, ok)
require.Same(t, rc, got)
require.Equal(t, *rc, *got)
}
func TestRunFromContextAbsent(t *testing.T) {
t.Parallel()
got, ok := chatdebug.RunFromContext(context.Background())
require.False(t, ok)
require.Nil(t, got)
}
func TestContextWithStepRoundTrip(t *testing.T) {
t.Parallel()
sc := &chatdebug.StepContext{
StepID: uuid.New(),
RunID: uuid.New(),
ChatID: uuid.New(),
StepNumber: 7,
Operation: chatdebug.OperationStream,
HistoryTipMessageID: 33,
}
ctx := chatdebug.ContextWithStep(context.Background(), sc)
got, ok := chatdebug.StepFromContext(ctx)
require.True(t, ok)
require.Same(t, sc, got)
require.Equal(t, *sc, *got)
}
func TestStepFromContextAbsent(t *testing.T) {
t.Parallel()
got, ok := chatdebug.StepFromContext(context.Background())
require.False(t, ok)
require.Nil(t, got)
}
func TestContextWithRunAndStep(t *testing.T) {
t.Parallel()
rc := &chatdebug.RunContext{RunID: uuid.New(), ChatID: uuid.New()}
sc := &chatdebug.StepContext{StepID: uuid.New(), RunID: rc.RunID, ChatID: rc.ChatID}
ctx := chatdebug.ContextWithStep(
chatdebug.ContextWithRun(context.Background(), rc),
sc,
)
gotRun, ok := chatdebug.RunFromContext(ctx)
require.True(t, ok)
require.Same(t, rc, gotRun)
gotStep, ok := chatdebug.StepFromContext(ctx)
require.True(t, ok)
require.Same(t, sc, gotStep)
}
func TestContextWithRunPanicsOnNil(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = chatdebug.ContextWithRun(context.Background(), nil)
})
}
func TestContextWithStepPanicsOnNil(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
_ = chatdebug.ContextWithStep(context.Background(), nil)
})
}
+786
View File
@@ -0,0 +1,786 @@
package chatdebug
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"strings"
"sync"
"charm.land/fantasy"
)
type debugModel struct {
inner fantasy.LanguageModel
svc *Service
opts RecorderOptions
}
var _ fantasy.LanguageModel = (*debugModel)(nil)
// normalizedCallOptions holds the optional model parameters shared by
// both regular and structured-output calls.
type normalizedCallOptions struct {
MaxOutputTokens *int64 `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int64 `json:"top_k,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
}
// normalizedCallPayload is the rich envelope persisted for Generate /
// Stream calls. It carries the full message structure and tool
// metadata so the debug panel can render conversation context.
type normalizedCallPayload struct {
Messages []normalizedMessage `json:"messages"`
Tools []normalizedTool `json:"tools,omitempty"`
Options normalizedCallOptions `json:"options"`
ToolChoice string `json:"tool_choice,omitempty"`
ProviderOptionCount int `json:"provider_option_count"`
}
// normalizedObjectCallPayload is the rich envelope for
// GenerateObject / StreamObject calls, including schema metadata.
type normalizedObjectCallPayload struct {
Messages []normalizedMessage `json:"messages"`
Options normalizedCallOptions `json:"options"`
SchemaName string `json:"schema_name,omitempty"`
SchemaDescription string `json:"schema_description,omitempty"`
StructuredOutput bool `json:"structured_output"`
ProviderOptionCount int `json:"provider_option_count"`
}
// normalizedResponsePayload is the rich envelope for persisted model
// responses. It includes the full content parts, finish reason, token
// usage breakdown, and any provider warnings.
type normalizedResponsePayload struct {
Content []normalizedContentPart `json:"content"`
FinishReason string `json:"finish_reason"`
Usage normalizedUsage `json:"usage"`
Warnings []normalizedWarning `json:"warnings,omitempty"`
}
// normalizedObjectResponsePayload is the rich envelope for
// structured-output responses. Raw text is bounded to length only.
type normalizedObjectResponsePayload struct {
RawTextLength int `json:"raw_text_length"`
FinishReason string `json:"finish_reason"`
Usage normalizedUsage `json:"usage"`
Warnings []normalizedWarning `json:"warnings,omitempty"`
StructuredOutput bool `json:"structured_output"`
}
// --------------- helper types ---------------
// normalizedMessage represents a single message in the prompt with
// its role and constituent parts.
type normalizedMessage struct {
Role string `json:"role"`
Parts []normalizedMessagePart `json:"parts"`
}
// MaxMessagePartTextLength is the rune limit for bounded text stored
// in request message parts. Longer text is truncated with an ellipsis.
const MaxMessagePartTextLength = 10_000
// maxStreamDebugTextBytes caps accumulated streamed text persisted in
// debug responses.
const maxStreamDebugTextBytes = 50_000
// normalizedMessagePart captures the type and bounded metadata for a
// single part within a prompt message. Text-like payloads are truncated
// to MaxMessagePartTextLength runes so request payloads stay bounded
// while still giving the debug panel readable content.
type normalizedMessagePart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
TextLength int `json:"text_length,omitempty"`
Filename string `json:"filename,omitempty"`
MediaType string `json:"media_type,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Result string `json:"result,omitempty"`
}
// normalizedTool captures tool identity along with any JSON input
// schema needed by the debug panel.
type normalizedTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
ID string `json:"id,omitempty"`
HasInputSchema bool `json:"has_input_schema,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
}
// normalizedContentPart captures one piece of the model response.
// Text is stored in full (the UI needs it), tool-call arguments are
// stored in bounded form while retaining their original length, and
// file data is never stored.
type normalizedContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Arguments string `json:"arguments,omitempty"`
InputLength int `json:"input_length,omitempty"`
MediaType string `json:"media_type,omitempty"`
SourceType string `json:"source_type,omitempty"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
}
// normalizedUsage mirrors fantasy.Usage with the full token
// breakdown so the debug panel can display cost/cache info.
type normalizedUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
}
// normalizedWarning captures a single provider warning.
type normalizedWarning struct {
Type string `json:"type"`
Setting string `json:"setting,omitempty"`
Details string `json:"details,omitempty"`
Message string `json:"message,omitempty"`
}
type normalizedErrorPayload struct {
Message string `json:"message"`
Type string `json:"type"`
ContextError string `json:"context_error,omitempty"`
ProviderTitle string `json:"provider_title,omitempty"`
ProviderStatus int `json:"provider_status,omitempty"`
IsRetryable bool `json:"is_retryable,omitempty"`
}
type streamSummary struct {
FinishReason string `json:"finish_reason,omitempty"`
TextDeltaCount int `json:"text_delta_count"`
ToolCallCount int `json:"tool_call_count"`
SourceCount int `json:"source_count"`
WarningCount int `json:"warning_count"`
ErrorCount int `json:"error_count"`
LastError string `json:"last_error,omitempty"`
PartCount int `json:"part_count"`
}
type objectStreamSummary struct {
FinishReason string `json:"finish_reason,omitempty"`
ObjectPartCount int `json:"object_part_count"`
TextDeltaCount int `json:"text_delta_count"`
ErrorCount int `json:"error_count"`
LastError string `json:"last_error,omitempty"`
WarningCount int `json:"warning_count"`
PartCount int `json:"part_count"`
StructuredOutput bool `json:"structured_output"`
}
func (d *debugModel) Generate(
ctx context.Context,
call fantasy.Call,
) (*fantasy.Response, error) {
handle, enrichedCtx := beginStep(ctx, d.svc, d.opts, OperationGenerate,
normalizeCall(call))
if handle == nil {
return d.inner.Generate(ctx, call)
}
resp, err := d.inner.Generate(enrichedCtx, call)
if err != nil {
handle.finish(ctx, StatusError, nil, nil, normalizeError(ctx, err), nil)
return nil, err
}
handle.finish(ctx, StatusCompleted, normalizeResponse(resp), &resp.Usage, nil, nil)
return resp, nil
}
func (d *debugModel) Stream(
ctx context.Context,
call fantasy.Call,
) (fantasy.StreamResponse, error) {
handle, enrichedCtx := beginStep(ctx, d.svc, d.opts, OperationStream,
normalizeCall(call))
if handle == nil {
return d.inner.Stream(ctx, call)
}
seq, err := d.inner.Stream(enrichedCtx, call)
if err != nil {
handle.finish(ctx, StatusError, nil, nil, normalizeError(ctx, err), nil)
return nil, err
}
return wrapStreamSeq(ctx, handle, seq), nil
}
func (d *debugModel) GenerateObject(
ctx context.Context,
call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) {
handle, enrichedCtx := beginStep(ctx, d.svc, d.opts, OperationGenerate,
normalizeObjectCall(call))
if handle == nil {
return d.inner.GenerateObject(ctx, call)
}
resp, err := d.inner.GenerateObject(enrichedCtx, call)
if err != nil {
handle.finish(ctx, StatusError, nil, nil, normalizeError(ctx, err),
map[string]any{"structured_output": true})
return nil, err
}
handle.finish(ctx, StatusCompleted, normalizeObjectResponse(resp), &resp.Usage,
nil, map[string]any{"structured_output": true})
return resp, nil
}
func (d *debugModel) StreamObject(
ctx context.Context,
call fantasy.ObjectCall,
) (fantasy.ObjectStreamResponse, error) {
handle, enrichedCtx := beginStep(ctx, d.svc, d.opts, OperationStream,
normalizeObjectCall(call))
if handle == nil {
return d.inner.StreamObject(ctx, call)
}
seq, err := d.inner.StreamObject(enrichedCtx, call)
if err != nil {
handle.finish(ctx, StatusError, nil, nil, normalizeError(ctx, err),
map[string]any{"structured_output": true})
return nil, err
}
return wrapObjectStreamSeq(ctx, handle, seq), nil
}
func (d *debugModel) Provider() string {
return d.inner.Provider()
}
func (d *debugModel) Model() string {
return d.inner.Model()
}
func wrapStreamSeq(
ctx context.Context,
handle *stepHandle,
seq iter.Seq[fantasy.StreamPart],
) fantasy.StreamResponse {
return func(yield func(fantasy.StreamPart) bool) {
var (
summary streamSummary
latestUsage fantasy.Usage
usageSeen bool
finishReason fantasy.FinishReason
textBuf strings.Builder
toolCalls []normalizedContentPart
once sync.Once
)
finalize := func(status Status) {
once.Do(func() {
summary.FinishReason = string(finishReason)
var content []normalizedContentPart
if text := textBuf.String(); text != "" {
content = append(content, normalizedContentPart{
Type: "text",
Text: text,
})
}
content = append(content, toolCalls...)
resp := normalizedResponsePayload{
Content: content,
FinishReason: string(finishReason),
}
if usageSeen {
resp.Usage = normalizeUsage(latestUsage)
}
var usage any
if usageSeen {
usage = &latestUsage
}
handle.finish(ctx, status, resp, usage, nil, map[string]any{
"stream_summary": summary,
})
})
}
if seq != nil {
seq(func(part fantasy.StreamPart) bool {
summary.PartCount++
summary.WarningCount += len(part.Warnings)
switch part.Type {
case fantasy.StreamPartTypeTextDelta:
summary.TextDeltaCount++
if textBuf.Len() < maxStreamDebugTextBytes {
textBuf.WriteString(part.Delta)
}
case fantasy.StreamPartTypeToolCall:
summary.ToolCallCount++
toolCalls = append(toolCalls, normalizedContentPart{
Type: "tool-call",
ToolCallID: part.ID,
ToolName: part.ToolCallName,
Arguments: boundText(part.ToolCallInput),
})
case fantasy.StreamPartTypeSource:
summary.SourceCount++
case fantasy.StreamPartTypeFinish:
finishReason = part.FinishReason
latestUsage = part.Usage
usageSeen = true
}
if part.Type == fantasy.StreamPartTypeError || part.Error != nil {
summary.ErrorCount++
if part.Error != nil {
summary.LastError = part.Error.Error()
}
}
if !yield(part) {
finalize(StatusInterrupted)
return false
}
return true
})
}
finalize(StatusCompleted)
}
}
func wrapObjectStreamSeq(
ctx context.Context,
handle *stepHandle,
seq iter.Seq[fantasy.ObjectStreamPart],
) fantasy.ObjectStreamResponse {
return func(yield func(fantasy.ObjectStreamPart) bool) {
var (
summary = objectStreamSummary{StructuredOutput: true}
latestUsage fantasy.Usage
usageSeen bool
finishReason fantasy.FinishReason
textBuf strings.Builder
once sync.Once
)
finalize := func(status Status) {
once.Do(func() {
summary.FinishReason = string(finishReason)
var content []normalizedContentPart
if text := textBuf.String(); text != "" {
content = append(content, normalizedContentPart{
Type: "text",
Text: text,
})
}
resp := normalizedResponsePayload{
Content: content,
FinishReason: string(finishReason),
}
if usageSeen {
resp.Usage = normalizeUsage(latestUsage)
}
var usage any
if usageSeen {
usage = &latestUsage
}
handle.finish(ctx, status, resp, usage, nil, map[string]any{
"structured_output": true,
"stream_summary": summary,
})
})
}
if seq != nil {
seq(func(part fantasy.ObjectStreamPart) bool {
summary.PartCount++
summary.WarningCount += len(part.Warnings)
switch part.Type {
case fantasy.ObjectStreamPartTypeObject:
summary.ObjectPartCount++
case fantasy.ObjectStreamPartTypeTextDelta:
summary.TextDeltaCount++
if textBuf.Len() < maxStreamDebugTextBytes {
textBuf.WriteString(part.Delta)
}
case fantasy.ObjectStreamPartTypeFinish:
finishReason = part.FinishReason
latestUsage = part.Usage
usageSeen = true
}
if part.Type == fantasy.ObjectStreamPartTypeError || part.Error != nil {
summary.ErrorCount++
if part.Error != nil {
summary.LastError = part.Error.Error()
}
}
if !yield(part) {
finalize(StatusInterrupted)
return false
}
return true
})
}
finalize(StatusCompleted)
}
}
// --------------- helper functions ---------------
// normalizeMessages converts a fantasy.Prompt into a slice of
// normalizedMessage values with bounded part metadata.
func normalizeMessages(prompt fantasy.Prompt) []normalizedMessage {
msgs := make([]normalizedMessage, 0, len(prompt))
for _, m := range prompt {
msgs = append(msgs, normalizedMessage{
Role: string(m.Role),
Parts: normalizeMessageParts(m.Content),
})
}
return msgs
}
// boundText truncates s to MaxMessagePartTextLength runes, appending
// an ellipsis if truncation occurs.
func boundText(s string) string {
return TruncateLabel(s, MaxMessagePartTextLength)
}
func mustMarshalJSON(label string, value any) json.RawMessage {
data, err := json.Marshal(value)
if err != nil {
panic(fmt.Sprintf("chatdebug: failed to marshal %s: %v", label, err))
}
return append(json.RawMessage(nil), data...)
}
func normalizeToolResultOutput(output fantasy.ToolResultOutputContent) string {
switch v := output.(type) {
case fantasy.ToolResultOutputContentText:
return boundText(v.Text)
case *fantasy.ToolResultOutputContentText:
return boundText(v.Text)
case fantasy.ToolResultOutputContentError:
if v.Error == nil {
return ""
}
return boundText(v.Error.Error())
case *fantasy.ToolResultOutputContentError:
if v.Error == nil {
return ""
}
return boundText(v.Error.Error())
case fantasy.ToolResultOutputContentMedia:
if v.Text != "" {
return boundText(v.Text)
}
if v.MediaType == "" {
return "[media output]"
}
return fmt.Sprintf("[media output: %s]", v.MediaType)
case *fantasy.ToolResultOutputContentMedia:
if v.Text != "" {
return boundText(v.Text)
}
if v.MediaType == "" {
return "[media output]"
}
return fmt.Sprintf("[media output: %s]", v.MediaType)
default:
if output == nil {
return ""
}
return boundText(string(mustMarshalJSON("tool result output", output)))
}
}
// normalizeMessageParts extracts type and bounded metadata from each
// MessagePart. Text-like payloads are bounded to
// MaxMessagePartTextLength runes so the debug panel can display
// readable content.
func normalizeMessageParts(parts []fantasy.MessagePart) []normalizedMessagePart {
result := make([]normalizedMessagePart, 0, len(parts))
for _, p := range parts {
np := normalizedMessagePart{
Type: string(p.GetType()),
}
switch v := p.(type) {
case fantasy.TextPart:
np.Text = boundText(v.Text)
np.TextLength = len(v.Text)
case *fantasy.TextPart:
np.Text = boundText(v.Text)
np.TextLength = len(v.Text)
case fantasy.ReasoningPart:
np.Text = boundText(v.Text)
np.TextLength = len(v.Text)
case *fantasy.ReasoningPart:
np.Text = boundText(v.Text)
np.TextLength = len(v.Text)
case fantasy.FilePart:
np.Filename = v.Filename
np.MediaType = v.MediaType
case *fantasy.FilePart:
np.Filename = v.Filename
np.MediaType = v.MediaType
case fantasy.ToolCallPart:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
np.Arguments = boundText(v.Input)
case *fantasy.ToolCallPart:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
np.Arguments = boundText(v.Input)
case fantasy.ToolResultPart:
np.ToolCallID = v.ToolCallID
np.Result = normalizeToolResultOutput(v.Output)
case *fantasy.ToolResultPart:
np.ToolCallID = v.ToolCallID
np.Result = normalizeToolResultOutput(v.Output)
}
result = append(result, np)
}
return result
}
// normalizeTools converts the tool list into lightweight descriptors.
// Function tool schemas are preserved so the debug panel can render
// parameter details without re-fetching provider metadata.
func normalizeTools(tools []fantasy.Tool) []normalizedTool {
if len(tools) == 0 {
return nil
}
result := make([]normalizedTool, 0, len(tools))
for _, t := range tools {
nt := normalizedTool{
Type: string(t.GetType()),
Name: t.GetName(),
}
switch v := t.(type) {
case fantasy.FunctionTool:
nt.Description = v.Description
nt.HasInputSchema = len(v.InputSchema) > 0
if nt.HasInputSchema {
nt.InputSchema = mustMarshalJSON(
fmt.Sprintf("tool %q input schema", v.Name),
v.InputSchema,
)
}
case *fantasy.FunctionTool:
nt.Description = v.Description
nt.HasInputSchema = len(v.InputSchema) > 0
if nt.HasInputSchema {
nt.InputSchema = mustMarshalJSON(
fmt.Sprintf("tool %q input schema", v.Name),
v.InputSchema,
)
}
case fantasy.ProviderDefinedTool:
nt.ID = v.ID
case *fantasy.ProviderDefinedTool:
nt.ID = v.ID
}
result = append(result, nt)
}
return result
}
// normalizeContentParts converts the response content into a slice
// of normalizedContentPart values. Text is stored in full (needed
// by the UI); tool-call arguments are stored in bounded form while
// preserving their original length; file data is never stored.
func normalizeContentParts(content fantasy.ResponseContent) []normalizedContentPart {
result := make([]normalizedContentPart, 0, len(content))
for _, c := range content {
np := normalizedContentPart{
Type: string(c.GetType()),
}
switch v := c.(type) {
case fantasy.TextContent:
np.Text = v.Text
case *fantasy.TextContent:
np.Text = v.Text
case fantasy.ReasoningContent:
np.Text = v.Text
case *fantasy.ReasoningContent:
np.Text = v.Text
case fantasy.ToolCallContent:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
np.Arguments = boundText(v.Input)
np.InputLength = len(v.Input)
case *fantasy.ToolCallContent:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
np.Arguments = boundText(v.Input)
np.InputLength = len(v.Input)
case fantasy.FileContent:
np.MediaType = v.MediaType
case *fantasy.FileContent:
np.MediaType = v.MediaType
case fantasy.SourceContent:
np.SourceType = string(v.SourceType)
np.Title = v.Title
np.URL = v.URL
case *fantasy.SourceContent:
np.SourceType = string(v.SourceType)
np.Title = v.Title
np.URL = v.URL
case fantasy.ToolResultContent:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
case *fantasy.ToolResultContent:
np.ToolCallID = v.ToolCallID
np.ToolName = v.ToolName
}
result = append(result, np)
}
return result
}
// normalizeUsage maps the full fantasy.Usage token breakdown into
// the debug-friendly normalizedUsage struct.
func normalizeUsage(u fantasy.Usage) normalizedUsage {
return normalizedUsage{
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
TotalTokens: u.TotalTokens,
ReasoningTokens: u.ReasoningTokens,
CacheCreationTokens: u.CacheCreationTokens,
CacheReadTokens: u.CacheReadTokens,
}
}
// normalizeWarnings converts provider call warnings into their
// normalized form. Returns nil for empty input to keep JSON clean.
func normalizeWarnings(warnings []fantasy.CallWarning) []normalizedWarning {
if len(warnings) == 0 {
return nil
}
result := make([]normalizedWarning, 0, len(warnings))
for _, w := range warnings {
result = append(result, normalizedWarning{
Type: string(w.Type),
Setting: w.Setting,
Details: w.Details,
Message: w.Message,
})
}
return result
}
// --------------- normalize functions ---------------
func normalizeCall(call fantasy.Call) normalizedCallPayload {
payload := normalizedCallPayload{
Messages: normalizeMessages(call.Prompt),
Tools: normalizeTools(call.Tools),
Options: normalizedCallOptions{
MaxOutputTokens: call.MaxOutputTokens,
Temperature: call.Temperature,
TopP: call.TopP,
TopK: call.TopK,
PresencePenalty: call.PresencePenalty,
FrequencyPenalty: call.FrequencyPenalty,
},
ProviderOptionCount: len(call.ProviderOptions),
}
if call.ToolChoice != nil {
payload.ToolChoice = string(*call.ToolChoice)
}
return payload
}
func normalizeObjectCall(call fantasy.ObjectCall) normalizedObjectCallPayload {
return normalizedObjectCallPayload{
Messages: normalizeMessages(call.Prompt),
Options: normalizedCallOptions{
MaxOutputTokens: call.MaxOutputTokens,
Temperature: call.Temperature,
TopP: call.TopP,
TopK: call.TopK,
PresencePenalty: call.PresencePenalty,
FrequencyPenalty: call.FrequencyPenalty,
},
SchemaName: call.SchemaName,
SchemaDescription: call.SchemaDescription,
StructuredOutput: true,
ProviderOptionCount: len(call.ProviderOptions),
}
}
func normalizeResponse(resp *fantasy.Response) normalizedResponsePayload {
if resp == nil {
return normalizedResponsePayload{}
}
return normalizedResponsePayload{
Content: normalizeContentParts(resp.Content),
FinishReason: string(resp.FinishReason),
Usage: normalizeUsage(resp.Usage),
Warnings: normalizeWarnings(resp.Warnings),
}
}
func normalizeObjectResponse(resp *fantasy.ObjectResponse) normalizedObjectResponsePayload {
if resp == nil {
return normalizedObjectResponsePayload{StructuredOutput: true}
}
return normalizedObjectResponsePayload{
RawTextLength: len(resp.RawText),
FinishReason: string(resp.FinishReason),
Usage: normalizeUsage(resp.Usage),
Warnings: normalizeWarnings(resp.Warnings),
StructuredOutput: true,
}
}
func normalizeError(ctx context.Context, err error) normalizedErrorPayload {
payload := normalizedErrorPayload{}
if err == nil {
return payload
}
payload.Message = err.Error()
payload.Type = fmt.Sprintf("%T", err)
if ctxErr := ctx.Err(); ctxErr != nil {
payload.ContextError = ctxErr.Error()
}
var providerErr *fantasy.ProviderError
if errors.As(err, &providerErr) {
payload.ProviderTitle = providerErr.Title
payload.ProviderStatus = providerErr.StatusCode
payload.IsRetryable = providerErr.IsRetryable()
}
return payload
}
@@ -0,0 +1,86 @@
package chatdebug //nolint:testpackage // Uses unexported normalization helpers.
import (
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/require"
)
func TestNormalizeCall_PreservesToolSchemasAndMessageToolPayloads(t *testing.T) {
t.Parallel()
payload := normalizeCall(fantasy.Call{
Prompt: fantasy.Prompt{
{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{
fantasy.ToolCallPart{
ToolCallID: "call-search",
ToolName: "search_docs",
Input: `{"query":"debug panel"}`,
},
},
},
{
Role: fantasy.MessageRoleTool,
Content: []fantasy.MessagePart{
fantasy.ToolResultPart{
ToolCallID: "call-search",
Output: fantasy.ToolResultOutputContentText{
Text: `{"matches":["model.go","DebugStepCard.tsx"]}`,
},
},
},
},
},
Tools: []fantasy.Tool{
fantasy.FunctionTool{
Name: "search_docs",
Description: "Searches documentation.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
},
"required": []string{"query"},
},
},
},
})
require.Len(t, payload.Tools, 1)
require.True(t, payload.Tools[0].HasInputSchema)
require.JSONEq(t, `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`,
string(payload.Tools[0].InputSchema))
require.Len(t, payload.Messages, 2)
require.Equal(t, `{"query":"debug panel"}`, payload.Messages[0].Parts[0].Arguments)
require.Equal(t,
`{"matches":["model.go","DebugStepCard.tsx"]}`,
payload.Messages[1].Parts[0].Result,
)
}
func TestNormalizeResponse_PreservesToolCallArguments(t *testing.T) {
t.Parallel()
payload := normalizeResponse(&fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.ToolCallContent{
ToolCallID: "call-calc",
ToolName: "calculator",
Input: `{"operation":"add","operands":[2,2]}`,
},
},
})
require.Len(t, payload.Content, 1)
require.Equal(t, "call-calc", payload.Content[0].ToolCallID)
require.Equal(t, "calculator", payload.Content[0].ToolName)
require.JSONEq(t,
`{"operation":"add","operands":[2,2]}`,
payload.Content[0].Arguments,
)
require.Equal(t, len(`{"operation":"add","operands":[2,2]}`), payload.Content[0].InputLength)
}
+582
View File
@@ -0,0 +1,582 @@
package chatdebug //nolint:testpackage // Uses unexported debug-model helpers.
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"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/testutil"
)
type scriptedModel struct {
provider string
model string
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
streamFn func(context.Context, fantasy.Call) (fantasy.StreamResponse, error)
generateObjFn func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error)
streamObjFn func(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error)
}
func (s *scriptedModel) Generate(
ctx context.Context,
call fantasy.Call,
) (*fantasy.Response, error) {
if s.generateFn == nil {
return &fantasy.Response{}, nil
}
return s.generateFn(ctx, call)
}
func (s *scriptedModel) Stream(
ctx context.Context,
call fantasy.Call,
) (fantasy.StreamResponse, error) {
if s.streamFn == nil {
return fantasy.StreamResponse(func(func(fantasy.StreamPart) bool) {}), nil
}
return s.streamFn(ctx, call)
}
func (s *scriptedModel) GenerateObject(
ctx context.Context,
call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) {
if s.generateObjFn == nil {
return &fantasy.ObjectResponse{}, nil
}
return s.generateObjFn(ctx, call)
}
func (s *scriptedModel) StreamObject(
ctx context.Context,
call fantasy.ObjectCall,
) (fantasy.ObjectStreamResponse, error) {
if s.streamObjFn == nil {
return fantasy.ObjectStreamResponse(func(func(fantasy.ObjectStreamPart) bool) {}), nil
}
return s.streamObjFn(ctx, call)
}
func (s *scriptedModel) Provider() string { return s.provider }
func (s *scriptedModel) Model() string { return s.model }
type testError struct{ message string }
func (e *testError) Error() string { return e.message }
func TestDebugModel_Provider(t *testing.T) {
t.Parallel()
inner := &stubModel{provider: "provider-a", model: "model-a"}
model := &debugModel{inner: inner}
require.Equal(t, inner.Provider(), model.Provider())
}
func TestDebugModel_Model(t *testing.T) {
t.Parallel()
inner := &stubModel{provider: "provider-a", model: "model-a"}
model := &debugModel{inner: inner}
require.Equal(t, inner.Model(), model.Model())
}
func TestDebugModel_Disabled(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
svc := NewService(db, testutil.Logger(t), nil)
respWant := &fantasy.Response{FinishReason: fantasy.FinishReasonStop}
inner := &scriptedModel{
generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
_, ok := StepFromContext(ctx)
require.False(t, ok)
require.Nil(t, attemptSinkFromContext(ctx))
return respWant, nil
},
}
model := &debugModel{
inner: inner,
svc: svc,
opts: RecorderOptions{
ChatID: chatID,
OwnerID: ownerID,
},
}
resp, err := model.Generate(context.Background(), fantasy.Call{})
require.NoError(t, err)
require.Same(t, respWant, resp)
}
func TestDebugModel_Generate(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
call := fantasy.Call{
Prompt: fantasy.Prompt{fantasy.NewUserMessage("hello")},
MaxOutputTokens: int64Ptr(128),
Temperature: float64Ptr(0.25),
}
respWant := &fantasy.Response{
Content: fantasy.ResponseContent{
fantasy.TextContent{Text: "hello"},
fantasy.ToolCallContent{ToolCallID: "tool-1", ToolName: "tool", Input: `{}`},
fantasy.SourceContent{ID: "source-1", Title: "docs", URL: "https://example.com"},
},
FinishReason: fantasy.FinishReasonStop,
Usage: fantasy.Usage{InputTokens: 10, OutputTokens: 4, TotalTokens: 14},
Warnings: []fantasy.CallWarning{{Message: "warning"}},
}
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, runID, params.RunID)
require.EqualValues(t, 1, params.StepNumber)
require.Equal(t, string(OperationGenerate), params.Operation)
require.Equal(t, string(StatusInProgress), params.Status)
require.JSONEq(t, `{"messages":[{"role":"user","parts":[{"type":"text","text":"hello","text_length":5}]}],"options":{"max_output_tokens":128,"temperature":0.25},"provider_option_count":0}`,
string(params.NormalizedRequest.RawMessage))
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, stepID, params.ID)
require.Equal(t, chatID, params.ChatID)
require.Equal(t, string(StatusCompleted), params.Status.String)
require.True(t, params.NormalizedResponse.Valid)
require.JSONEq(t, `{"content":[{"type":"text","text":"hello"},{"type":"tool-call","tool_call_id":"tool-1","tool_name":"tool","arguments":"{}","input_length":2},{"type":"source","title":"docs","url":"https://example.com"}],"finish_reason":"stop","usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0},"warnings":[{"type":"","message":"warning"}]}`,
string(params.NormalizedResponse.RawMessage))
require.True(t, params.Usage.Valid)
require.JSONEq(t, `{"input_tokens":10,"output_tokens":4,"total_tokens":14,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}`,
string(params.Usage.RawMessage))
require.True(t, params.Attempts.Valid)
require.JSONEq(t, `[]`, string(params.Attempts.RawMessage))
require.False(t, params.Error.Valid)
require.False(t, params.Metadata.Valid)
require.True(t, params.FinishedAt.Valid)
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
inner := &scriptedModel{
generateFn: func(ctx context.Context, got fantasy.Call) (*fantasy.Response, error) {
require.Equal(t, call, got)
stepCtx, ok := StepFromContext(ctx)
require.True(t, ok)
require.Equal(t, runID, stepCtx.RunID)
require.Equal(t, stepID, stepCtx.StepID)
require.NotNil(t, attemptSinkFromContext(ctx))
return respWant, nil
},
}
model := &debugModel{
inner: inner,
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
resp, err := model.Generate(ctx, call)
require.NoError(t, err)
require.Same(t, respWant, resp)
}
func TestDebugModel_GeneratePersistsAttemptsWithoutResponseClose(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.JSONEq(t, `{"message":"hello","api_key":"super-secret"}`,
string(body))
require.Equal(t, "Bearer top-secret", req.Header.Get("Authorization"))
rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("X-API-Key", "response-secret")
rw.WriteHeader(http.StatusCreated)
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
}))
defer server.Close()
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Return(
database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil,
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, stepID, params.ID)
require.Equal(t, chatID, params.ChatID)
require.Equal(t, string(StatusCompleted), params.Status.String)
require.True(t, params.Attempts.Valid)
var attempts []Attempt
require.NoError(t, json.Unmarshal(params.Attempts.RawMessage, &attempts))
require.Len(t, attempts, 1)
require.Equal(t, 1, attempts[0].Number)
require.Equal(t, RedactedValue, attempts[0].RequestHeaders["Authorization"])
require.JSONEq(t,
`{"message":"hello","api_key":"[REDACTED]"}`,
string(attempts[0].RequestBody),
)
require.Equal(t, http.StatusCreated, attempts[0].ResponseStatus)
require.Equal(t, "application/json", attempts[0].ResponseHeaders["Content-Type"])
require.Equal(t, RedactedValue, attempts[0].ResponseHeaders["X-Api-Key"])
require.JSONEq(t,
`{"token":"[REDACTED]","safe":"ok"}`,
string(attempts[0].ResponseBody),
)
require.Empty(t, attempts[0].Error)
require.GreaterOrEqual(t, attempts[0].DurationMs, int64(0))
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
inner := &scriptedModel{
generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
server.URL,
strings.NewReader(`{"message":"hello","api_key":"super-secret"}`),
)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer top-secret")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, `{"token":"response-secret","safe":"ok"}`, string(body))
require.NoError(t, resp.Body.Close())
return &fantasy.Response{FinishReason: fantasy.FinishReasonStop}, nil
},
}
model := &debugModel{
inner: inner,
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
resp, err := model.Generate(ctx, fantasy.Call{})
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestDebugModel_GenerateError(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
wantErr := &testError{message: "boom"}
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Return(
database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil,
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, string(StatusError), params.Status.String)
require.False(t, params.NormalizedResponse.Valid)
require.False(t, params.Usage.Valid)
require.True(t, params.Error.Valid)
require.JSONEq(t, `{"message":"boom","type":"*chatdebug.testError"}`,
string(params.Error.RawMessage))
require.False(t, params.Metadata.Valid)
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
model := &debugModel{
inner: &scriptedModel{
generateFn: func(context.Context, fantasy.Call) (*fantasy.Response, error) {
return nil, wantErr
},
},
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
resp, err := model.Generate(ctx, fantasy.Call{})
require.Nil(t, resp)
require.ErrorIs(t, err, wantErr)
}
func TestDebugModel_Stream(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
errPart := xerrors.New("chunk failed")
parts := []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextDelta, Delta: "hel"},
{Type: fantasy.StreamPartTypeToolCall, ID: "tool-call-1", ToolCallName: "tool"},
{Type: fantasy.StreamPartTypeSource, ID: "source-1", URL: "https://example.com", Title: "docs"},
{Type: fantasy.StreamPartTypeWarnings, Warnings: []fantasy.CallWarning{{Message: "w1"}, {Message: "w2"}}},
{Type: fantasy.StreamPartTypeError, Error: errPart},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop, Usage: fantasy.Usage{InputTokens: 8, OutputTokens: 3, TotalTokens: 11}},
}
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Return(
database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil,
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, string(StatusCompleted), params.Status.String)
require.True(t, params.NormalizedResponse.Valid)
require.JSONEq(t, `{"content":[{"type":"text","text":"hel"},{"type":"tool-call","tool_call_id":"tool-call-1","tool_name":"tool"}],"finish_reason":"stop","usage":{"input_tokens":8,"output_tokens":3,"total_tokens":11,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}}`,
string(params.NormalizedResponse.RawMessage))
require.True(t, params.Usage.Valid)
require.JSONEq(t, `{"input_tokens":8,"output_tokens":3,"total_tokens":11,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}`,
string(params.Usage.RawMessage))
require.True(t, params.Metadata.Valid)
require.JSONEq(t, `{"stream_summary":{"finish_reason":"stop","text_delta_count":1,"tool_call_count":1,"source_count":1,"warning_count":2,"error_count":1,"last_error":"chunk failed","part_count":6}}`,
string(params.Metadata.RawMessage))
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
model := &debugModel{
inner: &scriptedModel{
streamFn: func(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
stepCtx, ok := StepFromContext(ctx)
require.True(t, ok)
require.Equal(t, stepID, stepCtx.StepID)
require.NotNil(t, attemptSinkFromContext(ctx))
return partsToSeq(parts), nil
},
},
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
seq, err := model.Stream(ctx, fantasy.Call{})
require.NoError(t, err)
got := make([]fantasy.StreamPart, 0, len(parts))
for part := range seq {
got = append(got, part)
}
require.Equal(t, parts, got)
}
func TestDebugModel_StreamObject(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
parts := []fantasy.ObjectStreamPart{
{Type: fantasy.ObjectStreamPartTypeTextDelta, Delta: "ob"},
{Type: fantasy.ObjectStreamPartTypeTextDelta, Delta: "ject"},
{Type: fantasy.ObjectStreamPartTypeObject, Object: map[string]any{"value": "object"}},
{Type: fantasy.ObjectStreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop, Usage: fantasy.Usage{InputTokens: 5, OutputTokens: 2, TotalTokens: 7}},
}
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Return(
database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil,
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, string(StatusCompleted), params.Status.String)
require.True(t, params.NormalizedResponse.Valid)
require.JSONEq(t, `{"content":[{"type":"text","text":"object"}],"finish_reason":"stop","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}}`,
string(params.NormalizedResponse.RawMessage))
require.True(t, params.Usage.Valid)
require.JSONEq(t, `{"input_tokens":5,"output_tokens":2,"total_tokens":7,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}`,
string(params.Usage.RawMessage))
require.True(t, params.Metadata.Valid)
require.JSONEq(t, `{"structured_output":true,"stream_summary":{"finish_reason":"stop","object_part_count":1,"text_delta_count":2,"error_count":0,"warning_count":0,"part_count":4,"structured_output":true}}`,
string(params.Metadata.RawMessage))
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
model := &debugModel{
inner: &scriptedModel{
streamObjFn: func(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
stepCtx, ok := StepFromContext(ctx)
require.True(t, ok)
require.Equal(t, stepID, stepCtx.StepID)
require.NotNil(t, attemptSinkFromContext(ctx))
return objectPartsToSeq(parts), nil
},
},
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
seq, err := model.StreamObject(ctx, fantasy.ObjectCall{})
require.NoError(t, err)
got := make([]fantasy.ObjectStreamPart, 0, len(parts))
for part := range seq {
got = append(got, part)
}
require.Equal(t, parts, got)
}
func TestDebugModel_StreamEarlyStop(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
parts := []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextDelta, Delta: "first"},
{Type: fantasy.StreamPartTypeTextDelta, Delta: "second"},
}
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Return(
database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil,
)
db.EXPECT().UpdateChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.UpdateChatDebugStepParams) (database.ChatDebugStep, error) {
require.Equal(t, string(StatusInterrupted), params.Status.String)
require.True(t, params.NormalizedResponse.Valid)
require.JSONEq(t, `{"content":[{"type":"text","text":"first"}],"finish_reason":"","usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0,"reasoning_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0}}`,
string(params.NormalizedResponse.RawMessage))
require.False(t, params.Usage.Valid)
require.True(t, params.Metadata.Valid)
require.JSONEq(t, `{"stream_summary":{"text_delta_count":1,"tool_call_count":0,"source_count":0,"warning_count":0,"error_count":0,"part_count":1}}`,
string(params.Metadata.RawMessage))
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
model := &debugModel{
inner: &scriptedModel{
streamFn: func(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
return partsToSeq(parts), nil
},
},
svc: svc,
opts: RecorderOptions{ChatID: chatID, OwnerID: ownerID},
}
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
seq, err := model.Stream(ctx, fantasy.Call{})
require.NoError(t, err)
count := 0
for part := range seq {
require.Equal(t, parts[0], part)
count++
break
}
require.Equal(t, 1, count)
}
func objectPartsToSeq(parts []fantasy.ObjectStreamPart) fantasy.ObjectStreamResponse {
return func(yield func(fantasy.ObjectStreamPart) bool) {
for _, part := range parts {
if !yield(part) {
return
}
}
}
}
func partsToSeq(parts []fantasy.StreamPart) fantasy.StreamResponse {
return func(yield func(fantasy.StreamPart) bool) {
for _, part := range parts {
if !yield(part) {
return
}
}
}
}
func int64Ptr(v int64) *int64 { return &v }
func float64Ptr(v float64) *float64 { return &v }
+184
View File
@@ -0,0 +1,184 @@
package chatdebug
import (
"context"
"sync"
"sync/atomic"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"cdr.dev/slog/v3"
)
// RecorderOptions identifies the chat/model context for debug recording.
type RecorderOptions struct {
ChatID uuid.UUID
OwnerID uuid.UUID
Provider string
Model string
}
// WrapModel returns model unchanged when debug recording is disabled, or a
// debug wrapper when a service is available.
func WrapModel(
model fantasy.LanguageModel,
svc *Service,
opts RecorderOptions,
) fantasy.LanguageModel {
if model == nil {
panic("chatdebug: nil LanguageModel")
}
if svc == nil {
return model
}
return &debugModel{inner: model, svc: svc, opts: opts}
}
type attemptSink struct {
mu sync.Mutex
attempts []Attempt
}
func (s *attemptSink) record(a Attempt) {
s.mu.Lock()
defer s.mu.Unlock()
s.attempts = append(s.attempts, a)
}
func (s *attemptSink) snapshot() []Attempt {
s.mu.Lock()
defer s.mu.Unlock()
attempts := make([]Attempt, len(s.attempts))
copy(attempts, s.attempts)
return attempts
}
type attemptSinkKey struct{}
func withAttemptSink(ctx context.Context, sink *attemptSink) context.Context {
if sink == nil {
panic("chatdebug: nil attemptSink")
}
return context.WithValue(ctx, attemptSinkKey{}, sink)
}
func attemptSinkFromContext(ctx context.Context) *attemptSink {
sink, _ := ctx.Value(attemptSinkKey{}).(*attemptSink)
return sink
}
var stepCounters sync.Map // map[uuid.UUID]*atomic.Int32
func nextStepNumber(runID uuid.UUID) int32 {
val, _ := stepCounters.LoadOrStore(runID, &atomic.Int32{})
counter, ok := val.(*atomic.Int32)
if !ok {
panic("chatdebug: invalid step counter type")
}
return counter.Add(1)
}
type stepHandle struct {
stepCtx *StepContext
sink *attemptSink
svc *Service
opts RecorderOptions
}
// beginStep validates preconditions, creates a debug step, and returns a
// handle plus an enriched context carrying StepContext and attemptSink.
// Returns (nil, original ctx) when debug recording should be skipped.
func beginStep(
ctx context.Context,
svc *Service,
opts RecorderOptions,
op Operation,
normalizedReq any,
) (*stepHandle, context.Context) {
if svc == nil || !svc.IsEnabled(ctx, opts.ChatID, opts.OwnerID) {
return nil, ctx
}
rc, ok := RunFromContext(ctx)
if !ok {
return nil, ctx
}
holder, reuseStep := reuseHolderFromContext(ctx)
if reuseStep {
holder.mu.Lock()
defer holder.mu.Unlock()
if holder.handle != nil {
enriched := ContextWithStep(ctx, holder.handle.stepCtx)
enriched = withAttemptSink(enriched, holder.handle.sink)
return holder.handle, enriched
}
}
stepNum := nextStepNumber(rc.RunID)
step, err := svc.CreateStep(ctx, CreateStepParams{
RunID: rc.RunID,
ChatID: opts.ChatID,
StepNumber: stepNum,
Operation: op,
Status: StatusInProgress,
HistoryTipMessageID: rc.HistoryTipMessageID,
NormalizedRequest: normalizedReq,
})
if err != nil {
svc.log.Warn(ctx, "failed to create chat debug step",
slog.Error(err),
slog.F("chat_id", opts.ChatID),
slog.F("run_id", rc.RunID),
slog.F("operation", op),
)
return nil, ctx
}
sc := &StepContext{
StepID: step.ID,
RunID: rc.RunID,
ChatID: opts.ChatID,
StepNumber: stepNum,
Operation: op,
HistoryTipMessageID: rc.HistoryTipMessageID,
}
handle := &stepHandle{stepCtx: sc, sink: &attemptSink{}, svc: svc, opts: opts}
enriched := ContextWithStep(ctx, handle.stepCtx)
enriched = withAttemptSink(enriched, handle.sink)
if reuseStep {
holder.handle = handle
}
return handle, enriched
}
// finish updates the debug step with final status and data.
func (h *stepHandle) finish(
ctx context.Context,
status Status,
response any,
usage any,
errPayload any,
metadata any,
) {
if h == nil {
return
}
_, _ = h.svc.UpdateStep(ctx, UpdateStepParams{
ID: h.stepCtx.StepID,
ChatID: h.opts.ChatID,
Status: status,
NormalizedResponse: response,
Usage: usage,
Attempts: h.sink.snapshot(),
Error: errPayload,
Metadata: metadata,
FinishedAt: time.Now(),
})
}
+175
View File
@@ -0,0 +1,175 @@
package chatdebug //nolint:testpackage // Uses unexported recorder helpers.
import (
"context"
"sort"
"sync"
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
)
type stubModel struct {
provider string
model string
}
func (*stubModel) Generate(
ctx context.Context,
call fantasy.Call,
) (*fantasy.Response, error) {
return &fantasy.Response{}, nil
}
func (*stubModel) Stream(
ctx context.Context,
call fantasy.Call,
) (fantasy.StreamResponse, error) {
return fantasy.StreamResponse(func(func(fantasy.StreamPart) bool) {}), nil
}
func (*stubModel) GenerateObject(
ctx context.Context,
call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) {
return &fantasy.ObjectResponse{}, nil
}
func (*stubModel) StreamObject(
ctx context.Context,
call fantasy.ObjectCall,
) (fantasy.ObjectStreamResponse, error) {
return nil, xerrors.New("not implemented")
}
func (s *stubModel) Provider() string {
return s.provider
}
func (s *stubModel) Model() string {
return s.model
}
func TestAttemptSink_ThreadSafe(t *testing.T) {
t.Parallel()
const n = 256
sink := &attemptSink{}
var wg sync.WaitGroup
wg.Add(n)
for i := range n {
i := i
go func() {
defer wg.Done()
sink.record(Attempt{Number: i + 1, ResponseStatus: 200 + i})
}()
}
wg.Wait()
attempts := sink.snapshot()
require.Len(t, attempts, n)
numbers := make([]int, 0, n)
statuses := make([]int, 0, n)
for _, attempt := range attempts {
numbers = append(numbers, attempt.Number)
statuses = append(statuses, attempt.ResponseStatus)
}
sort.Ints(numbers)
sort.Ints(statuses)
for i := range n {
require.Equal(t, i+1, numbers[i])
require.Equal(t, 200+i, statuses[i])
}
}
func TestAttemptSinkContext(t *testing.T) {
t.Parallel()
ctx := context.Background()
require.Nil(t, attemptSinkFromContext(ctx))
sink := &attemptSink{}
ctx = withAttemptSink(ctx, sink)
require.Same(t, sink, attemptSinkFromContext(ctx))
}
func TestWrapModel_NilModel(t *testing.T) {
t.Parallel()
require.Panics(t, func() {
WrapModel(nil, &Service{}, RecorderOptions{})
})
}
func TestWrapModel_NilService(t *testing.T) {
t.Parallel()
model := &stubModel{provider: "provider", model: "model"}
wrapped := WrapModel(model, nil, RecorderOptions{})
require.Same(t, model, wrapped)
}
func TestNextStepNumber_Concurrent(t *testing.T) {
t.Parallel()
const n = 256
runID := uuid.New()
results := make([]int, n)
var wg sync.WaitGroup
wg.Add(n)
for i := range n {
i := i
go func() {
defer wg.Done()
results[i] = int(nextStepNumber(runID))
}()
}
wg.Wait()
sort.Ints(results)
for i := range n {
require.Equal(t, i+1, results[i])
}
}
func TestStepHandleFinish_NilHandle(t *testing.T) {
t.Parallel()
var handle *stepHandle
handle.finish(context.Background(), StatusCompleted, nil, nil, nil, nil)
}
func TestBeginStep_NilService(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, enriched := beginStep(ctx, nil, RecorderOptions{}, OperationGenerate, nil)
require.Nil(t, handle)
require.Nil(t, attemptSinkFromContext(enriched))
_, ok := StepFromContext(enriched)
require.False(t, ok)
}
func TestWrapModel_ReturnsDebugModel(t *testing.T) {
t.Parallel()
model := &stubModel{provider: "provider", model: "model"}
wrapped := WrapModel(model, &Service{}, RecorderOptions{})
require.NotSame(t, model, wrapped)
require.IsType(t, &debugModel{}, wrapped)
require.Implements(t, (*fantasy.LanguageModel)(nil), wrapped)
require.Equal(t, model.Provider(), wrapped.Provider())
require.Equal(t, model.Model(), wrapped.Model())
}
+140
View File
@@ -0,0 +1,140 @@
package chatdebug
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
)
// RedactedValue replaces sensitive values in debug payloads.
const RedactedValue = "[REDACTED]"
var sensitiveHeaderNames = map[string]struct{}{
"authorization": {},
"x-api-key": {},
"api-key": {},
"proxy-authorization": {},
}
var sensitiveJSONKeyFragments = []string{
"token",
"secret",
"key",
"password",
"authorization",
"credential",
}
// RedactHeaders returns a flattened copy of h with sensitive values redacted.
func RedactHeaders(h http.Header) map[string]string {
if h == nil {
return nil
}
redacted := make(map[string]string, len(h))
for name, values := range h {
if isSensitiveHeaderName(name) {
redacted[name] = RedactedValue
continue
}
redacted[name] = strings.Join(values, ", ")
}
return redacted
}
// RedactJSONSecrets redacts sensitive JSON values by key name.
func RedactJSONSecrets(data []byte) []byte {
if len(data) == 0 {
return data
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return data
}
if err := consumeJSONEOF(decoder); err != nil {
return data
}
redacted, changed := redactJSONValue(value)
if !changed {
return data
}
encoded, err := json.Marshal(redacted)
if err != nil {
return data
}
return encoded
}
func consumeJSONEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return err
}
return nil
}
func isSensitiveHeaderName(name string) bool {
lowerName := strings.ToLower(name)
if _, ok := sensitiveHeaderNames[lowerName]; ok {
return true
}
if strings.Contains(lowerName, "ratelimit") {
return false
}
return strings.Contains(lowerName, "token") ||
strings.Contains(lowerName, "secret")
}
func isSensitiveJSONKey(key string) bool {
lowerKey := strings.ToLower(key)
for _, fragment := range sensitiveJSONKeyFragments {
if strings.Contains(lowerKey, fragment) {
return true
}
}
return false
}
func redactJSONValue(value any) (any, bool) {
switch typed := value.(type) {
case map[string]any:
changed := false
for key, child := range typed {
if isSensitiveJSONKey(key) {
if current, ok := child.(string); ok && current == RedactedValue {
continue
}
typed[key] = RedactedValue
changed = true
continue
}
redactedChild, childChanged := redactJSONValue(child)
if childChanged {
typed[key] = redactedChild
changed = true
}
}
return typed, changed
case []any:
changed := false
for i, child := range typed {
redactedChild, childChanged := redactJSONValue(child)
if childChanged {
typed[i] = redactedChild
changed = true
}
}
return typed, changed
default:
return value, false
}
}
+171
View File
@@ -0,0 +1,171 @@
package chatdebug_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
)
func TestRedactHeaders(t *testing.T) {
t.Parallel()
t.Run("nil input", func(t *testing.T) {
t.Parallel()
require.Nil(t, chatdebug.RedactHeaders(nil))
})
t.Run("empty header", func(t *testing.T) {
t.Parallel()
redacted := chatdebug.RedactHeaders(http.Header{})
require.NotNil(t, redacted)
require.Empty(t, redacted)
})
t.Run("authorization redacted and others preserved", func(t *testing.T) {
t.Parallel()
headers := http.Header{
"Authorization": {"Bearer secret-token"},
"Accept": {"application/json"},
}
redacted := chatdebug.RedactHeaders(headers)
require.Equal(t, chatdebug.RedactedValue, redacted["Authorization"])
require.Equal(t, "application/json", redacted["Accept"])
})
t.Run("multi-value headers are flattened", func(t *testing.T) {
t.Parallel()
headers := http.Header{
"Accept": {"application/json", "text/plain"},
}
redacted := chatdebug.RedactHeaders(headers)
require.Equal(t, "application/json, text/plain", redacted["Accept"])
})
t.Run("header name matching is case insensitive", func(t *testing.T) {
t.Parallel()
lowerAuthorization := "authorization"
upperAuthorization := "AUTHORIZATION"
headers := http.Header{
lowerAuthorization: {"lower"},
upperAuthorization: {"upper"},
}
redacted := chatdebug.RedactHeaders(headers)
require.Equal(t, chatdebug.RedactedValue, redacted[lowerAuthorization])
require.Equal(t, chatdebug.RedactedValue, redacted[upperAuthorization])
})
t.Run("token and secret substrings are redacted", func(t *testing.T) {
t.Parallel()
traceHeader := "X-Trace-ID"
headers := http.Header{
"X-Custom-Token": {"abc"},
"X-Custom-Secret": {"def"},
traceHeader: {"trace"},
}
redacted := chatdebug.RedactHeaders(headers)
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Token"])
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Secret"])
require.Equal(t, "trace", redacted[traceHeader])
})
t.Run("rate limit headers containing token are not redacted", func(t *testing.T) {
t.Parallel()
headers := http.Header{
"Anthropic-Ratelimit-Tokens-Limit": {"1000000"},
"Anthropic-Ratelimit-Tokens-Remaining": {"999000"},
"Anthropic-Ratelimit-Tokens-Reset": {"2026-03-31T08:55:26Z"},
}
redacted := chatdebug.RedactHeaders(headers)
require.Equal(t, "1000000", redacted["Anthropic-Ratelimit-Tokens-Limit"])
require.Equal(t, "999000", redacted["Anthropic-Ratelimit-Tokens-Remaining"])
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Ratelimit-Tokens-Reset"])
})
t.Run("original header is not modified", func(t *testing.T) {
t.Parallel()
headers := http.Header{
"Authorization": {"Bearer keep-me"},
"X-Test": {"value"},
}
redacted := chatdebug.RedactHeaders(headers)
redacted["X-Test"] = "changed"
require.Equal(t, []string{"Bearer keep-me"}, headers["Authorization"])
require.Equal(t, []string{"value"}, headers["X-Test"])
require.Equal(t, chatdebug.RedactedValue, redacted["Authorization"])
})
}
func TestRedactJSONSecrets(t *testing.T) {
t.Parallel()
t.Run("redacts top level secret fields", func(t *testing.T) {
t.Parallel()
input := []byte(`{"api_key":"abc","token":"def","password":"ghi","safe":"ok"}`)
redacted := chatdebug.RedactJSONSecrets(input)
require.JSONEq(t, `{"api_key":"[REDACTED]","token":"[REDACTED]","password":"[REDACTED]","safe":"ok"}`, string(redacted))
})
t.Run("redacts nested objects", func(t *testing.T) {
t.Parallel()
input := []byte(`{"outer":{"nested_secret":"abc","safe":1},"keep":true}`)
redacted := chatdebug.RedactJSONSecrets(input)
require.JSONEq(t, `{"outer":{"nested_secret":"[REDACTED]","safe":1},"keep":true}`, string(redacted))
})
t.Run("redacts arrays of objects", func(t *testing.T) {
t.Parallel()
input := []byte(`[{"token":"abc"},{"value":1,"credentials":{"access_key":"def"}}]`)
redacted := chatdebug.RedactJSONSecrets(input)
require.JSONEq(t, `[{"token":"[REDACTED]"},{"value":1,"credentials":"[REDACTED]"}]`, string(redacted))
})
t.Run("non JSON input is unchanged", func(t *testing.T) {
t.Parallel()
input := []byte("not json")
require.Equal(t, input, chatdebug.RedactJSONSecrets(input))
})
t.Run("empty input is unchanged", func(t *testing.T) {
t.Parallel()
input := []byte{}
require.Equal(t, input, chatdebug.RedactJSONSecrets(input))
})
t.Run("JSON without sensitive keys is unchanged", func(t *testing.T) {
t.Parallel()
input := []byte(`{"safe":"ok","nested":{"value":1}}`)
require.Equal(t, input, chatdebug.RedactJSONSecrets(input))
})
t.Run("key matching is case insensitive", func(t *testing.T) {
t.Parallel()
input := []byte(`{"API_KEY":"abc","Token":"def","PASSWORD":"ghi"}`)
redacted := chatdebug.RedactJSONSecrets(input)
require.JSONEq(t, `{"API_KEY":"[REDACTED]","Token":"[REDACTED]","PASSWORD":"[REDACTED]"}`, string(redacted))
})
}
@@ -0,0 +1,98 @@
package chatdebug //nolint:testpackage // Uses unexported recorder helpers.
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/testutil"
)
func TestBeginStepReuseStep(t *testing.T) {
t.Parallel()
t.Run("reuses handle under ReuseStep", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepID := uuid.New()
db.EXPECT().GetChatByID(gomock.Any(), chatID).Times(2).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, params database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
require.EqualValues(t, 1, params.StepNumber)
return database.ChatDebugStep{ID: stepID, RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
ctx = ReuseStep(ctx)
opts := RecorderOptions{ChatID: chatID, OwnerID: ownerID}
firstHandle, firstEnriched := beginStep(ctx, svc, opts, OperationStream, nil)
secondHandle, secondEnriched := beginStep(ctx, svc, opts, OperationStream, nil)
require.NotNil(t, firstHandle)
require.Same(t, firstHandle, secondHandle)
require.Same(t, firstHandle.stepCtx, secondHandle.stepCtx)
require.Same(t, firstHandle.sink, secondHandle.sink)
firstStepCtx, ok := StepFromContext(firstEnriched)
require.True(t, ok)
secondStepCtx, ok := StepFromContext(secondEnriched)
require.True(t, ok)
require.Same(t, firstStepCtx, secondStepCtx)
require.Same(t, firstHandle.stepCtx, firstStepCtx)
require.Same(t, attemptSinkFromContext(firstEnriched), attemptSinkFromContext(secondEnriched))
})
t.Run("creates new handles without ReuseStep", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
ownerID := uuid.New()
runID := uuid.New()
stepIDs := []uuid.UUID{uuid.New(), uuid.New()}
insertCalls := 0
db.EXPECT().GetChatByID(gomock.Any(), chatID).Times(2).Return(database.Chat{
ID: chatID,
DebugLogsEnabledOverride: sql.NullBool{Bool: true, Valid: true},
}, nil)
db.EXPECT().InsertChatDebugStep(gomock.Any(), gomock.Any()).Times(2).DoAndReturn(
func(ctx context.Context, params database.InsertChatDebugStepParams) (database.ChatDebugStep, error) {
insertCalls++
require.EqualValues(t, insertCalls, params.StepNumber)
return database.ChatDebugStep{ID: stepIDs[insertCalls-1], RunID: runID, ChatID: chatID}, nil
},
)
svc := NewService(db, testutil.Logger(t), nil)
ctx := ContextWithRun(context.Background(), &RunContext{RunID: runID, ChatID: chatID})
opts := RecorderOptions{ChatID: chatID, OwnerID: ownerID}
firstHandle, _ := beginStep(ctx, svc, opts, OperationStream, nil)
secondHandle, _ := beginStep(ctx, svc, opts, OperationStream, nil)
require.NotNil(t, firstHandle)
require.NotNil(t, secondHandle)
require.NotSame(t, firstHandle, secondHandle)
require.NotEqual(t, firstHandle.stepCtx.StepID, secondHandle.stepCtx.StepID)
})
}
+395
View File
@@ -0,0 +1,395 @@
package chatdebug
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
)
const broadcastPubsubChannel = "chat_debug:broadcast"
// StaleThreshold matches chatd's in-flight stale timeout for debug rows.
const StaleThreshold = 5 * time.Minute
// Service persists chat debug rows and fans out lightweight change events.
type Service struct {
db database.Store
log slog.Logger
pubsub pubsub.Pubsub
}
// CreateRunParams contains friendly inputs for creating a debug run.
type CreateRunParams struct {
ChatID uuid.UUID
RootChatID uuid.UUID
ParentChatID uuid.UUID
ModelConfigID uuid.UUID
TriggerMessageID int64
HistoryTipMessageID int64
Kind RunKind
Status Status
Provider string
Model string
Summary any
}
// UpdateRunParams contains optional inputs for updating a debug run.
type UpdateRunParams struct {
ID uuid.UUID
ChatID uuid.UUID
Status Status
Summary any
FinishedAt time.Time
}
// CreateStepParams contains friendly inputs for creating a debug step.
type CreateStepParams struct {
RunID uuid.UUID
ChatID uuid.UUID
StepNumber int32
Operation Operation
Status Status
HistoryTipMessageID int64
NormalizedRequest any
}
// UpdateStepParams contains optional inputs for updating a debug step.
type UpdateStepParams struct {
ID uuid.UUID
ChatID uuid.UUID
Status Status
AssistantMessageID int64
NormalizedResponse any
Usage any
Attempts any
Error any
Metadata any
FinishedAt time.Time
}
// NewService constructs a chat debug persistence service.
func NewService(db database.Store, log slog.Logger, ps pubsub.Pubsub) *Service {
if db == nil {
panic("chatdebug: nil database.Store")
}
return &Service{
db: db,
log: log,
pubsub: ps,
}
}
func chatdContext(ctx context.Context) context.Context {
//nolint:gocritic // AsChatd provides narrowly-scoped daemon access for
// chat debug persistence reads and writes.
return dbauthz.AsChatd(ctx)
}
// IsEnabled returns whether debug logging is enabled for the given chat.
func (s *Service) IsEnabled(
ctx context.Context,
chatID uuid.UUID,
ownerID uuid.UUID,
) bool {
authCtx := chatdContext(ctx)
chat, err := s.db.GetChatByID(authCtx, chatID)
if err != nil {
s.log.Warn(ctx, "failed to load chat debug logging override",
slog.Error(err),
slog.F("chat_id", chatID),
)
return false
}
if chat.DebugLogsEnabledOverride.Valid {
return chat.DebugLogsEnabledOverride.Bool
}
if ownerID != uuid.Nil {
enabled, err := s.db.GetUserChatDebugLoggingEnabled(authCtx, ownerID)
if err == nil {
return enabled
}
if !errors.Is(err, sql.ErrNoRows) {
s.log.Warn(ctx, "failed to load user chat debug logging setting",
slog.Error(err),
slog.F("owner_id", ownerID),
)
return false
}
}
enabled, err := s.db.GetChatDebugLoggingEnabled(authCtx)
if err == nil {
return enabled
}
if errors.Is(err, sql.ErrNoRows) {
return false
}
s.log.Warn(ctx, "failed to load deployment chat debug logging setting",
slog.Error(err),
)
return false
}
// CreateRun inserts a new debug run and emits a run update event.
func (s *Service) CreateRun(
ctx context.Context,
params CreateRunParams,
) (database.ChatDebugRun, error) {
run, err := s.db.InsertChatDebugRun(chatdContext(ctx),
database.InsertChatDebugRunParams{
ChatID: params.ChatID,
RootChatID: nullUUID(params.RootChatID),
ParentChatID: nullUUID(params.ParentChatID),
ModelConfigID: nullUUID(params.ModelConfigID),
TriggerMessageID: nullInt64(params.TriggerMessageID),
HistoryTipMessageID: nullInt64(params.HistoryTipMessageID),
Kind: string(params.Kind),
Status: string(params.Status),
Provider: nullString(params.Provider),
Model: nullString(params.Model),
Summary: s.nullJSON(params.Summary),
StartedAt: sql.NullTime{},
UpdatedAt: sql.NullTime{},
FinishedAt: sql.NullTime{},
})
if err != nil {
return database.ChatDebugRun{}, err
}
s.publishEvent(run.ChatID, EventKindRunUpdate, run.ID, uuid.Nil)
return run, nil
}
// UpdateRun updates an existing debug run and emits a run update event.
func (s *Service) UpdateRun(
ctx context.Context,
params UpdateRunParams,
) (database.ChatDebugRun, error) {
run, err := s.db.UpdateChatDebugRun(chatdContext(ctx),
database.UpdateChatDebugRunParams{
RootChatID: uuid.NullUUID{},
ParentChatID: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{},
TriggerMessageID: sql.NullInt64{},
HistoryTipMessageID: sql.NullInt64{},
Kind: sql.NullString{},
Status: nullString(string(params.Status)),
Provider: sql.NullString{},
Model: sql.NullString{},
Summary: s.nullJSON(params.Summary),
FinishedAt: nullTime(params.FinishedAt),
ID: params.ID,
ChatID: params.ChatID,
})
if err != nil {
return database.ChatDebugRun{}, err
}
s.publishEvent(run.ChatID, EventKindRunUpdate, run.ID, uuid.Nil)
return run, nil
}
// CreateStep inserts a new debug step and emits a step update event.
func (s *Service) CreateStep(
ctx context.Context,
params CreateStepParams,
) (database.ChatDebugStep, error) {
step, err := s.db.InsertChatDebugStep(chatdContext(ctx),
database.InsertChatDebugStepParams{
RunID: params.RunID,
StepNumber: params.StepNumber,
Operation: string(params.Operation),
Status: string(params.Status),
HistoryTipMessageID: nullInt64(params.HistoryTipMessageID),
AssistantMessageID: sql.NullInt64{},
NormalizedRequest: s.nullJSON(params.NormalizedRequest),
NormalizedResponse: pqtype.NullRawMessage{},
Usage: pqtype.NullRawMessage{},
Attempts: pqtype.NullRawMessage{},
Error: pqtype.NullRawMessage{},
Metadata: pqtype.NullRawMessage{},
StartedAt: sql.NullTime{},
UpdatedAt: sql.NullTime{},
FinishedAt: sql.NullTime{},
ChatID: params.ChatID,
})
if err != nil {
return database.ChatDebugStep{}, err
}
s.publishEvent(step.ChatID, EventKindStepUpdate, step.RunID, step.ID)
return step, nil
}
// UpdateStep updates an existing debug step and emits a step update event.
func (s *Service) UpdateStep(
ctx context.Context,
params UpdateStepParams,
) (database.ChatDebugStep, error) {
step, err := s.db.UpdateChatDebugStep(chatdContext(ctx),
database.UpdateChatDebugStepParams{
Operation: sql.NullString{},
Status: nullString(string(params.Status)),
HistoryTipMessageID: sql.NullInt64{},
AssistantMessageID: nullInt64(params.AssistantMessageID),
NormalizedRequest: pqtype.NullRawMessage{},
NormalizedResponse: s.nullJSON(params.NormalizedResponse),
Usage: s.nullJSON(params.Usage),
Attempts: s.nullJSON(params.Attempts),
Error: s.nullJSON(params.Error),
Metadata: s.nullJSON(params.Metadata),
FinishedAt: nullTime(params.FinishedAt),
ID: params.ID,
ChatID: params.ChatID,
})
if err != nil {
return database.ChatDebugStep{}, err
}
s.publishEvent(step.ChatID, EventKindStepUpdate, step.RunID, step.ID)
return step, nil
}
// DeleteByChatID deletes all debug data for a chat and emits a delete event.
func (s *Service) DeleteByChatID(
ctx context.Context,
chatID uuid.UUID,
) (int64, error) {
deleted, err := s.db.DeleteChatDebugDataByChatID(chatdContext(ctx), chatID)
if err != nil {
return 0, err
}
s.publishEvent(chatID, EventKindDelete, uuid.Nil, uuid.Nil)
return deleted, nil
}
// DeleteAfterMessageID deletes debug data newer than the given message.
func (s *Service) DeleteAfterMessageID(
ctx context.Context,
chatID uuid.UUID,
messageID int64,
) (int64, error) {
deleted, err := s.db.DeleteChatDebugDataAfterMessageID(
chatdContext(ctx),
database.DeleteChatDebugDataAfterMessageIDParams{
ChatID: chatID,
MessageID: messageID,
},
)
if err != nil {
return 0, err
}
s.publishEvent(chatID, EventKindDelete, uuid.Nil, uuid.Nil)
return deleted, nil
}
// FinalizeStale finalizes stale in-flight debug rows and emits a broadcast.
func (s *Service) FinalizeStale(
ctx context.Context,
) (database.FinalizeStaleChatDebugRowsRow, error) {
result, err := s.db.FinalizeStaleChatDebugRows(
chatdContext(ctx),
time.Now().Add(-StaleThreshold),
)
if err != nil {
return database.FinalizeStaleChatDebugRowsRow{}, err
}
s.publishEvent(uuid.Nil, EventKindFinalize, uuid.Nil, uuid.Nil)
return result, nil
}
func nullUUID(id uuid.UUID) uuid.NullUUID {
return uuid.NullUUID{UUID: id, Valid: id != uuid.Nil}
}
func nullInt64(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: v != 0}
}
func nullString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
}
func nullTime(value time.Time) sql.NullTime {
return sql.NullTime{Time: value, Valid: !value.IsZero()}
}
func (s *Service) nullJSON(value any) pqtype.NullRawMessage {
if value == nil {
return pqtype.NullRawMessage{}
}
data, err := json.Marshal(value)
if err != nil {
s.log.Warn(context.Background(), "failed to marshal chat debug JSON",
slog.Error(err),
slog.F("value_type", fmt.Sprintf("%T", value)),
)
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: data, Valid: true}
}
func (s *Service) publishEvent(
chatID uuid.UUID,
kind EventKind,
runID uuid.UUID,
stepID uuid.UUID,
) {
if s.pubsub == nil {
s.log.Debug(context.Background(),
"chat debug pubsub unavailable; skipping event",
slog.F("kind", kind),
slog.F("chat_id", chatID),
)
return
}
event := DebugEvent{
Kind: kind,
ChatID: chatID,
RunID: runID,
StepID: stepID,
}
data, err := json.Marshal(event)
if err != nil {
s.log.Warn(context.Background(), "failed to marshal chat debug event",
slog.Error(err),
slog.F("kind", kind),
slog.F("chat_id", chatID),
)
return
}
channel := PubsubChannel(chatID)
if chatID == uuid.Nil {
channel = broadcastPubsubChannel
}
if err := s.pubsub.Publish(channel, data); err != nil {
s.log.Warn(context.Background(), "failed to publish chat debug event",
slog.Error(err),
slog.F("channel", channel),
slog.F("kind", kind),
slog.F("chat_id", chatID),
)
}
}
+589
View File
@@ -0,0 +1,589 @@
package chatdebug_test
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
type testFixture struct {
ctx context.Context
db database.Store
svc *chatdebug.Service
owner database.User
chat database.Chat
model database.ChatModelConfig
}
func TestService_IsEnabled(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
owner, chat, model := seedChat(ctx, t, db)
require.NotEqual(t, uuid.Nil, model.ID)
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
require.False(t, svc.IsEnabled(ctx, chat.ID, owner.ID))
err := db.UpsertChatDebugLoggingEnabled(ctx, true)
require.NoError(t, err)
require.True(t, svc.IsEnabled(ctx, chat.ID, uuid.Nil))
err = db.UpsertUserChatDebugLoggingEnabled(ctx,
database.UpsertUserChatDebugLoggingEnabledParams{
UserID: owner.ID,
DebugLoggingEnabled: false,
},
)
require.NoError(t, err)
require.False(t, svc.IsEnabled(ctx, chat.ID, owner.ID))
_, err = sqlDB.ExecContext(ctx,
"UPDATE chats SET debug_logs_enabled_override = $1 WHERE id = $2",
true,
chat.ID,
)
require.NoError(t, err)
require.True(t, svc.IsEnabled(ctx, chat.ID, owner.ID))
}
func TestService_CreateRun(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
rootChat := insertChat(fixture.ctx, t, fixture.db, fixture.owner.ID, fixture.model.ID)
parentChat := insertChat(fixture.ctx, t, fixture.db, fixture.owner.ID, fixture.model.ID)
triggerMsg := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID,
fixture.owner.ID, fixture.model.ID, database.ChatMessageRoleUser, "trigger")
historyTipMsg := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID,
fixture.owner.ID, fixture.model.ID, database.ChatMessageRoleAssistant,
"history-tip")
run, err := fixture.svc.CreateRun(fixture.ctx, chatdebug.CreateRunParams{
ChatID: fixture.chat.ID,
RootChatID: rootChat.ID,
ParentChatID: parentChat.ID,
ModelConfigID: fixture.model.ID,
TriggerMessageID: triggerMsg.ID,
HistoryTipMessageID: historyTipMsg.ID,
Kind: chatdebug.KindChatTurn,
Status: chatdebug.StatusInProgress,
Provider: fixture.model.Provider,
Model: fixture.model.Model,
Summary: map[string]any{
"phase": "create",
"count": 1,
},
})
require.NoError(t, err)
assertRunMatches(t, run, fixture.chat.ID, rootChat.ID, parentChat.ID,
fixture.model.ID, triggerMsg.ID, historyTipMsg.ID,
chatdebug.KindChatTurn, chatdebug.StatusInProgress,
fixture.model.Provider, fixture.model.Model,
`{"count":1,"phase":"create"}`)
stored, err := fixture.db.GetChatDebugRunByID(fixture.ctx, run.ID)
require.NoError(t, err)
require.Equal(t, run.ID, stored.ID)
require.JSONEq(t, string(run.Summary), string(stored.Summary))
}
func TestService_UpdateRun(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run, err := fixture.svc.CreateRun(fixture.ctx, chatdebug.CreateRunParams{
ChatID: fixture.chat.ID,
Kind: chatdebug.KindChatTurn,
Status: chatdebug.StatusInProgress,
Summary: map[string]any{
"before": true,
},
})
require.NoError(t, err)
finishedAt := time.Now().UTC().Round(time.Microsecond)
updated, err := fixture.svc.UpdateRun(fixture.ctx, chatdebug.UpdateRunParams{
ID: run.ID,
ChatID: fixture.chat.ID,
Status: chatdebug.StatusCompleted,
Summary: map[string]any{"after": "done"},
FinishedAt: finishedAt,
})
require.NoError(t, err)
require.Equal(t, string(chatdebug.StatusCompleted), updated.Status)
require.True(t, updated.FinishedAt.Valid)
require.WithinDuration(t, finishedAt, updated.FinishedAt.Time, time.Second)
require.JSONEq(t, `{"after":"done"}`, string(updated.Summary))
stored, err := fixture.db.GetChatDebugRunByID(fixture.ctx, run.ID)
require.NoError(t, err)
require.Equal(t, string(chatdebug.StatusCompleted), stored.Status)
require.JSONEq(t, `{"after":"done"}`, string(stored.Summary))
require.True(t, stored.FinishedAt.Valid)
}
func TestService_CreateStep(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
historyTipMsg := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID,
fixture.owner.ID, fixture.model.ID, database.ChatMessageRoleAssistant,
"history-tip")
step, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: run.ID,
ChatID: fixture.chat.ID,
StepNumber: 1,
Operation: chatdebug.OperationStream,
Status: chatdebug.StatusInProgress,
HistoryTipMessageID: historyTipMsg.ID,
NormalizedRequest: map[string]any{
"messages": []string{"hello"},
},
})
require.NoError(t, err)
require.Equal(t, fixture.chat.ID, step.ChatID)
require.Equal(t, run.ID, step.RunID)
require.EqualValues(t, 1, step.StepNumber)
require.Equal(t, string(chatdebug.OperationStream), step.Operation)
require.Equal(t, string(chatdebug.StatusInProgress), step.Status)
require.True(t, step.HistoryTipMessageID.Valid)
require.Equal(t, historyTipMsg.ID, step.HistoryTipMessageID.Int64)
require.JSONEq(t, `{"messages":["hello"]}`, string(step.NormalizedRequest))
steps, err := fixture.db.GetChatDebugStepsByRunID(fixture.ctx, run.ID)
require.NoError(t, err)
require.Len(t, steps, 1)
require.Equal(t, step.ID, steps[0].ID)
}
func TestService_UpdateStep(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
step, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: run.ID,
ChatID: fixture.chat.ID,
StepNumber: 1,
Operation: chatdebug.OperationStream,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
assistantMsg := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID,
fixture.owner.ID, fixture.model.ID, database.ChatMessageRoleAssistant,
"assistant")
finishedAt := time.Now().UTC().Round(time.Microsecond)
updated, err := fixture.svc.UpdateStep(fixture.ctx, chatdebug.UpdateStepParams{
ID: step.ID,
ChatID: fixture.chat.ID,
Status: chatdebug.StatusCompleted,
AssistantMessageID: assistantMsg.ID,
NormalizedResponse: map[string]any{"text": "done"},
Usage: map[string]any{"input_tokens": 10, "output_tokens": 5},
Attempts: []chatdebug.Attempt{{
Number: 1,
ResponseStatus: 200,
DurationMs: 25,
}},
Metadata: map[string]any{"provider": fixture.model.Provider},
FinishedAt: finishedAt,
})
require.NoError(t, err)
require.Equal(t, string(chatdebug.StatusCompleted), updated.Status)
require.True(t, updated.AssistantMessageID.Valid)
require.Equal(t, assistantMsg.ID, updated.AssistantMessageID.Int64)
require.True(t, updated.NormalizedResponse.Valid)
require.JSONEq(t, `{"text":"done"}`,
string(updated.NormalizedResponse.RawMessage))
require.True(t, updated.Usage.Valid)
require.JSONEq(t, `{"input_tokens":10,"output_tokens":5}`,
string(updated.Usage.RawMessage))
require.JSONEq(t,
`[{"number":1,"response_status":200,"duration_ms":25}]`,
string(updated.Attempts),
)
require.JSONEq(t, `{"provider":"`+fixture.model.Provider+`"}`,
string(updated.Metadata))
require.True(t, updated.FinishedAt.Valid)
storedSteps, err := fixture.db.GetChatDebugStepsByRunID(fixture.ctx, run.ID)
require.NoError(t, err)
require.Len(t, storedSteps, 1)
require.Equal(t, updated.ID, storedSteps[0].ID)
}
func TestService_DeleteByChatID(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
_, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: run.ID,
ChatID: fixture.chat.ID,
StepNumber: 1,
Operation: chatdebug.OperationGenerate,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
deleted, err := fixture.svc.DeleteByChatID(fixture.ctx, fixture.chat.ID)
require.NoError(t, err)
require.EqualValues(t, 1, deleted)
runs, err := fixture.db.GetChatDebugRunsByChat(fixture.ctx, fixture.chat.ID)
require.NoError(t, err)
require.Empty(t, runs)
}
func TestService_DeleteAfterMessageID(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
low := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID, fixture.owner.ID,
fixture.model.ID, database.ChatMessageRoleAssistant, "low")
threshold := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID,
fixture.owner.ID, fixture.model.ID, database.ChatMessageRoleAssistant,
"threshold")
high := insertMessage(fixture.ctx, t, fixture.db, fixture.chat.ID, fixture.owner.ID,
fixture.model.ID, database.ChatMessageRoleAssistant, "high")
require.Less(t, low.ID, threshold.ID)
require.Less(t, threshold.ID, high.ID)
runKeep := createRun(t, fixture)
stepKeep, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: runKeep.ID,
ChatID: fixture.chat.ID,
StepNumber: 1,
Operation: chatdebug.OperationGenerate,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
_, err = fixture.svc.UpdateStep(fixture.ctx, chatdebug.UpdateStepParams{
ID: stepKeep.ID,
ChatID: fixture.chat.ID,
AssistantMessageID: low.ID,
})
require.NoError(t, err)
runDelete := createRun(t, fixture)
stepDelete, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: runDelete.ID,
ChatID: fixture.chat.ID,
StepNumber: 1,
Operation: chatdebug.OperationGenerate,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
_, err = fixture.svc.UpdateStep(fixture.ctx, chatdebug.UpdateStepParams{
ID: stepDelete.ID,
ChatID: fixture.chat.ID,
AssistantMessageID: high.ID,
})
require.NoError(t, err)
deleted, err := fixture.svc.DeleteAfterMessageID(fixture.ctx, fixture.chat.ID,
threshold.ID)
require.NoError(t, err)
require.EqualValues(t, 1, deleted)
runs, err := fixture.db.GetChatDebugRunsByChat(fixture.ctx, fixture.chat.ID)
require.NoError(t, err)
require.Len(t, runs, 1)
require.Equal(t, runKeep.ID, runs[0].ID)
steps, err := fixture.db.GetChatDebugStepsByRunID(fixture.ctx, runKeep.ID)
require.NoError(t, err)
require.Len(t, steps, 1)
require.Equal(t, stepKeep.ID, steps[0].ID)
}
func TestService_FinalizeStale(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
owner, chat, model := seedChat(ctx, t, db)
require.NotEqual(t, uuid.Nil, owner.ID)
staleTime := time.Now().Add(-10 * time.Minute).UTC().Round(time.Microsecond)
run, err := db.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Kind: string(chatdebug.KindChatTurn),
Status: string(chatdebug.StatusInProgress),
StartedAt: sql.NullTime{Time: staleTime, Valid: true},
UpdatedAt: sql.NullTime{Time: staleTime, Valid: true},
})
require.NoError(t, err)
step, err := db.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: run.ID,
StepNumber: 1,
Operation: string(chatdebug.OperationStream),
Status: string(chatdebug.StatusInProgress),
StartedAt: sql.NullTime{Time: staleTime, Valid: true},
UpdatedAt: sql.NullTime{Time: staleTime, Valid: true},
ChatID: chat.ID,
})
require.NoError(t, err)
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
result, err := svc.FinalizeStale(ctx)
require.NoError(t, err)
require.EqualValues(t, 1, result.RunsFinalized)
require.EqualValues(t, 1, result.StepsFinalized)
storedRun, err := db.GetChatDebugRunByID(ctx, run.ID)
require.NoError(t, err)
require.Equal(t, string(chatdebug.StatusInterrupted), storedRun.Status)
require.True(t, storedRun.FinishedAt.Valid)
storedSteps, err := db.GetChatDebugStepsByRunID(ctx, run.ID)
require.NoError(t, err)
require.Len(t, storedSteps, 1)
require.Equal(t, step.ID, storedSteps[0].ID)
require.Equal(t, string(chatdebug.StatusInterrupted), storedSteps[0].Status)
require.True(t, storedSteps[0].FinishedAt.Valid)
}
func TestService_PublishesEvents(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
owner, chat, model := seedChat(ctx, t, db)
require.NotEqual(t, uuid.Nil, owner.ID)
memoryPubsub := dbpubsub.NewInMemory()
svc := chatdebug.NewService(db, testutil.Logger(t), memoryPubsub)
events := make(chan struct {
event chatdebug.DebugEvent
err error
}, 1)
cancel, err := memoryPubsub.Subscribe(chatdebug.PubsubChannel(chat.ID),
func(_ context.Context, message []byte) {
var event chatdebug.DebugEvent
events <- struct {
event chatdebug.DebugEvent
err error
}{
event: event,
err: json.Unmarshal(message, &event),
}
},
)
require.NoError(t, err)
defer cancel()
run, err := svc.CreateRun(ctx, chatdebug.CreateRunParams{
ChatID: chat.ID,
ModelConfigID: model.ID,
Kind: chatdebug.KindChatTurn,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
select {
case received := <-events:
require.NoError(t, received.err)
require.Equal(t, chatdebug.EventKindRunUpdate, received.event.Kind)
require.Equal(t, chat.ID, received.event.ChatID)
require.Equal(t, run.ID, received.event.RunID)
require.Equal(t, uuid.Nil, received.event.StepID)
case <-time.After(testutil.WaitShort):
t.Fatal("timed out waiting for debug event")
}
select {
case received := <-events:
t.Fatalf("unexpected extra event: %+v", received.event)
default:
}
}
func newFixture(t *testing.T) testFixture {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
owner, chat, model := seedChat(ctx, t, db)
return testFixture{
ctx: ctx,
db: db,
svc: chatdebug.NewService(db, testutil.Logger(t), nil),
owner: owner,
chat: chat,
model: model,
}
}
func seedChat(
ctx context.Context,
t *testing.T,
db database.Store,
) (database.User, database.Chat, database.ChatModelConfig) {
t.Helper()
owner := dbgen.User(t, db, database.User{})
providerName := "openai"
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: providerName,
DisplayName: "OpenAI",
APIKey: "test-key",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
})
require.NoError(t, err)
model, err := db.InsertChatModelConfig(ctx,
database.InsertChatModelConfigParams{
Provider: providerName,
Model: "model-" + uuid.NewString(),
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 70,
Options: json.RawMessage(`{}`),
},
)
require.NoError(t, err)
chat := insertChat(ctx, t, db, owner.ID, model.ID)
return owner, chat, model
}
func insertChat(
ctx context.Context,
t *testing.T,
db database.Store,
ownerID uuid.UUID,
modelID uuid.UUID,
) database.Chat {
t.Helper()
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: ownerID,
LastModelConfigID: modelID,
Title: "chat-" + uuid.NewString(),
})
require.NoError(t, err)
return chat
}
func insertMessage(
ctx context.Context,
t *testing.T,
db database.Store,
chatID uuid.UUID,
createdBy uuid.UUID,
modelID uuid.UUID,
role database.ChatMessageRole,
text string,
) database.ChatMessage {
t.Helper()
parts, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(text),
})
require.NoError(t, err)
messages, err := db.InsertChatMessages(ctx, database.InsertChatMessagesParams{
ChatID: chatID,
CreatedBy: []uuid.UUID{createdBy},
ModelConfigID: []uuid.UUID{modelID},
Role: []database.ChatMessageRole{role},
Content: []string{string(parts.RawMessage)},
ContentVersion: []int16{chatprompt.CurrentContentVersion},
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
ProviderResponseID: []string{""},
})
require.NoError(t, err)
require.Len(t, messages, 1)
return messages[0]
}
func createRun(t *testing.T, fixture testFixture) database.ChatDebugRun {
t.Helper()
run, err := fixture.svc.CreateRun(fixture.ctx, chatdebug.CreateRunParams{
ChatID: fixture.chat.ID,
ModelConfigID: fixture.model.ID,
Kind: chatdebug.KindChatTurn,
Status: chatdebug.StatusInProgress,
Provider: fixture.model.Provider,
Model: fixture.model.Model,
})
require.NoError(t, err)
return run
}
func assertRunMatches(
t *testing.T,
run database.ChatDebugRun,
chatID uuid.UUID,
rootChatID uuid.UUID,
parentChatID uuid.UUID,
modelID uuid.UUID,
triggerMessageID int64,
historyTipMessageID int64,
kind chatdebug.RunKind,
status chatdebug.Status,
provider string,
model string,
summary string,
) {
t.Helper()
require.Equal(t, chatID, run.ChatID)
require.True(t, run.RootChatID.Valid)
require.Equal(t, rootChatID, run.RootChatID.UUID)
require.True(t, run.ParentChatID.Valid)
require.Equal(t, parentChatID, run.ParentChatID.UUID)
require.True(t, run.ModelConfigID.Valid)
require.Equal(t, modelID, run.ModelConfigID.UUID)
require.True(t, run.TriggerMessageID.Valid)
require.Equal(t, triggerMessageID, run.TriggerMessageID.Int64)
require.True(t, run.HistoryTipMessageID.Valid)
require.Equal(t, historyTipMessageID, run.HistoryTipMessageID.Int64)
require.Equal(t, string(kind), run.Kind)
require.Equal(t, string(status), run.Status)
require.True(t, run.Provider.Valid)
require.Equal(t, provider, run.Provider.String)
require.True(t, run.Model.Valid)
require.Equal(t, model, run.Model.String)
require.JSONEq(t, summary, string(run.Summary))
require.False(t, run.StartedAt.IsZero())
require.False(t, run.UpdatedAt.IsZero())
require.False(t, run.FinishedAt.Valid)
}
+173
View File
@@ -0,0 +1,173 @@
package chatdebug
import (
"context"
"encoding/json"
"regexp"
"strings"
"unicode/utf8"
"charm.land/fantasy"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
)
// MaxLabelLength is the default rune limit for truncated labels.
const MaxLabelLength = 100
// whitespaceRun matches one or more consecutive whitespace characters.
var whitespaceRun = regexp.MustCompile(`\s+`)
// TruncateLabel whitespace-normalizes and truncates text to maxLen runes.
// Returns "" if input is empty or whitespace-only.
func TruncateLabel(text string, maxLen int) string {
if maxLen < 0 {
maxLen = 0
}
normalized := strings.TrimSpace(whitespaceRun.ReplaceAllString(text, " "))
if normalized == "" {
return ""
}
if utf8.RuneCountInString(normalized) <= maxLen {
return normalized
}
// Truncate at maxLen runes and append ellipsis.
runes := []rune(normalized)
return string(runes[:maxLen]) + "…"
}
// SeedSummary builds a base summary map with a first_message label.
// Returns nil if label is empty.
func SeedSummary(label string) map[string]any {
if label == "" {
return nil
}
return map[string]any{"first_message": label}
}
// ExtractFirstUserText extracts the plain text content from a
// fantasy.Prompt for the first user message. Used to derive
// first_message labels at run creation time.
func ExtractFirstUserText(prompt fantasy.Prompt) string {
for _, msg := range prompt {
if msg.Role != fantasy.MessageRoleUser {
continue
}
var sb strings.Builder
for _, part := range msg.Content {
tp, ok := fantasy.AsMessagePart[fantasy.TextPart](part)
if !ok {
continue
}
_, _ = sb.WriteString(tp.Text)
}
return sb.String()
}
return ""
}
// AggregateRunSummary reads all steps for the given run, computes token
// totals, and merges them with the run's existing summary (preserving any
// seeded first_message label). The baseSummary parameter should be the
// current run summary (may be nil).
func (s *Service) AggregateRunSummary(
ctx context.Context,
runID uuid.UUID,
baseSummary map[string]any,
) (map[string]any, error) {
if runID == uuid.Nil {
return baseSummary, nil
}
steps, err := s.db.GetChatDebugStepsByRunID(chatdContext(ctx), runID)
if err != nil {
return nil, err
}
// Start from a shallow copy of baseSummary to avoid mutating the
// caller's map.
result := make(map[string]any, len(baseSummary)+6)
for k, v := range baseSummary {
result[k] = v
}
var (
totalInput int64
totalOutput int64
totalCacheCreation int64
totalCacheRead int64
)
for _, step := range steps {
if !step.Usage.Valid || len(step.Usage.RawMessage) == 0 {
continue
}
var usage fantasy.Usage
if err := json.Unmarshal(step.Usage.RawMessage, &usage); err != nil {
s.log.Warn(ctx, "skipping malformed step usage JSON",
slog.Error(err),
slog.F("run_id", runID),
slog.F("step_id", step.ID),
)
continue
}
totalInput += usage.InputTokens
totalOutput += usage.OutputTokens
totalCacheCreation += usage.CacheCreationTokens
totalCacheRead += usage.CacheReadTokens
}
result["step_count"] = len(steps)
result["total_input_tokens"] = totalInput
result["total_output_tokens"] = totalOutput
// Only include cache fields when non-zero to keep the summary
// compact for the common case.
if totalCacheCreation > 0 {
result["total_cache_creation_tokens"] = totalCacheCreation
}
if totalCacheRead > 0 {
result["total_cache_read_tokens"] = totalCacheRead
}
// Derive endpoint_label from the first completed attempt's path
// across all steps. This gives the debug panel a meaningful
// identifier like "POST /v1/messages" for the run row.
if label := extractEndpointLabel(steps); label != "" {
result["endpoint_label"] = label
}
return result, nil
}
// extractEndpointLabel scans steps for the first attempt with a
// non-empty path and returns "METHOD /path" (or just "/path").
func extractEndpointLabel(steps []database.ChatDebugStep) string {
for _, step := range steps {
if len(step.Attempts) == 0 {
continue
}
var attempts []Attempt
if err := json.Unmarshal(step.Attempts, &attempts); err != nil {
continue
}
for _, a := range attempts {
if a.Path == "" {
continue
}
if a.Method != "" {
return a.Method + " " + a.Path
}
return a.Path
}
}
return ""
}
+326
View File
@@ -0,0 +1,326 @@
package chatdebug_test
import (
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
)
func TestTruncateLabel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "Empty",
input: "",
maxLen: 10,
want: "",
},
{
name: "WhitespaceOnly",
input: " \t\n ",
maxLen: 10,
want: "",
},
{
name: "ShortText",
input: "hello world",
maxLen: 20,
want: "hello world",
},
{
name: "ExactLength",
input: "abcde",
maxLen: 5,
want: "abcde",
},
{
name: "LongTextTruncated",
input: "abcdefghij",
maxLen: 5,
want: "abcde…",
},
{
name: "NegativeMaxLen",
input: "hello",
maxLen: -1,
want: "…",
},
{
name: "ZeroMaxLen",
input: "hello",
maxLen: 0,
want: "…",
},
{
name: "MultipleWhitespaceRuns",
input: " hello world \t again ",
maxLen: 100,
want: "hello world again",
},
{
name: "UnicodeRunes",
input: "こんにちは世界",
maxLen: 3,
want: "こんに…",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := chatdebug.TruncateLabel(tc.input, tc.maxLen)
require.Equal(t, tc.want, got)
})
}
}
func TestSeedSummary(t *testing.T) {
t.Parallel()
t.Run("NonEmptyLabel", func(t *testing.T) {
t.Parallel()
got := chatdebug.SeedSummary("hello world")
require.Equal(t, map[string]any{"first_message": "hello world"}, got)
})
t.Run("EmptyLabel", func(t *testing.T) {
t.Parallel()
got := chatdebug.SeedSummary("")
require.Nil(t, got)
})
}
func TestExtractFirstUserText(t *testing.T) {
t.Parallel()
t.Run("EmptyPrompt", func(t *testing.T) {
t.Parallel()
got := chatdebug.ExtractFirstUserText(fantasy.Prompt{})
require.Equal(t, "", got)
})
t.Run("NoUserMessages", func(t *testing.T) {
t.Parallel()
prompt := fantasy.Prompt{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "system"}},
},
{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "assistant"}},
},
}
got := chatdebug.ExtractFirstUserText(prompt)
require.Equal(t, "", got)
})
t.Run("FirstUserMessageMixedParts", func(t *testing.T) {
t.Parallel()
prompt := fantasy.Prompt{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello "},
fantasy.FilePart{Filename: "test.png"},
fantasy.TextPart{Text: "world"},
},
},
}
got := chatdebug.ExtractFirstUserText(prompt)
require.Equal(t, "hello world", got)
})
t.Run("MultipleUserMessagesReturnsFirst", func(t *testing.T) {
t.Parallel()
prompt := fantasy.Prompt{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "system"}},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "first"}},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "second"}},
},
}
got := chatdebug.ExtractFirstUserText(prompt)
require.Equal(t, "first", got)
})
}
func TestService_AggregateRunSummary(t *testing.T) {
t.Parallel()
t.Run("NilRunID", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, uuid.Nil, nil)
require.NoError(t, err)
require.Nil(t, got)
})
t.Run("NilBaseSummary", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
// Create a step with usage.
step := createTestStep(t, fixture, run.ID)
updateTestStepWithUsage(t, fixture, step.ID, 10, 5, 0, 0)
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, run.ID, nil)
require.NoError(t, err)
require.NotNil(t, got)
require.EqualValues(t, 1, got["step_count"])
require.EqualValues(t, int64(10), got["total_input_tokens"])
require.EqualValues(t, int64(5), got["total_output_tokens"])
})
t.Run("PreservesFirstMessage", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
step := createTestStep(t, fixture, run.ID)
updateTestStepWithUsage(t, fixture, step.ID, 20, 10, 0, 0)
base := map[string]any{"first_message": "hello world"}
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, run.ID, base)
require.NoError(t, err)
require.Equal(t, "hello world", got["first_message"])
require.EqualValues(t, 1, got["step_count"])
require.EqualValues(t, int64(20), got["total_input_tokens"])
require.EqualValues(t, int64(10), got["total_output_tokens"])
})
t.Run("MultipleStepsSumTokens", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
step1 := createTestStep(t, fixture, run.ID)
updateTestStepWithUsage(t, fixture, step1.ID, 10, 5, 2, 3)
step2 := createTestStepN(t, fixture, run.ID, 2)
updateTestStepWithUsage(t, fixture, step2.ID, 15, 7, 1, 4)
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, run.ID, nil)
require.NoError(t, err)
require.EqualValues(t, 2, got["step_count"])
require.EqualValues(t, int64(25), got["total_input_tokens"])
require.EqualValues(t, int64(12), got["total_output_tokens"])
require.EqualValues(t, int64(3), got["total_cache_creation_tokens"])
require.EqualValues(t, int64(7), got["total_cache_read_tokens"])
})
t.Run("StepWithNilUsageContributesZeroTokens", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
// Step with usage.
step1 := createTestStep(t, fixture, run.ID)
updateTestStepWithUsage(t, fixture, step1.ID, 10, 5, 0, 0)
// Step without usage (just complete it, no usage).
step2 := createTestStepN(t, fixture, run.ID, 2)
_, err := fixture.svc.UpdateStep(fixture.ctx, chatdebug.UpdateStepParams{
ID: step2.ID,
ChatID: fixture.chat.ID,
Status: chatdebug.StatusCompleted,
})
require.NoError(t, err)
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, run.ID, nil)
require.NoError(t, err)
// Both steps are counted even though one has no usage.
require.EqualValues(t, 2, got["step_count"])
require.EqualValues(t, int64(10), got["total_input_tokens"])
require.EqualValues(t, int64(5), got["total_output_tokens"])
})
t.Run("ZeroCacheTotalsOmitCacheFields", func(t *testing.T) {
t.Parallel()
fixture := newFixture(t)
run := createRun(t, fixture)
step := createTestStep(t, fixture, run.ID)
updateTestStepWithUsage(t, fixture, step.ID, 10, 5, 0, 0)
got, err := fixture.svc.AggregateRunSummary(fixture.ctx, run.ID, nil)
require.NoError(t, err)
_, hasCacheCreation := got["total_cache_creation_tokens"]
_, hasCacheRead := got["total_cache_read_tokens"]
require.False(t, hasCacheCreation,
"cache creation tokens should be omitted when zero")
require.False(t, hasCacheRead,
"cache read tokens should be omitted when zero")
})
}
// createTestStep is a thin helper that creates a debug step with
// step number 1 for the given run.
func createTestStep(
t *testing.T,
fixture testFixture,
runID uuid.UUID,
) database.ChatDebugStep {
t.Helper()
return createTestStepN(t, fixture, runID, 1)
}
// createTestStepN creates a debug step with the given step number.
func createTestStepN(
t *testing.T,
fixture testFixture,
runID uuid.UUID,
stepNumber int32,
) database.ChatDebugStep {
t.Helper()
step, err := fixture.svc.CreateStep(fixture.ctx, chatdebug.CreateStepParams{
RunID: runID,
ChatID: fixture.chat.ID,
StepNumber: stepNumber,
Operation: chatdebug.OperationGenerate,
Status: chatdebug.StatusInProgress,
})
require.NoError(t, err)
return step
}
// updateTestStepWithUsage completes a step and sets token usage fields.
func updateTestStepWithUsage(
t *testing.T,
fixture testFixture,
stepID uuid.UUID,
input, output, cacheCreation, cacheRead int64,
) {
t.Helper()
_, err := fixture.svc.UpdateStep(fixture.ctx, chatdebug.UpdateStepParams{
ID: stepID,
ChatID: fixture.chat.ID,
Status: chatdebug.StatusCompleted,
Usage: map[string]any{
"input_tokens": input,
"output_tokens": output,
"cache_creation_tokens": cacheCreation,
"cache_read_tokens": cacheRead,
},
})
require.NoError(t, err)
}
+157
View File
@@ -0,0 +1,157 @@
package chatdebug
import (
"bytes"
"errors"
"io"
"net/http"
"sync"
"time"
)
// attemptStatusCompleted is the status recorded when a response body
// is fully read without transport-level errors.
const attemptStatusCompleted = "completed"
// attemptStatusFailed is the status recorded when a transport error
// or body read error occurs.
const attemptStatusFailed = "failed"
// RecordingTransport captures HTTP request/response data for debug steps.
// When the request context carries an attemptSink, it records each round
// trip. Otherwise it delegates directly.
type RecordingTransport struct {
// Base is the underlying transport. nil defaults to http.DefaultTransport.
Base http.RoundTripper
}
var _ http.RoundTripper = (*RecordingTransport)(nil)
func (t *RecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil {
panic("chatdebug: nil request")
}
base := t.Base
if base == nil {
base = http.DefaultTransport
}
sink := attemptSinkFromContext(req.Context())
if sink == nil {
return base.RoundTrip(req)
}
requestHeaders := RedactHeaders(req.Header)
// Capture method and URL/path from the request.
method := req.Method
reqURL := ""
reqPath := ""
if req.URL != nil {
reqURL = req.URL.String()
reqPath = req.URL.Path
}
var originalBody []byte
if req.Body != nil {
var err error
originalBody, err = io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(originalBody))
}
requestBody := RedactJSONSecrets(originalBody)
startedAt := time.Now()
resp, err := base.RoundTrip(req)
finishedAt := time.Now()
durationMs := finishedAt.Sub(startedAt).Milliseconds()
if err != nil {
sink.record(Attempt{
Number: len(sink.snapshot()) + 1,
Status: attemptStatusFailed,
Method: method,
URL: reqURL,
Path: reqPath,
StartedAt: startedAt.UTC().Format(time.RFC3339Nano),
FinishedAt: finishedAt.UTC().Format(time.RFC3339Nano),
RequestHeaders: requestHeaders,
RequestBody: requestBody,
Error: err.Error(),
DurationMs: durationMs,
})
return nil, err
}
respHeaders := RedactHeaders(resp.Header)
resp.Body = &recordingBody{
inner: resp.Body,
sink: sink,
startedAt: startedAt,
base: Attempt{
Method: method,
URL: reqURL,
Path: reqPath,
RequestHeaders: requestHeaders,
RequestBody: requestBody,
ResponseStatus: resp.StatusCode,
ResponseHeaders: respHeaders,
DurationMs: durationMs,
},
}
return resp, nil
}
type recordingBody struct {
inner io.ReadCloser
buf bytes.Buffer
sink *attemptSink
base Attempt
startedAt time.Time
recordOnce sync.Once
closeOnce sync.Once
}
func (r *recordingBody) Read(p []byte) (int, error) {
n, err := r.inner.Read(p)
if n > 0 {
_, _ = r.buf.Write(p[:n])
}
if err != nil {
r.record(err)
}
return n, err
}
func (r *recordingBody) Close() error {
r.record(nil)
var closeErr error
r.closeOnce.Do(func() {
closeErr = r.inner.Close()
})
return closeErr
}
func (r *recordingBody) record(err error) {
r.recordOnce.Do(func() {
finishedAt := time.Now()
r.base.Number = len(r.sink.snapshot()) + 1
r.base.ResponseBody = RedactJSONSecrets(r.buf.Bytes())
r.base.StartedAt = r.startedAt.UTC().Format(time.RFC3339Nano)
r.base.FinishedAt = finishedAt.UTC().Format(time.RFC3339Nano)
// Recompute duration to include body read time.
r.base.DurationMs = finishedAt.Sub(r.startedAt).Milliseconds()
if err != nil && !errors.Is(err, io.EOF) {
r.base.Error = err.Error()
r.base.Status = attemptStatusFailed
} else {
r.base.Status = attemptStatusCompleted
}
r.sink.record(r.base)
})
}
+286
View File
@@ -0,0 +1,286 @@
package chatdebug //nolint:testpackage // Uses unexported recorder helpers.
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
)
func newTestSinkContext(t *testing.T) (context.Context, *attemptSink) {
t.Helper()
sink := &attemptSink{}
return withAttemptSink(context.Background(), sink), sink
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestRecordingTransport_NoSink(t *testing.T) {
t.Parallel()
gotMethod := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
gotMethod <- req.Method
_, _ = rw.Write([]byte("ok"))
}))
defer server.Close()
client := &http.Client{
Transport: &RecordingTransport{Base: server.Client().Transport},
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "ok", string(body))
require.Equal(t, http.MethodGet, <-gotMethod)
}
func TestRecordingTransport_CaptureRequest(t *testing.T) {
t.Parallel()
const requestBody = `{"message":"hello","api_key":"super-secret"}`
type receivedRequest struct {
authorization string
body []byte
}
gotRequest := make(chan receivedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
gotRequest <- receivedRequest{
authorization: req.Header.Get("Authorization"),
body: body,
}
_, _ = rw.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{Base: server.Client().Transport},
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
server.URL,
strings.NewReader(requestBody),
)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer top-secret")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, 1, attempts[0].Number)
require.Equal(t, RedactedValue, attempts[0].RequestHeaders["Authorization"])
require.Equal(t, "application/json", attempts[0].RequestHeaders["Content-Type"])
require.JSONEq(t, `{"message":"hello","api_key":"[REDACTED]"}`, string(attempts[0].RequestBody))
received := <-gotRequest
require.JSONEq(t, requestBody, string(received.body))
require.Equal(t, "Bearer top-secret", received.authorization)
}
func TestRecordingTransport_CaptureResponse(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("X-API-Key", "response-secret")
rw.Header().Set("X-Trace-ID", "trace-123")
rw.WriteHeader(http.StatusCreated)
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
}))
defer server.Close()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{Base: server.Client().Transport},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.JSONEq(t, `{"token":"response-secret","safe":"ok"}`, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, http.StatusCreated, attempts[0].ResponseStatus)
require.Equal(t, RedactedValue, attempts[0].ResponseHeaders["X-Api-Key"])
require.Equal(t, "trace-123", attempts[0].ResponseHeaders["X-Trace-Id"])
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
}
func TestRecordingTransport_CaptureResponseOnEOFWithoutClose(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("X-API-Key", "response-secret")
rw.WriteHeader(http.StatusAccepted)
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
}))
defer server.Close()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{Base: server.Client().Transport},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, `{"token":"response-secret","safe":"ok"}`, string(body))
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, http.StatusAccepted, attempts[0].ResponseStatus)
require.Equal(t, "application/json", attempts[0].ResponseHeaders["Content-Type"])
require.Equal(t, RedactedValue, attempts[0].ResponseHeaders["X-Api-Key"])
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
require.NoError(t, resp.Body.Close())
}
func TestRecordingTransport_StreamingBody(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
flusher, ok := rw.(http.Flusher)
require.True(t, ok)
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write([]byte(`{"safe":"stream",`))
flusher.Flush()
_, _ = rw.Write([]byte(`"token":"chunk-secret"}`))
flusher.Flush()
}))
defer server.Close()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{Base: server.Client().Transport},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
buf := make([]byte, 5)
var body strings.Builder
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
_, writeErr := body.Write(buf[:n])
require.NoError(t, writeErr)
}
if errors.Is(readErr, io.EOF) {
break
}
require.NoError(t, readErr)
}
require.NoError(t, resp.Body.Close())
require.JSONEq(t, `{"safe":"stream","token":"chunk-secret"}`, body.String())
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.JSONEq(t, `{"safe":"stream","token":"[REDACTED]"}`, string(attempts[0].ResponseBody))
}
func TestRecordingTransport_TransportError(t *testing.T) {
t.Parallel()
ctx, sink := newTestSinkContext(t)
client := &http.Client{
Transport: &RecordingTransport{
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, xerrors.New("transport exploded")
}),
},
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"http://example.invalid",
strings.NewReader(`{"password":"secret","safe":"ok"}`),
)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer top-secret")
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
require.Nil(t, resp)
require.EqualError(t, err, "Post \"http://example.invalid\": transport exploded")
attempts := sink.snapshot()
require.Len(t, attempts, 1)
require.Equal(t, 1, attempts[0].Number)
require.Equal(t, RedactedValue, attempts[0].RequestHeaders["Authorization"])
require.JSONEq(t, `{"password":"[REDACTED]","safe":"ok"}`, string(attempts[0].RequestBody))
require.Zero(t, attempts[0].ResponseStatus)
require.Equal(t, "transport exploded", attempts[0].Error)
require.GreaterOrEqual(t, attempts[0].DurationMs, int64(0))
}
func TestRecordingTransport_NilBase(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
_, _ = rw.Write([]byte("ok"))
}))
defer server.Close()
client := &http.Client{Transport: &RecordingTransport{}}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "ok", string(body))
}
+112
View File
@@ -0,0 +1,112 @@
package chatdebug
import "github.com/google/uuid"
// RunKind identifies the kind of debug run being recorded.
type RunKind string
const (
// KindChatTurn records a standard chat turn.
KindChatTurn RunKind = "chat_turn"
// KindTitleGeneration records title generation for a chat.
KindTitleGeneration RunKind = "title_generation"
// KindQuickgen records quick-generation workflows.
KindQuickgen RunKind = "quickgen"
// KindCompaction records history compaction workflows.
KindCompaction RunKind = "compaction"
)
// Status identifies lifecycle state shared by runs and steps.
type Status string
const (
// StatusInProgress indicates work is still running.
StatusInProgress Status = "in_progress"
// StatusCompleted indicates work finished successfully.
StatusCompleted Status = "completed"
// StatusError indicates work finished with an error.
StatusError Status = "error"
// StatusInterrupted indicates work was canceled or interrupted.
StatusInterrupted Status = "interrupted"
)
// Operation identifies the model operation a step performed.
type Operation string
const (
// OperationStream records a streaming model operation.
OperationStream Operation = "stream"
// OperationGenerate records a non-streaming generation operation.
OperationGenerate Operation = "generate"
)
// RunContext carries identity and metadata for a debug run.
type RunContext struct {
RunID uuid.UUID
ChatID uuid.UUID
RootChatID uuid.UUID // Zero means not set.
ParentChatID uuid.UUID // Zero means not set.
ModelConfigID uuid.UUID // Zero means not set.
TriggerMessageID int64 // Zero means not set.
HistoryTipMessageID int64 // Zero means not set.
Kind RunKind
Provider string
Model string
}
// StepContext carries identity and metadata for a debug step.
type StepContext struct {
StepID uuid.UUID
RunID uuid.UUID
ChatID uuid.UUID
StepNumber int32
Operation Operation
HistoryTipMessageID int64 // Zero means not set.
}
// Attempt captures a single HTTP round trip made during a step.
type Attempt struct {
Number int `json:"number"`
Status string `json:"status,omitempty"`
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
RequestHeaders map[string]string `json:"request_headers,omitempty"`
RequestBody []byte `json:"request_body,omitempty"`
ResponseStatus int `json:"response_status,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
ResponseBody []byte `json:"response_body,omitempty"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
RetryClassification string `json:"retry_classification,omitempty"`
RetryDelayMs int64 `json:"retry_delay_ms,omitempty"`
}
// EventKind identifies the type of pubsub debug event.
type EventKind string
const (
// EventKindRunUpdate publishes a run mutation.
EventKindRunUpdate EventKind = "run_update"
// EventKindStepUpdate publishes a step mutation.
EventKindStepUpdate EventKind = "step_update"
// EventKindFinalize publishes a finalization signal.
EventKindFinalize EventKind = "finalize"
// EventKindDelete publishes a deletion signal.
EventKindDelete EventKind = "delete"
)
// DebugEvent is the lightweight pubsub envelope for chat debug updates.
type DebugEvent struct {
Kind EventKind `json:"kind"`
ChatID uuid.UUID `json:"chat_id"`
RunID uuid.UUID `json:"run_id,omitempty"`
StepID uuid.UUID `json:"step_id,omitempty"`
}
// PubsubChannel returns the chat-scoped pubsub channel for debug events.
func PubsubChannel(chatID uuid.UUID) string {
return "chat_debug:" + chatID.String()
}
+3 -1
View File
@@ -17,6 +17,7 @@ import (
"charm.land/fantasy/schema"
"golang.org/x/xerrors"
"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"
@@ -324,7 +325,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(),
+83 -1
View File
@@ -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,78 @@ 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 {
if options.OnError != nil {
options.OnError(xerrors.Errorf("create compaction debug run: %w", err))
}
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 _, updateErr := options.DebugSvc.UpdateRun(
context.WithoutCancel(compactionCtx),
chatdebug.UpdateRunParams{
ID: run.ID,
ChatID: options.ChatID,
Status: status,
FinishedAt: time.Now(),
},
); updateErr != nil && options.OnError != nil {
options.OnError(xerrors.Errorf(
"finalize compaction debug run: %w",
updateErr,
))
}
}
}
// 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 +356,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 +370,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,
+28 -1
View File
@@ -2,6 +2,7 @@ package chatprovider
import (
"context"
"net/http"
"sort"
"strings"
@@ -938,13 +939,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 {
@@ -970,6 +973,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 == "" {
@@ -984,6 +990,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{
@@ -993,6 +1002,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{
@@ -1005,6 +1017,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{
@@ -1018,6 +1033,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{
@@ -1030,6 +1048,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{
@@ -1039,6 +1060,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{
@@ -1051,6 +1075,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)
@@ -21,6 +21,12 @@ import (
"github.com/coder/coder/v2/testutil"
)
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()
@@ -210,7 +216,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{
@@ -241,7 +247,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{
@@ -277,7 +283,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{
@@ -292,6 +298,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
+291 -11
View File
@@ -3,6 +3,7 @@ package chatd
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"time"
@@ -20,6 +21,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"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"
@@ -99,35 +101,163 @@ 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
if err != nil {
status = chatdebug.StatusError
}
finalSummary := seedSummary
if aggregated, aggErr := debugSvc.AggregateRunSummary(
titleCtx,
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(
titleCtx,
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),
)
}
}
if err != nil {
lastErr = err
logger.Debug(ctx, "title model candidate failed",
@@ -165,6 +295,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.
@@ -490,30 +655,144 @@ 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,
) 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,
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,
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
if err != nil {
status = chatdebug.StatusError
}
finalSummary := pushSeedSummary
if aggregated, aggErr := debugSvc.AggregateRunSummary(
summaryCtx,
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(
summaryCtx,
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),
)
}
}
if err != nil {
logger.Debug(ctx, "push summary model candidate failed",
slog.Error(err),
@@ -529,7 +808,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,
+174 -2
View File
@@ -68,6 +68,9 @@ type Chat struct {
// the owner's read cursor, which updates on stream
// connect and disconnect.
HasUnread bool `json:"has_unread"`
// DebugLogsEnabledOverride overrides debug logging for this
// chat when set.
DebugLogsEnabledOverride *bool `json:"debug_logs_enabled_override,omitempty"`
// LastInjectedContext holds the most recently persisted
// injected context parts (AGENTS.md files and skills). It
// is updated only when context changes — first workspace
@@ -357,8 +360,9 @@ type UpdateChatRequest struct {
// - >0 (chat is already pinned): move the chat to the
// requested position, shifting neighbors as needed. The
// value is clamped to [1, pinned_count].
PinOrder *int32 `json:"pin_order,omitempty"`
Labels *map[string]string `json:"labels,omitempty"`
PinOrder *int32 `json:"pin_order,omitempty"`
Labels *map[string]string `json:"labels,omitempty"`
DebugLogsEnabledOverride *bool `json:"debug_logs_enabled_override,omitempty"`
}
// CreateChatMessageRequest is the request to add a message to a chat.
@@ -471,6 +475,92 @@ type UpdateChatDesktopEnabledRequest struct {
EnableDesktop bool `json:"enable_desktop"`
}
// ChatDebugSettings is the response for getting the debug logging setting.
type ChatDebugSettings struct {
DebugLoggingEnabled bool `json:"debug_logging_enabled"`
}
// UpdateChatDebugLoggingRequest is the request to update the debug logging setting.
type UpdateChatDebugLoggingRequest struct {
DebugLoggingEnabled bool `json:"debug_logging_enabled"`
}
// ChatDebugRunSummary is a lightweight run entry for list endpoints.
type ChatDebugRunSummary struct {
ID uuid.UUID `json:"id" format:"uuid"`
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
Kind string `json:"kind"`
Status string `json:"status"`
Provider *string `json:"provider,omitempty"`
Model *string `json:"model,omitempty"`
Summary json.RawMessage `json:"summary"`
StartedAt time.Time `json:"started_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}
// ChatDebugRun is the detailed run response including steps.
type ChatDebugRun struct {
ID uuid.UUID `json:"id" format:"uuid"`
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"`
TriggerMessageID *int64 `json:"trigger_message_id,omitempty"`
HistoryTipMessageID *int64 `json:"history_tip_message_id,omitempty"`
Kind string `json:"kind"`
Status string `json:"status"`
Provider *string `json:"provider,omitempty"`
Model *string `json:"model,omitempty"`
Summary json.RawMessage `json:"summary"`
StartedAt time.Time `json:"started_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
Steps []ChatDebugStep `json:"steps"`
}
// ChatDebugStep is a single step within a debug run.
type ChatDebugStep struct {
ID uuid.UUID `json:"id" format:"uuid"`
RunID uuid.UUID `json:"run_id" format:"uuid"`
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
StepNumber int32 `json:"step_number"`
Operation string `json:"operation"`
Status string `json:"status"`
HistoryTipMessageID *int64 `json:"history_tip_message_id,omitempty"`
AssistantMessageID *int64 `json:"assistant_message_id,omitempty"`
NormalizedRequest json.RawMessage `json:"normalized_request"`
NormalizedResponse *json.RawMessage `json:"normalized_response,omitempty"`
Usage *json.RawMessage `json:"usage,omitempty"`
Attempts json.RawMessage `json:"attempts"`
Error *json.RawMessage `json:"error,omitempty"`
Metadata json.RawMessage `json:"metadata"`
StartedAt time.Time `json:"started_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}
// ChatDebugAttempt is a single LLM attempt within a step.
// Kept opaque for now — the attempts field on ChatDebugStep
// is json.RawMessage.
type ChatDebugAttempt struct {
AttemptNumber int32 `json:"attempt_number"`
Status string `json:"status"`
RawRequest *json.RawMessage `json:"raw_request,omitempty"`
RawResponse *json.RawMessage `json:"raw_response,omitempty"`
Error *json.RawMessage `json:"error,omitempty"`
DurationMs *int64 `json:"duration_ms,omitempty"`
StartedAt time.Time `json:"started_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}
// ChatDebugEvent is a forward-compatible SSE event type for future
// live debug streaming. No transport is wired in this phase.
type ChatDebugEvent struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
// DefaultChatWorkspaceTTL is the default TTL for chat workspaces.
// Zero means disabled — the template's own autostop setting applies.
const DefaultChatWorkspaceTTL = 0
@@ -1516,6 +1606,60 @@ func (c *ExperimentalClient) UpdateChatDesktopEnabled(ctx context.Context, req U
return nil
}
// GetChatDebugLoggingEnabled returns the deployment-wide debug logging setting.
func (c *ExperimentalClient) GetChatDebugLoggingEnabled(ctx context.Context) (ChatDebugSettings, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/debug-logging", nil)
if err != nil {
return ChatDebugSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatDebugSettings{}, ReadBodyAsError(res)
}
var resp ChatDebugSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateChatDebugLoggingEnabled updates the deployment-wide debug logging setting.
func (c *ExperimentalClient) UpdateChatDebugLoggingEnabled(ctx context.Context, req UpdateChatDebugLoggingRequest) 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
}
// GetUserChatDebugLoggingEnabled returns the user debug logging setting.
func (c *ExperimentalClient) GetUserChatDebugLoggingEnabled(ctx context.Context) (ChatDebugSettings, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-debug-logging", nil)
if err != nil {
return ChatDebugSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatDebugSettings{}, ReadBodyAsError(res)
}
var resp ChatDebugSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserChatDebugLoggingEnabled updates the user debug logging setting.
func (c *ExperimentalClient) UpdateUserChatDebugLoggingEnabled(ctx context.Context, req UpdateChatDebugLoggingRequest) 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
}
// GetChatWorkspaceTTL returns the configured chat workspace TTL.
func (c *ExperimentalClient) GetChatWorkspaceTTL(ctx context.Context) (ChatWorkspaceTTLResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/workspace-ttl", nil)
@@ -1778,6 +1922,34 @@ func (c *ExperimentalClient) StreamChat(ctx context.Context, chatID uuid.UUID, o
}), 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)
+198
View File
@@ -1986,6 +1986,190 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|----------------------|---------|----------|--------------|-------------|
| `acquire_batch_size` | integer | false | | |
## codersdk.ChatDebugRun
```json
{
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"finished_at": "2019-08-24T14:15:22Z",
"history_tip_message_id": 0,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"kind": "string",
"model": "string",
"model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"provider": "string",
"root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7",
"started_at": "2019-08-24T14:15:22Z",
"status": "string",
"steps": [
{
"assistant_message_id": 0,
"attempts": [
0
],
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"error": [
0
],
"finished_at": "2019-08-24T14:15:22Z",
"history_tip_message_id": 0,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"metadata": [
0
],
"normalized_request": [
0
],
"normalized_response": [
0
],
"operation": "string",
"run_id": "dded282c-8ebd-44cf-8ba5-9a234973d1ec",
"started_at": "2019-08-24T14:15:22Z",
"status": "string",
"step_number": 0,
"updated_at": "2019-08-24T14:15:22Z",
"usage": [
0
]
}
],
"summary": [
0
],
"trigger_message_id": 0,
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------------|-----------------------------------------------------------|----------|--------------|-------------|
| `chat_id` | string | false | | |
| `finished_at` | string | false | | |
| `history_tip_message_id` | integer | false | | |
| `id` | string | false | | |
| `kind` | string | false | | |
| `model` | string | false | | |
| `model_config_id` | string | false | | |
| `parent_chat_id` | string | false | | |
| `provider` | string | false | | |
| `root_chat_id` | string | false | | |
| `started_at` | string | false | | |
| `status` | string | false | | |
| `steps` | array of [codersdk.ChatDebugStep](#codersdkchatdebugstep) | false | | |
| `summary` | array of integer | false | | |
| `trigger_message_id` | integer | false | | |
| `updated_at` | string | false | | |
## codersdk.ChatDebugRunSummary
```json
{
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"finished_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"kind": "string",
"model": "string",
"provider": "string",
"started_at": "2019-08-24T14:15:22Z",
"status": "string",
"summary": [
0
],
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------|------------------|----------|--------------|-------------|
| `chat_id` | string | false | | |
| `finished_at` | string | false | | |
| `id` | string | false | | |
| `kind` | string | false | | |
| `model` | string | false | | |
| `provider` | string | false | | |
| `started_at` | string | false | | |
| `status` | string | false | | |
| `summary` | array of integer | false | | |
| `updated_at` | string | false | | |
## codersdk.ChatDebugSettings
```json
{
"debug_logging_enabled": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------------|---------|----------|--------------|-------------|
| `debug_logging_enabled` | boolean | false | | |
## codersdk.ChatDebugStep
```json
{
"assistant_message_id": 0,
"attempts": [
0
],
"chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86",
"error": [
0
],
"finished_at": "2019-08-24T14:15:22Z",
"history_tip_message_id": 0,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"metadata": [
0
],
"normalized_request": [
0
],
"normalized_response": [
0
],
"operation": "string",
"run_id": "dded282c-8ebd-44cf-8ba5-9a234973d1ec",
"started_at": "2019-08-24T14:15:22Z",
"status": "string",
"step_number": 0,
"updated_at": "2019-08-24T14:15:22Z",
"usage": [
0
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------------|------------------|----------|--------------|-------------|
| `assistant_message_id` | integer | false | | |
| `attempts` | array of integer | false | | |
| `chat_id` | string | false | | |
| `error` | array of integer | false | | |
| `finished_at` | string | false | | |
| `history_tip_message_id` | integer | false | | |
| `id` | string | false | | |
| `metadata` | array of integer | false | | |
| `normalized_request` | array of integer | false | | |
| `normalized_response` | array of integer | false | | |
| `operation` | string | false | | |
| `run_id` | string | false | | |
| `started_at` | string | false | | |
| `status` | string | false | | |
| `step_number` | integer | false | | |
| `updated_at` | string | false | | |
| `usage` | array of integer | false | | |
## codersdk.ConnectionLatency
```json
@@ -10237,6 +10421,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. |
## codersdk.UpdateChatDebugLoggingRequest
```json
{
"debug_logging_enabled": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------------------|---------|----------|--------------|-------------|
| `debug_logging_enabled` | boolean | false | | |
## codersdk.UpdateCheckResponse
```json
+48
View File
@@ -3241,6 +3241,54 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatDebugLogging = async (): Promise<TypesGen.ChatDebugSettings> => {
const response = await this.axios.get<TypesGen.ChatDebugSettings>(
"/api/experimental/chats/config/debug-logging",
);
return response.data;
};
updateChatDebugLogging = async (
req: TypesGen.UpdateChatDebugLoggingRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/debug-logging", req);
};
getChatUserDebugLogging = async (): Promise<TypesGen.ChatDebugSettings> => {
const response = await this.axios.get<TypesGen.ChatDebugSettings>(
"/api/experimental/chats/config/user-debug-logging",
);
return response.data;
};
updateChatUserDebugLogging = async (
req: TypesGen.UpdateChatDebugLoggingRequest,
): 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 =
+122
View File
@@ -9,10 +9,14 @@ import {
chatCostSummaryKey,
chatCostUsers,
chatCostUsersKey,
chatDebugLogging,
chatDebugRun,
chatDebugRuns,
chatDiffContentsKey,
chatKey,
chatMessagesKey,
chatsKey,
chatUserDebugLogging,
createChat,
createChatMessage,
deleteChatQueuedMessage,
@@ -26,6 +30,8 @@ import {
reorderPinnedChat,
unarchiveChat,
unpinChat,
updateChatDebugLogging,
updateChatUserDebugLogging,
updateInfiniteChatsCache,
} from "./chats";
@@ -38,6 +44,12 @@ vi.mock("#/api/api", () => ({
getChats: vi.fn(),
getChatCostSummary: vi.fn(),
getChatCostUsers: vi.fn(),
getChatDebugLogging: vi.fn(),
updateChatDebugLogging: vi.fn(),
getChatUserDebugLogging: vi.fn(),
updateChatUserDebugLogging: vi.fn(),
getChatDebugRuns: vi.fn(),
getChatDebugRun: vi.fn(),
createChatMessage: vi.fn(),
editChatMessage: vi.fn(),
interruptChat: vi.fn(),
@@ -107,6 +119,116 @@ const createTestQueryClient = (): QueryClient =>
},
});
describe("chat debug queries", () => {
it("builds the expected chat debug logging query", async () => {
const settings = {
debug_logging_enabled: true,
} satisfies TypesGen.ChatDebugSettings;
vi.mocked(API.experimental.getChatDebugLogging).mockResolvedValue(settings);
const query = chatDebugLogging();
expect(query.queryKey).toEqual(["chatDebugLogging"]);
await expect(query.queryFn()).resolves.toEqual(settings);
});
it("invalidates chat debug logging after updates", async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const req = {
debug_logging_enabled: true,
} satisfies TypesGen.UpdateChatDebugLoggingRequest;
vi.mocked(API.experimental.updateChatDebugLogging).mockResolvedValue();
const mutation = updateChatDebugLogging(queryClient);
await expect(mutation.mutationFn(req)).resolves.toBeUndefined();
await mutation.onSuccess();
expect(API.experimental.updateChatDebugLogging).toHaveBeenCalledWith(req);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chatDebugLogging"],
});
});
it("builds the expected chat user debug logging query", async () => {
const settings = {
debug_logging_enabled: false,
} satisfies TypesGen.ChatDebugSettings;
vi.mocked(API.experimental.getChatUserDebugLogging).mockResolvedValue(
settings,
);
const query = chatUserDebugLogging();
expect(query.queryKey).toEqual(["chatUserDebugLogging"]);
await expect(query.queryFn()).resolves.toEqual(settings);
});
it("invalidates chat user debug logging after updates", async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const req = {
debug_logging_enabled: false,
} satisfies TypesGen.UpdateChatDebugLoggingRequest;
vi.mocked(API.experimental.updateChatUserDebugLogging).mockResolvedValue();
const mutation = updateChatUserDebugLogging(queryClient);
await expect(mutation.mutationFn(req)).resolves.toBeUndefined();
await mutation.onSuccess();
expect(API.experimental.updateChatUserDebugLogging).toHaveBeenCalledWith(
req,
);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chatUserDebugLogging"],
});
});
it("builds the expected chat debug runs query", async () => {
const chatId = "chat-1";
const runs = [
{
id: "run-1",
chat_id: chatId,
kind: "message",
status: "running",
summary: {},
started_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
] satisfies TypesGen.ChatDebugRunSummary[];
vi.mocked(API.experimental.getChatDebugRuns).mockResolvedValue(runs);
const query = chatDebugRuns(chatId);
expect(query.queryKey).toEqual(["chats", chatId, "debug-runs"]);
expect(query.refetchInterval).toBe(5_000);
expect(query.refetchIntervalInBackground).toBe(false);
await expect(query.queryFn()).resolves.toEqual(runs);
});
it("builds the expected chat debug run query", async () => {
const chatId = "chat-1";
const runId = "run-1";
const run = {
id: runId,
chat_id: chatId,
kind: "message",
status: "running",
summary: {},
started_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
steps: [],
} satisfies TypesGen.ChatDebugRun;
vi.mocked(API.experimental.getChatDebugRun).mockResolvedValue(run);
const query = chatDebugRun(chatId, runId);
expect(query.queryKey).toEqual(["chats", chatId, "debug-runs", runId]);
await expect(query.queryFn()).resolves.toEqual(run);
});
});
describe("invalidateChatListQueries", () => {
it("invalidates flat and infinite chat list queries", async () => {
const queryClient = createTestQueryClient();
+50
View File
@@ -641,6 +641,56 @@ export const updateChatSystemPrompt = (queryClient: QueryClient) => ({
},
});
const chatDebugLoggingKey = ["chatDebugLogging"] as const;
export const chatDebugLogging = () => ({
queryKey: chatDebugLoggingKey,
queryFn: () => API.experimental.getChatDebugLogging(),
});
export const updateChatDebugLogging = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatDebugLogging,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatDebugLoggingKey,
});
},
});
const chatUserDebugLoggingKey = ["chatUserDebugLogging"] as const;
export const chatUserDebugLogging = () => ({
queryKey: chatUserDebugLoggingKey,
queryFn: () => API.experimental.getChatUserDebugLogging(),
});
export const updateChatUserDebugLogging = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatUserDebugLogging,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatUserDebugLoggingKey,
});
},
});
export const chatDebugRunsKey = (chatId: string) =>
["chats", chatId, "debug-runs"] as const;
export const chatDebugRuns = (chatId: string) => ({
queryKey: chatDebugRunsKey(chatId),
queryFn: () => API.experimental.getChatDebugRuns(chatId),
refetchInterval: 5_000,
refetchIntervalInBackground: false,
});
const chatDebugRunKey = (chatId: string, runId: string) =>
["chats", chatId, "debug-runs", runId] as const;
export const chatDebugRun = (chatId: string, runId: string) => ({
queryKey: chatDebugRunKey(chatId, runId),
queryFn: () => API.experimental.getChatDebugRun(chatId, runId),
});
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
export const chatDesktopEnabled = () => ({
+113
View File
@@ -1200,6 +1200,11 @@ export interface Chat {
* connect and disconnect.
*/
readonly has_unread: boolean;
/**
* DebugLogsEnabledOverride overrides debug logging for this
* chat when set.
*/
readonly debug_logs_enabled_override?: boolean;
/**
* LastInjectedContext holds the most recently persisted
* injected context parts (AGENTS.md files and skills). It
@@ -1343,6 +1348,105 @@ export interface ChatCostUsersResponse {
readonly users: readonly ChatCostUserRollup[];
}
// From codersdk/chats.go
/**
* ChatDebugAttempt is a single LLM attempt within a step.
* Kept opaque for now the attempts field on ChatDebugStep
* is json.RawMessage.
*/
export interface ChatDebugAttempt {
readonly attempt_number: number;
readonly status: string;
readonly raw_request?: Record<string, string>;
readonly raw_response?: Record<string, string>;
readonly error?: Record<string, string>;
readonly duration_ms?: number;
readonly started_at: string;
readonly finished_at?: string;
}
// From codersdk/chats.go
/**
* ChatDebugEvent is a forward-compatible SSE event type for future
* live debug streaming. No transport is wired in this phase.
*/
export interface ChatDebugEvent {
readonly type: string;
readonly data: Record<string, string>;
}
// From codersdk/chats.go
/**
* ChatDebugRun is the detailed run response including steps.
*/
export interface ChatDebugRun {
readonly id: string;
readonly chat_id: string;
readonly root_chat_id?: string;
readonly parent_chat_id?: string;
readonly model_config_id?: string;
readonly trigger_message_id?: number;
readonly history_tip_message_id?: number;
readonly kind: string;
readonly status: string;
readonly provider?: string;
readonly model?: string;
readonly summary: Record<string, string>;
readonly started_at: string;
readonly updated_at: string;
readonly finished_at?: string;
readonly steps: readonly ChatDebugStep[];
}
// From codersdk/chats.go
/**
* ChatDebugRunSummary is a lightweight run entry for list endpoints.
*/
export interface ChatDebugRunSummary {
readonly id: string;
readonly chat_id: string;
readonly kind: string;
readonly status: string;
readonly provider?: string;
readonly model?: string;
readonly summary: Record<string, string>;
readonly started_at: string;
readonly updated_at: string;
readonly finished_at?: string;
}
// From codersdk/chats.go
/**
* ChatDebugSettings is the response for getting the debug logging setting.
*/
export interface ChatDebugSettings {
readonly debug_logging_enabled: boolean;
}
// From codersdk/chats.go
/**
* ChatDebugStep is a single step within a debug run.
*/
export interface ChatDebugStep {
readonly id: string;
readonly run_id: string;
readonly chat_id: string;
readonly step_number: number;
readonly operation: string;
readonly status: string;
readonly history_tip_message_id?: number;
readonly assistant_message_id?: number;
readonly normalized_request: Record<string, string>;
readonly normalized_response?: Record<string, string>;
readonly usage?: Record<string, string>;
readonly attempts: Record<string, string>;
readonly error?: Record<string, string>;
readonly metadata: Record<string, string>;
readonly started_at: string;
readonly updated_at: string;
readonly finished_at?: string;
}
// From codersdk/chats.go
/**
* ChatDesktopEnabledResponse is the response for getting the desktop setting.
@@ -7117,6 +7221,14 @@ export interface UpdateAppearanceConfig {
readonly announcement_banners: readonly BannerConfig[];
}
// From codersdk/chats.go
/**
* UpdateChatDebugLoggingRequest is the request to update the debug logging setting.
*/
export interface UpdateChatDebugLoggingRequest {
readonly debug_logging_enabled: boolean;
}
// From codersdk/chats.go
/**
* UpdateChatDesktopEnabledRequest is the request to update the desktop setting.
@@ -7171,6 +7283,7 @@ export interface UpdateChatRequest {
*/
readonly pin_order?: number;
readonly labels?: Record<string, string>;
readonly debug_logs_enabled_override?: boolean;
}
// From codersdk/chats.go
@@ -12,10 +12,12 @@ import { API, watchWorkspace } from "#/api/api";
import { isApiError } from "#/api/errors";
import {
chat,
chatDebugLogging,
chatDesktopEnabled,
chatMessagesForInfiniteScroll,
chatModelConfigs,
chatModels,
chatUserDebugLogging,
createChatMessage,
deleteChatQueuedMessage,
editChatMessage,
@@ -409,9 +411,18 @@ const AgentChatPage: FC = () => {
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const userThresholdsQuery = useQuery(userCompactionThresholds());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const deploymentDebugLoggingQuery = useQuery(chatDebugLogging());
const userDebugLoggingQuery = useQuery(chatUserDebugLogging());
const mcpServersQuery = useQuery(mcpServerConfigs());
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
// Debug logging is enabled when the user setting (or deployment
// fallback) is on. The tab is hidden entirely when disabled.
const debugLoggingEnabled =
userDebugLoggingQuery.data?.debug_logging_enabled ??
deploymentDebugLoggingQuery.data?.debug_logging_enabled ??
false;
// MCP server selection state.
const mcpServers = mcpServersQuery.data ?? [];
const [selectedMCPServerIds, setSelectedMCPServerIds] = useState<
@@ -1029,6 +1040,7 @@ const AgentChatPage: FC = () => {
return (
<AgentChatPageView
agentId={agentId}
chatId={chatQuery.data.id}
chatTitle={chatTitle}
parentChat={parentChat}
persistedError={persistedError}
@@ -1054,6 +1066,7 @@ const AgentChatPage: FC = () => {
onSetShowSidebarPanel={handleSetShowSidebarPanel}
prNumber={prNumber}
diffStatusData={chatQuery.data?.diff_status}
debugLoggingEnabled={debugLoggingEnabled}
gitWatcher={gitWatcher}
canOpenEditors={canOpenEditors}
canOpenWorkspace={canOpenWorkspace}
@@ -129,6 +129,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
diffStatusData: undefined as ComponentProps<
typeof AgentChatPageView
>["diffStatusData"],
debugLoggingEnabled: false,
gitWatcher: buildGitWatcher(),
canOpenEditors: false,
canOpenWorkspace: false,
@@ -21,6 +21,7 @@ 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 { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import type { ChatDetailError } from "./utils/usageLimitMessage";
@@ -53,6 +54,7 @@ interface EditingState {
interface AgentChatPageViewProps {
// Chat data.
agentId: string;
chatId?: string;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
persistedError: ChatDetailError | undefined;
@@ -93,6 +95,7 @@ interface AgentChatPageViewProps {
// Sidebar content data.
prNumber: number | undefined;
diffStatusData: ChatDiffStatus | undefined;
debugLoggingEnabled: boolean;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => boolean;
@@ -145,6 +148,7 @@ interface AgentChatPageViewProps {
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
agentId,
chatId,
chatTitle,
parentChat,
persistedError,
@@ -170,6 +174,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onSetShowSidebarPanel,
prNumber,
diffStatusData,
debugLoggingEnabled,
gitWatcher,
canOpenEditors,
canOpenWorkspace,
@@ -387,6 +392,15 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
/>
),
},
...(debugLoggingEnabled
? [
{
id: "debug",
label: "Debug",
content: <DebugPanel chatId={chatId ?? agentId} />,
},
]
: []),
]}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
@@ -1,14 +1,18 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDebugLogging,
chatDesktopEnabled,
chatModelConfigs,
chatSystemPrompt,
chatUserCustomPrompt,
chatUserDebugLogging,
chatWorkspaceTTL,
deleteUserCompactionThreshold,
updateChatDebugLogging,
updateChatDesktopEnabled,
updateChatSystemPrompt,
updateChatUserDebugLogging,
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
updateUserCompactionThreshold,
@@ -39,6 +43,19 @@ const AgentSettingsBehaviorPage: FC = () => {
updateChatDesktopEnabled(queryClient),
);
const debugLoggingQuery = useQuery({
...chatDebugLogging(),
enabled: permissions.editDeploymentConfig,
});
const saveDebugLoggingMutation = useMutation(
updateChatDebugLogging(queryClient),
);
const userDebugLoggingQuery = useQuery(chatUserDebugLogging());
const saveUserDebugLoggingMutation = useMutation(
updateChatUserDebugLogging(queryClient),
);
const workspaceTTLQuery = useQuery(chatWorkspaceTTL());
const saveWorkspaceTTLMutation = useMutation(
updateChatWorkspaceTTL(queryClient),
@@ -72,6 +89,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}
@@ -92,6 +111,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}
@@ -13,11 +13,6 @@ import { DurationField } from "./components/DurationField/DurationField";
import { SectionHeader } from "./components/SectionHeader";
import { TextPreviewDialog } from "./components/TextPreviewDialog";
import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings";
import {
getKylesophyEnabled,
isKylesophyForced,
setKylesophyEnabled,
} from "./utils/chime";
const textareaMaxHeight = 240;
const textareaBaseClassName =
@@ -36,6 +31,8 @@ interface AgentSettingsBehaviorPageViewProps {
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
debugLoggingData: TypesGen.ChatDebugSettings | undefined;
userDebugLoggingData: TypesGen.ChatDebugSettings | undefined;
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
@@ -75,6 +72,20 @@ interface AgentSettingsBehaviorPageViewProps {
isSavingDesktopEnabled: boolean;
isSaveDesktopEnabledError: boolean;
onSaveDebugLogging: (
req: TypesGen.UpdateChatDebugLoggingRequest,
options?: MutationCallbacks,
) => void;
isSavingDebugLogging: boolean;
isSaveDebugLoggingError: boolean;
onSaveUserDebugLogging: (
req: TypesGen.UpdateChatDebugLoggingRequest,
options?: MutationCallbacks,
) => void;
isSavingUserDebugLogging: boolean;
isSaveUserDebugLoggingError: boolean;
onSaveWorkspaceTTL: (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
options?: MutationCallbacks,
@@ -90,6 +101,8 @@ export const AgentSettingsBehaviorPageView: FC<
systemPromptData,
userPromptData,
desktopEnabledData,
debugLoggingData,
userDebugLoggingData,
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
@@ -110,6 +123,12 @@ export const AgentSettingsBehaviorPageView: FC<
onSaveDesktopEnabled,
isSavingDesktopEnabled,
isSaveDesktopEnabledError,
onSaveDebugLogging,
isSavingDebugLogging,
isSaveDebugLoggingError,
onSaveUserDebugLogging,
isSavingUserDebugLogging,
isSaveUserDebugLoggingError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
@@ -129,8 +148,6 @@ export const AgentSettingsBehaviorPageView: FC<
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] =
useState(false);
const kylesophyForced = isKylesophyForced();
const [kylesophyEnabled, setKylesophyLocal] = useState(getKylesophyEnabled);
// ── Derived state ──
const hasLoadedSystemPrompt = systemPromptData !== undefined;
@@ -163,6 +180,10 @@ export const AgentSettingsBehaviorPageView: FC<
const isUserPromptDirty =
localUserEdit !== null && localUserEdit !== serverUserPrompt;
const desktopEnabled = desktopEnabledData?.enable_desktop ?? false;
const deploymentDebugLoggingEnabled =
debugLoggingData?.debug_logging_enabled ?? false;
const userDebugLoggingEnabled =
userDebugLoggingData?.debug_logging_enabled ?? false;
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
const ttlMs = localTTLMs ?? serverTTLMs;
const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0;
@@ -518,30 +539,65 @@ export const AgentSettingsBehaviorPageView: FC<
</>
)}
<hr className="my-5 border-0 border-t border-solid border-border" />
{/* ── Kyleosophy toggle (always visible) ── */}
<SectionHeader
label="Debug Logging"
description="Control default debug logging for deployment-wide and personal chats."
/>
{canSetSystemPrompt && (
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Debug Logging Admin Override
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Force debug logging on for all users. Users can still disable it
in their personal settings.
</p>
<Switch
checked={deploymentDebugLoggingEnabled}
onCheckedChange={(checked) =>
onSaveDebugLogging({ debug_logging_enabled: checked })
}
aria-label="Debug Logging Admin Override"
disabled={isSavingDebugLogging}
/>
</div>
{isSaveDebugLoggingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save deployment debug logging setting.
</p>
)}
</div>
<hr className="my-5 border-0 border-t border-solid border-border" />
</>
)}
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Kyleosophy
Debug Logging
</h3>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Replace the standard completion chime. IYKYK.
{kylesophyForced && (
<span className="ml-1 font-semibold">
Kyleosophy is mandatory on <code>dev.coder.com</code>.
</span>
)}
Enable debug logging for your chats. When enabled, API requests and
responses are recorded for inspection in the Debug panel.
</p>
<Switch
checked={kylesophyEnabled}
onCheckedChange={(checked) => {
setKylesophyEnabled(checked);
setKylesophyLocal(checked);
}}
aria-label="Enable Kyleosophy"
disabled={kylesophyForced}
checked={userDebugLoggingEnabled}
onCheckedChange={(checked) =>
onSaveUserDebugLogging({ debug_logging_enabled: checked })
}
aria-label="Debug Logging"
disabled={isSavingUserDebugLogging}
/>
</div>
{isSaveUserDebugLoggingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal debug logging setting.
</p>
)}
</div>
{showDefaultPromptPreview && (
<TextPreviewDialog
@@ -157,6 +157,8 @@ const BehaviorRouteElement = () => {
}}
userPromptData={{ custom_prompt: "" }}
desktopEnabledData={{ enable_desktop: false }}
debugLoggingData={{ debug_logging_enabled: false }}
userDebugLoggingData={{ debug_logging_enabled: false }}
workspaceTTLData={{ workspace_ttl_ms: 0 }}
isWorkspaceTTLLoading={false}
isWorkspaceTTLLoadError={false}
@@ -175,6 +177,12 @@ const BehaviorRouteElement = () => {
onSaveDesktopEnabled={fn()}
isSavingDesktopEnabled={false}
isSaveDesktopEnabledError={false}
onSaveDebugLogging={fn()}
isSavingDebugLogging={false}
isSaveDebugLoggingError={false}
onSaveUserDebugLogging={fn()}
isSavingUserDebugLogging={false}
isSaveUserDebugLoggingError={false}
onSaveWorkspaceTTL={fn()}
isSavingWorkspaceTTL={false}
isSaveWorkspaceTTLError={false}
@@ -1,6 +1,10 @@
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { watchChat } from "#/api/api";
import { chatMessagesKey, chatsKey } from "#/api/queries/chats";
import {
chatDebugRunsKey,
chatMessagesKey,
chatsKey,
} from "#/api/queries/chats";
// The infinite query key used by useInfiniteQuery(infiniteChats())
// is [...chatsKey, undefined] = ["chats", undefined].
@@ -3197,6 +3201,110 @@ describe("thinking indicator event ordering", () => {
});
});
describe("chat debug runs invalidation", () => {
it("invalidates debug runs on status events for the active chat", async () => {
immediateAnimationFrame();
const chatID = "chat-debug-status";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
useChatStore({
chatID,
chatMessages: [],
chatRecord: makeChat(chatID),
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "running" },
});
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatDebugRunsKey(chatID),
});
});
});
it("invalidates debug runs when the chat stream disconnects", async () => {
immediateAnimationFrame();
const chatID = "chat-debug-disconnect";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
useChatStore({
chatID,
chatMessages: [],
chatRecord: makeChat(chatID),
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitClose();
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatDebugRunsKey(chatID),
});
});
});
});
describe("updateSidebarChat via stream events", () => {
it("updates sidebar chat status on status stream event", async () => {
immediateAnimationFrame();
@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { type InfiniteData, useQueryClient } from "react-query";
import { watchChat } from "#/api/api";
import { chatMessagesKey, updateInfiniteChatsCache } from "#/api/queries/chats";
import {
chatDebugRunsKey,
chatMessagesKey,
updateInfiniteChatsCache,
} from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket";
@@ -346,6 +350,11 @@ export const useChatStore = (
// Capture chatID as a narrowed string for use in closures.
const activeChatID = chatID;
const invalidateDebugRuns = () => {
void queryClient.invalidateQueries({
queryKey: chatDebugRunsKey(activeChatID),
});
};
// Local disposed flag so the message handler (which lives
// outside the utility) can bail out after cleanup.
let disposed = false;
@@ -437,6 +446,7 @@ export const useChatStore = (
// instead of N copies and N sorts.
const pendingMessages: TypesGen.ChatMessage[] = [];
let needsStreamReset = false;
let shouldInvalidateDebugRuns = false;
// Wrap all store mutations in a batch so subscribers
// are notified exactly once at the end, not per event.
@@ -517,6 +527,7 @@ export const useChatStore = (
continue;
}
shouldInvalidateDebugRuns = true;
store.clearRetryState();
store.setChatStatus(nextStatus);
if (nextStatus === "pending" || nextStatus === "waiting") {
@@ -580,6 +591,9 @@ export const useChatStore = (
upsertCacheMessages(pendingMessages);
}
});
if (shouldInvalidateDebugRuns) {
invalidateDebugRuns();
}
if (needsStreamReset) {
scheduleStreamReset();
}
@@ -610,6 +624,7 @@ export const useChatStore = (
if (shouldSurfaceReconnectState(snapshot)) {
store.setReconnectState(reconnectState);
}
invalidateDebugRuns();
},
});
@@ -0,0 +1,180 @@
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,
} from "./DebugPanelPrimitives";
import {
annotateRedactedJson,
computeDurationMs,
getStatusBadgeVariant,
type NormalizedAttempt,
} from "./debugPanelUtils";
interface DebugAttemptAccordionProps {
attempts: NormalizedAttempt[];
rawFallback?: string;
}
const renderJsonBlock = (value: unknown, fallbackCopy: string) => {
if (
!value ||
(typeof value === "string" && value.length === 0) ||
(typeof value === "object" && Object.keys(value as object).length === 0)
) {
return (
<p className="text-sm leading-6 text-content-secondary">{fallbackCopy}</p>
);
}
if (typeof value === "string") {
return <DebugCodeBlock code={value} />;
}
return (
<DebugCodeBlock
code={JSON.stringify(annotateRedactedJson(value), null, 2)}
/>
);
};
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) {
return (
<DebugDataSection
title="Unable to parse raw attempts"
description="Showing the original payload exactly as it was captured."
>
<DebugCodeBlock code={rawFallback} />
</DebugDataSection>
);
}
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">
{renderJsonBlock(
attempt.raw_request,
"No raw request captured.",
)}
</DebugDataSection>
<DebugDataSection title="Raw response">
{renderJsonBlock(
attempt.raw_response,
"No raw response captured.",
)}
</DebugDataSection>
<DebugDataSection title="Error">
{renderJsonBlock(attempt.error, "No error captured.")}
</DebugDataSection>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
);
};
@@ -0,0 +1,67 @@
import type { FC, ReactNode } from "react";
import { useQuery } from "react-query";
import { chatDebugRuns } from "#/api/queries/chats";
import { Alert } from "#/components/Alert/Alert";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Spinner } from "#/components/Spinner/Spinner";
import { DebugRunList } from "./DebugRunList";
interface DebugPanelProps {
chatId: string;
}
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return "Unable to load debug panel data.";
};
export const DebugPanel: FC<DebugPanelProps> = ({ chatId }) => {
const runsQuery = useQuery(chatDebugRuns(chatId));
const sortedRuns = [...(runsQuery.data ?? [])].sort(
(left, right) =>
Date.parse(right.started_at || right.updated_at) -
Date.parse(left.started_at || left.updated_at),
);
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)}
</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="p-4 text-sm text-content-secondary">
No debug runs recorded yet.
</div>
);
} else {
content = <DebugRunList runs={sortedRuns} chatId={chatId} />;
}
return (
<ScrollArea
className="h-full"
viewportClassName="h-full [&>div]:!block [&>div]:!w-full"
>
<div className="min-h-full w-full min-w-0 overflow-x-hidden">
{content}
</div>
</ScrollArea>
);
};
@@ -0,0 +1,165 @@
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>;
};
// ---------------------------------------------------------------------------
// 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,152 @@
import { ChevronDownIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { chatDebugRun } from "#/api/queries/chats";
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 {
coerceRunSummary,
compactDuration,
computeDurationMs,
formatTokenSummary,
getRunKindLabel,
getStatusBadgeVariant,
isActiveStatus,
truncatePrimaryLabel,
} from "./debugPanelUtils";
interface DebugRunCardProps {
run: ChatDebugRunSummary;
chatId: string;
}
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return "Unable to load debug run details.";
};
const getDurationLabel = (startedAt: string, finishedAt?: string): string => {
const durationMs = computeDurationMs(startedAt, finishedAt);
return durationMs !== null ? compactDuration(durationMs) : "—";
};
export const DebugRunCard: FC<DebugRunCardProps> = ({ run, chatId }) => {
const [isExpanded, setIsExpanded] = useState(false);
const runDetailQuery = useQuery({
...chatDebugRun(chatId, run.id),
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 = truncatePrimaryLabel(
summaryVm.primaryLabel.trim() || getRunKindLabel(run.kind),
);
// 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)}
</p>
</Alert>
) : (
<div className="space-y-0">
{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,26 @@
import type { FC } from "react";
import type { ChatDebugRunSummary } from "#/api/typesGenerated";
import { DebugRunCard } from "./DebugRunCard";
interface DebugRunListProps {
runs: ChatDebugRunSummary[];
chatId: string;
}
export const DebugRunList: FC<DebugRunListProps> = ({ runs, chatId }) => {
if (runs.length === 0) {
return (
<div className="p-4 text-sm text-content-secondary">
No debug runs recorded yet.
</div>
);
}
return (
<div className="w-full max-w-full min-w-0">
{runs.map((run) => (
<DebugRunCard key={run.id} run={run} chatId={chatId} />
))}
</div>
);
};
@@ -0,0 +1,455 @@
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,
MetadataItem,
PillToggle,
} from "./DebugPanelPrimitives";
import {
MessageRow,
ToolBadge,
ToolEventCard,
ToolPayloadDisclosure,
} from "./DebugStepCardTooling";
import {
annotateRedactedJson,
coerceStepRequest,
coerceStepResponse,
coerceUsage,
compactDuration,
computeDurationMs,
extractTokenCounts,
formatTokenSummary,
getStatusBadgeVariant,
normalizeAttempts,
TRANSCRIPT_PREVIEW_COUNT,
} from "./debugPanelUtils";
interface DebugStepCardProps {
step: ChatDebugStep;
defaultOpen?: boolean;
}
type SectionKey = "tools" | "options" | "usage" | "policy";
const safeStringify = (value: unknown): string => {
try {
return JSON.stringify(annotateRedactedJson(value), null, 2);
} catch {
return String(value);
}
};
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 = coerceUsage(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.finishReason;
// Detect whether there is an error payload.
const hasError =
!!step.error &&
typeof step.error === "object" &&
Object.keys(step.error).length > 0;
return (
<Collapsible defaultOpen={defaultOpen}>
<div className="border-l border-l-border-default/50">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-1 text-left transition-colors hover:bg-surface-secondary/20"
>
<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 px-3 pb-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={String(
request.policy.tool_choice ?? request.policy.toolChoice,
)}
/>
) : 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">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
{Object.entries(request.options).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-content-tertiary">{key}</dt>
<dd className="font-medium text-content-primary">
{String(value)}
</dd>
</div>
))}
</dl>
</DebugDataSection>
) : null}
{activeSection === "usage" && usageEntryCount > 0 ? (
<DebugDataSection title="Usage">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
{Object.entries(mergedUsage).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-content-tertiary">{key}</dt>
<dd className="font-medium text-content-primary">
{value.toLocaleString()}
</dd>
</div>
))}
</dl>
</DebugDataSection>
) : null}
{activeSection === "policy" && policyCount > 0 ? (
<DebugDataSection title="Policy">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
{Object.entries(request.policy).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-content-tertiary">{key}</dt>
<dd className="font-medium text-content-primary">
{typeof value === "object"
? JSON.stringify(value)
: String(value)}
</dd>
</div>
))}
</dl>
</DebugDataSection>
) : null}
{/* ── Input / Output two-column grid ─────────── */}
<div className="grid gap-4 lg:grid-cols-2">
{/* ── 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="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">
{w}
</p>
))}
</div>
) : null}
</div>
) : (
<EmptyHelper message="No output captured." />
)}
</DebugDataSection>
</div>
{/* ── Error ───────────────────────────────────── */}
{hasError ? (
<DebugDataSection title="Error">
<CopyableCodeBlock
code={safeStringify(step.error)}
label="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={safeStringify(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={safeStringify(step.normalized_response)}
label="Copy response body JSON"
/>
</CollapsibleContent>
</Collapsible>
) : null}
{/* ── Raw HTTP attempts ───────────────────────── */}
{attemptCount > 0 || normalizedAttempts.rawFallback ? (
<DebugDataSection title="Raw attempts">
<DebugAttemptAccordion
attempts={normalizedAttempts.parsed}
rawFallback={normalizedAttempts.rawFallback}
/>
</DebugDataSection>
) : null}
</CollapsibleContent>
</div>
</Collapsible>
);
};
@@ -0,0 +1,162 @@
import { WrenchIcon } from "lucide-react";
import { type FC, useState } from "react";
import { cn } from "#/utils/cn";
import { CopyableCodeBlock, RoleBadge } from "./DebugPanelPrimitives";
import {
MESSAGE_CONTENT_CLAMP_CHARS,
clampContent,
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 (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/10 px-2 py-0.5 text-2xs font-medium text-purple-300">
<WrenchIcon className="size-3 shrink-0" />
<span>{label}</span>
</span>
);
};
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 flex-wrap items-center gap-2">
<ToolBadge label={badgeLabel} />
{toolCallId ? (
<span className="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="font-mono text-2xs text-content-tertiary">
{msg.toolName}
</span>
) : null}
{msg.toolCallId && !msg.toolName ? (
<span className="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"
>
{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} />;
};
File diff suppressed because it is too large Load Diff