Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions coderd/x/chatd/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 + ")"
}
}

Expand Down
81 changes: 53 additions & 28 deletions coderd/x/chatd/subagent_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4208,69 +4208,82 @@ 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 {
return pqtype.NullRawMessage{RawMessage: json.RawMessage(s), Valid: true}
}

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
// model context; the caller falls back to its status reason.
{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)
})
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")

Expand All @@ -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")
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading