From bfd2e8e80c6037d282c3192e7d14aa0dec4db1ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:59:00 +0000 Subject: [PATCH] feat(coderd/x/chatd): add structured error fields to wait_agent error payload --- coderd/x/chatd/subagent.go | 36 +++++++---- coderd/x/chatd/subagent_internal_test.go | 81 ++++++++++++++++-------- 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index b870d6626ce..98df7f1f0cf 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -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 + ")" } } diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 67b6da04f59..8615c0521b9 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -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 {