fix!: persist structured chat errors (#24919)

**Breaking change for changelog:**

> `codersdk.Chat.last_error` now returns a structured `ChatError` object
(`{message, kind, provider, retryable, status_code, detail}`) instead of
a plain string. The chats API is experimental
(`/api/experimental/chats`), so this ships without a deprecation cycle;
consumers reading `chat.last_error` as a string must update to read
`chat.last_error.message`. SDK/generated TypeScript terminal error
payloads now use the single `ChatError` type; the live stream error
payload type is renamed from `ChatStreamError` to `ChatError`.

Persisted chat errors now carry the same provider-specific detail (kind,
provider, retryable, HTTP status, optional detail) as the live stream,
so refreshing a failed chat rehydrates with the full structured error
instead of a one-line headline.

Existing rows are migrated in place: legacy text errors are wrapped into
`{message, kind: "generic"}` so already-errored chats still render, and
rows with `last_error IS NULL` stay NULL. Internally, persisted fallback
decoding now reuses the existing `chaterror.KindGeneric` constant, with
no JSON value change.

Closes CODAGT-239
This commit is contained in:
Ethan
2026-05-05 12:56:06 +10:00
committed by GitHub
parent 7e01edeb8e
commit 4751416b29
47 changed files with 599 additions and 256 deletions
+1 -1
View File
@@ -1287,7 +1287,7 @@ func (m chatViewModel) handleStreamEvent(event codersdk.ChatStreamEvent) (chatVi
chatID: m.activeChatID,
event: codersdk.ChatStreamEvent{
Type: codersdk.ChatStreamEventTypeError,
Error: &codersdk.ChatStreamError{
Error: &codersdk.ChatError{
Message: fmt.Sprintf(
"failed to parse ask_user_question: %v",
err,
+4 -3
View File
@@ -78,7 +78,7 @@ func (m chatListModel) filteredChats() []codersdk.Chat {
filtered = append(filtered, chat)
continue
}
if chat.LastError != nil && strings.Contains(strings.ToLower(*chat.LastError), query) {
if chat.LastError != nil && strings.Contains(strings.ToLower(chat.LastError.Message), query) {
filtered = append(filtered, chat)
}
}
@@ -445,13 +445,14 @@ func (m chatListModel) View() string {
rowText := fmt.Sprintf("%s%s %s %s%s", rowPrefix, rowStyle.Render(title), status, m.styles.dimmedText.Render(timeAgo(row.chat.UpdatedAt)), extra)
lines = append(lines, rowText)
if row.chat.Status == codersdk.ChatStatusError && row.chat.LastError != nil {
if row.chat.Status == codersdk.ChatStatusError && row.chat.LastError != nil && row.chat.LastError.Message != "" {
lastError := row.chat.LastError.Message
errWidth := max(m.width-4, 20)
errPrefix := " "
if row.depth > 0 {
errPrefix += strings.Repeat(" ", row.depth)
}
lines = append(lines, errPrefix+m.styles.dimmedText.Render(m.styles.truncate(sanitizeTerminalRenderableText(*row.chat.LastError), errWidth)))
lines = append(lines, errPrefix+m.styles.dimmedText.Render(m.styles.truncate(sanitizeTerminalRenderableText(lastError), errWidth)))
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ func TestConsumeChatStreamText(t *testing.T) {
{Type: codersdk.ChatStreamEventTypeMessage, Message: &codersdk.ChatMessage{ID: 1, ChatID: uuid.New(), Role: codersdk.ChatMessageRoleAssistant, Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "Hello world"}}}},
{Type: codersdk.ChatStreamEventTypeStatus, Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}},
{Type: codersdk.ChatStreamEventTypeRetry, Retry: &codersdk.ChatStreamRetry{Attempt: 2, Error: "rate limited"}},
{Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatStreamError{Message: "boom"}},
{Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatError{Message: "boom"}},
} {
events <- event
}
+1 -1
View File
@@ -1070,7 +1070,7 @@ func TestAgents(t *testing.T) {
t.Parallel()
updated, cmd := applyStream(newTestChatViewModel(nil), codersdk.ChatStreamEvent{
Type: codersdk.ChatStreamEventTypeError,
Error: &codersdk.ChatStreamError{Message: "stream blew up"},
Error: &codersdk.ChatError{Message: "stream blew up"},
})
require.Nil(t, cmd)
require.Equal(t, "stream error: stream blew up", updated.err.Error())