Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01358ab917 | |||
| 5282a77b41 | |||
| 1c5ad426f2 | |||
| 1d1128127c | |||
| 656ab1bccd | |||
| 3977c015c0 | |||
| 0c8b810af6 | |||
| 9020947a45 | |||
| d9bc758184 |
Generated
+414
@@ -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": {
|
||||
|
||||
Generated
+390
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+65
-1
@@ -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);
|
||||
+65
@@ -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'
|
||||
);
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Generated
+198
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Generated
+113
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user