mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(coderd/x/chatd): add structured error fields to wait_agent error payload (#27478)
This commit is contained in:
+25
-11
@@ -929,17 +929,29 @@ func (p *Server) subagentTools(
|
||||
}
|
||||
if errStatus, ok := errors.AsType[*subagentStatusError](awaitErr); ok {
|
||||
errChat := errStatus.chat
|
||||
lastError := subagentLastErrorMessage(errChat.LastError)
|
||||
decoded, lastError := subagentLastError(errChat.LastError)
|
||||
if lastError == "" {
|
||||
lastError = errStatus.reason
|
||||
}
|
||||
return toolJSONResponse(withSubagentType(map[string]any{
|
||||
payload := map[string]any{
|
||||
"chat_id": errChat.ID.String(),
|
||||
"title": errChat.Title,
|
||||
"status": string(errChat.Status),
|
||||
"last_error": lastError,
|
||||
"report": errStatus.report,
|
||||
}, errChat)), nil
|
||||
}
|
||||
if decoded != nil {
|
||||
kind := decoded.Kind
|
||||
if kind == "" {
|
||||
kind = codersdk.ChatErrorKindGeneric
|
||||
}
|
||||
payload["last_error_kind"] = string(kind)
|
||||
payload["last_error_retryable"] = decoded.Retryable
|
||||
if decoded.Detail != "" {
|
||||
payload["last_error_detail"] = decoded.Detail
|
||||
}
|
||||
}
|
||||
return toolJSONResponse(withSubagentType(payload, errChat)), nil
|
||||
}
|
||||
return subagentErrorResponse(awaitErr, targetChatInfo), nil
|
||||
}
|
||||
@@ -1506,27 +1518,29 @@ func handleSubagentDone(
|
||||
// actionable information, so a provider detail replaces it entirely.
|
||||
const subagentGenericErrorMessage = "The chat request failed unexpectedly."
|
||||
|
||||
// subagentLastErrorMessage builds the message surfaced to the parent
|
||||
// model from a chat's last_error payload, preferring the actionable
|
||||
// subagentLastError decodes a chat's last_error payload and builds the
|
||||
// message surfaced to the parent model, preferring the actionable
|
||||
// provider detail. The content mirrors what the chat UI renders from
|
||||
// the same payload. An unrecognized payload yields an empty message so
|
||||
// the caller falls back to its own status reason instead of exposing
|
||||
// raw stored bytes.
|
||||
func subagentLastErrorMessage(raw pqtype.NullRawMessage) string {
|
||||
func subagentLastError(raw pqtype.NullRawMessage) (*codersdk.ChatError, string) {
|
||||
if !raw.Valid {
|
||||
return ""
|
||||
return nil, ""
|
||||
}
|
||||
var payload codersdk.ChatError
|
||||
if err := json.Unmarshal(raw.RawMessage, &payload); err != nil {
|
||||
return ""
|
||||
return nil, ""
|
||||
}
|
||||
switch {
|
||||
case payload.Message == "" && payload.Detail == "":
|
||||
return nil, ""
|
||||
case payload.Detail == "":
|
||||
return payload.Message
|
||||
return &payload, payload.Message
|
||||
case payload.Message == "" || payload.Message == subagentGenericErrorMessage:
|
||||
return payload.Detail
|
||||
return &payload, payload.Detail
|
||||
default:
|
||||
return payload.Message + " (" + payload.Detail + ")"
|
||||
return &payload, payload.Message + " (" + payload.Detail + ")"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4208,10 +4208,13 @@ func TestWaitAgentErrorStatusReturnsStructuredPayload(t *testing.T) {
|
||||
require.Equal(t, "provider overloaded", result["last_error"])
|
||||
require.Equal(t, "partial progress", result["report"])
|
||||
require.Equal(t, subagentTypeGeneral, result["type"])
|
||||
require.Equal(t, string(codersdk.ChatErrorKindGeneric), result["last_error_kind"])
|
||||
require.Equal(t, false, result["last_error_retryable"])
|
||||
require.NotContains(t, result, "last_error_detail")
|
||||
require.NotContains(t, result, "timed_out")
|
||||
}
|
||||
|
||||
func TestSubagentLastErrorMessage(t *testing.T) {
|
||||
func TestSubagentLastError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := func(s string) pqtype.NullRawMessage {
|
||||
@@ -4219,9 +4222,10 @@ func TestSubagentLastErrorMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw pqtype.NullRawMessage
|
||||
want string
|
||||
name string
|
||||
raw pqtype.NullRawMessage
|
||||
want string
|
||||
wantDecoded bool
|
||||
}{
|
||||
{name: "Invalid", raw: pqtype.NullRawMessage{}, want: ""},
|
||||
// Unrecognized payloads must not leak raw stored bytes into
|
||||
@@ -4229,48 +4233,57 @@ func TestSubagentLastErrorMessage(t *testing.T) {
|
||||
{name: "NotChatError", raw: rawJSON(`"oops"`), want: ""},
|
||||
{name: "EmptyObject", raw: rawJSON(`{}`), want: ""},
|
||||
{
|
||||
name: "MessageOnly",
|
||||
raw: rawJSON(`{"message":"provider overloaded"}`),
|
||||
want: "provider overloaded",
|
||||
name: "MessageOnly",
|
||||
raw: rawJSON(`{"message":"provider overloaded"}`),
|
||||
want: "provider overloaded",
|
||||
wantDecoded: true,
|
||||
},
|
||||
{
|
||||
name: "DetailReplacesGenericMessage",
|
||||
raw: rawJSON(`{"message":"The chat request failed unexpectedly.","detail":"reasoning model ` + "`max`" + ` not supported"}`),
|
||||
want: "reasoning model `max` not supported",
|
||||
name: "DetailReplacesGenericMessage",
|
||||
raw: rawJSON(`{"message":"The chat request failed unexpectedly.","detail":"reasoning model ` + "`max`" + ` not supported"}`),
|
||||
want: "reasoning model `max` not supported",
|
||||
wantDecoded: true,
|
||||
},
|
||||
{
|
||||
name: "DetailOnly",
|
||||
raw: rawJSON(`{"detail":"reasoning model ` + "`max`" + ` not supported"}`),
|
||||
want: "reasoning model `max` not supported",
|
||||
name: "DetailOnly",
|
||||
raw: rawJSON(`{"detail":"reasoning model ` + "`max`" + ` not supported"}`),
|
||||
want: "reasoning model `max` not supported",
|
||||
wantDecoded: true,
|
||||
},
|
||||
{
|
||||
name: "DetailAppendedToMeaningfulMessage",
|
||||
raw: rawJSON(`{"kind":"config","message":"Vercel AI Gateway rejected the model configuration.","detail":"unknown model slug"}`),
|
||||
want: "Vercel AI Gateway rejected the model configuration. (unknown model slug)",
|
||||
name: "DetailAppendedToMeaningfulMessage",
|
||||
raw: rawJSON(`{"kind":"config","message":"Vercel AI Gateway rejected the model configuration.","detail":"unknown model slug"}`),
|
||||
want: "Vercel AI Gateway rejected the model configuration. (unknown model slug)",
|
||||
wantDecoded: true,
|
||||
},
|
||||
// Detail passes through exactly as the chat UI renders it,
|
||||
// including auth details, provider request IDs, and long
|
||||
// opaque diagnostic tokens.
|
||||
{
|
||||
name: "AuthKindDetailPreserved",
|
||||
raw: rawJSON(`{"kind":"auth","message":"Authentication with Anthropic failed.","detail":"401 invalid x-api-key"}`),
|
||||
want: "Authentication with Anthropic failed. (401 invalid x-api-key)",
|
||||
name: "AuthKindDetailPreserved",
|
||||
raw: rawJSON(`{"kind":"auth","message":"Authentication with Anthropic failed.","detail":"401 invalid x-api-key"}`),
|
||||
want: "Authentication with Anthropic failed. (401 invalid x-api-key)",
|
||||
wantDecoded: true,
|
||||
},
|
||||
{
|
||||
name: "RequestIDPreserved",
|
||||
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)"}`),
|
||||
want: "upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)",
|
||||
name: "RequestIDPreserved",
|
||||
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)"}`),
|
||||
want: "upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)",
|
||||
wantDecoded: true,
|
||||
},
|
||||
{
|
||||
name: "ModelSlugPreserved",
|
||||
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"model claude-haiku-4-5-20251001 is not available"}`),
|
||||
want: "model claude-haiku-4-5-20251001 is not available",
|
||||
name: "ModelSlugPreserved",
|
||||
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"model claude-haiku-4-5-20251001 is not available"}`),
|
||||
want: "model claude-haiku-4-5-20251001 is not available",
|
||||
wantDecoded: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tc.want, subagentLastErrorMessage(tc.raw))
|
||||
decoded, message := subagentLastError(tc.raw)
|
||||
require.Equal(t, tc.want, message)
|
||||
require.Equal(t, tc.wantDecoded, decoded != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4311,6 +4324,9 @@ func TestWaitAgentErrorStatusSurfacesLastErrorDetail(t *testing.T) {
|
||||
require.Equal(t, child.ID.String(), result["chat_id"])
|
||||
require.Equal(t, "reasoning model `max` not supported", result["last_error"])
|
||||
require.Equal(t, "partial progress", result["report"])
|
||||
require.Equal(t, string(codersdk.ChatErrorKindGeneric), result["last_error_kind"])
|
||||
require.Equal(t, false, result["last_error_retryable"])
|
||||
require.Equal(t, "reasoning model `max` not supported", result["last_error_detail"])
|
||||
}
|
||||
|
||||
func TestWaitAgentTimeoutGapCompletesWithError(t *testing.T) {
|
||||
@@ -4366,6 +4382,9 @@ func TestWaitAgentTimeoutGapCompletesWithError(t *testing.T) {
|
||||
require.Equal(t, "partial progress", m["report"])
|
||||
require.Equal(t, child.ID.String(), m["chat_id"])
|
||||
require.Equal(t, subagentTypeGeneral, m["type"])
|
||||
require.Equal(t, string(codersdk.ChatErrorKindGeneric), m["last_error_kind"])
|
||||
require.Equal(t, false, m["last_error_retryable"])
|
||||
require.NotContains(t, m, "last_error_detail")
|
||||
require.NotContains(t, m, "timed_out")
|
||||
}
|
||||
|
||||
@@ -4412,7 +4431,7 @@ func TestWaitAgentTimeoutGapSurfacesLastErrorDetail(t *testing.T) {
|
||||
Detail: "reasoning model `max` not supported",
|
||||
Kind: codersdk.ChatErrorKindGeneric,
|
||||
Provider: "vercel",
|
||||
Retryable: false,
|
||||
Retryable: true,
|
||||
})
|
||||
insertAssistantMessage(t, db, child.ID, model.ID, "partial progress")
|
||||
|
||||
@@ -4425,6 +4444,9 @@ func TestWaitAgentTimeoutGapSurfacesLastErrorDetail(t *testing.T) {
|
||||
require.Equal(t, "reasoning model `max` not supported", m["last_error"])
|
||||
require.Equal(t, "partial progress", m["report"])
|
||||
require.Equal(t, child.ID.String(), m["chat_id"])
|
||||
require.Equal(t, string(codersdk.ChatErrorKindGeneric), m["last_error_kind"])
|
||||
require.Equal(t, true, m["last_error_retryable"])
|
||||
require.Equal(t, "reasoning model `max` not supported", m["last_error_detail"])
|
||||
require.NotContains(t, m, "timed_out")
|
||||
}
|
||||
|
||||
@@ -4456,9 +4478,12 @@ func TestWaitAgentErrorStatusUnrecognizedLastError(t *testing.T) {
|
||||
), false)
|
||||
|
||||
// Unrecognized payloads fall back to the status reason instead of
|
||||
// leaking raw stored bytes.
|
||||
// leaking raw stored bytes, and omit the structured fields.
|
||||
require.Equal(t, string(database.ChatStatusError), result["status"])
|
||||
require.Equal(t, "agent reached error status", result["last_error"])
|
||||
require.NotContains(t, result, "last_error_kind")
|
||||
require.NotContains(t, result, "last_error_retryable")
|
||||
require.NotContains(t, result, "last_error_detail")
|
||||
}
|
||||
|
||||
func listAgentsChatIDs(t *testing.T, result map[string]any) []string {
|
||||
|
||||
Reference in New Issue
Block a user