Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6deb103
fix(coderd/x): surface Gemini malformed-function-call stream deaths a…
ibetitsmike Aug 24, 2026
ccabdf1
fix: apply MCP server selection when editing a chat message (#28471)
ibetitsmike Aug 24, 2026
2dbe792
refactor: consolidate viewport hooks and remove defineProperty matchM…
ibetitsmike Aug 24, 2026
cb0c11c
feat(coderd/x/chatd/chattool): improve find_tools relevance and model…
ibetitsmike Aug 25, 2026
7a00423
fix(coderd/x/chatd/mcpclient): enforce MCP connect budget and unblock…
ibetitsmike Aug 25, 2026
87618a2
fix(coderd/x/chatd): truncate overlong generated chat titles instead …
ibetitsmike Aug 25, 2026
63c3565
feat: mount chat API routes under /api/v2 (#28496)
ibetitsmike Aug 26, 2026
33d092e
feat: promote codersdk chat API methods to Client (#28497)
ibetitsmike Aug 26, 2026
29dd39a
feat(site): use /api/v2 chat API paths (#28498)
ibetitsmike Aug 26, 2026
afacdea
fix(site/src): repair failing Storybook stories (#28462)
ethanndickson Aug 24, 2026
78f8e61
feat: enable Coder Agents for organization members (#28186)
ibetitsmike Aug 26, 2026
30368be
feat: allow sharing MCP servers with users and groups (#28593)
ibetitsmike Aug 26, 2026
83517e3
test(site/src/api): expect v2 MCP ACL path (#28657)
jakehwll Aug 26, 2026
d912e58
Merge remote-tracking branch 'origin/release/2.37' into backport/2846…
ibetitsmike Aug 27, 2026
eee1dd6
Merge remote-tracking branch 'origin/backport/28462-to-2.37' into bac…
ibetitsmike Aug 27, 2026
8353315
Merge branch 'backport/28186-to-2.37' into backport/28593-to-2.37
ibetitsmike Aug 27, 2026
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
Next Next commit
fix(coderd/x): surface Gemini malformed-function-call stream deaths a…
…s retryable errors (#28470)

## Problem

When Gemini's OpenAI-compatible endpoint rejects a model-generated
function call server-side, it ends the SSE stream cleanly with the
nonstandard finish reason `function_call_filter:
MALFORMED_FUNCTION_CALL` after streaming only thought summaries; the
rejected call never reaches the wire. chatd treated this as a normal
completion: fantasy maps the unrecognized finish reason to `unknown`,
the reasoning block never closes (the only non-thought delta is the
`</thought>` marker, which the transport seam strips to an empty string,
and the openaicompat hook only ends reasoning on a non-empty content
delta), so the step accumulates no content and the generation loop
finishes the turn as complete. The user sees the model think for ~40
seconds and then nothing: no assistant message, no `last_error`, chat
status `waiting`. Observed twice in a row in production on
gemini-3.7-flash, with "Resume" reproducing it identically.

## Fix

Two independent layers:

- `coderd/x/googleopenai`: the stream rewrite now converts any chunk
whose `finish_reason` starts with `function_call_filter` into an
OpenAI-style SSE `{"error": ...}` event embedding the raw reason.
openai-go turns error-bearing events into stream errors, so the failure
rides the existing stream-error path instead of ending the stream
cleanly.
- `coderd/x/chatd/chaterror`: classifies that injected error as
retryable (kind `generic`, provider `google`) with a clear user-facing
message, so the existing generation retry machinery re-runs the step and
persists a `last_error` if retries exhaust.
- `coderd/x/chatd/chatloop`: provider-agnostic guard: a step that
produced no user-visible content and no tool calls under a finish reason
of `unknown`, `error`, `other`, or `tool-calls` (a tool-calls finish
that delivered zero calls) now returns a retryable error instead of
silently completing the turn. `stop` and `length` finishes keep their
existing semantics.

Tests cover the seam rewrite (live-capture SSE shape plus standard
finish reason passthrough), the new classification, and the chatloop
guard (error cases plus preserved stop, length, text, and tool-call
behavior). Each layer was red-green verified independently.

Remote dogfood UAT ran against this exact commit: normal reasoning and
tool-call chats on a real model complete cleanly with no spurious guard
errors and no retry loops. The Google-side failure itself is not
deterministically triggerable against live Gemini and is owned by the
unit tests.

> Xum acted on Mike's (@ibetitsmike) behalf.

<!-- xum-attribution: model=claude-opus, thinking=enabled -->

(cherry picked from commit a48aedd)
  • Loading branch information
ibetitsmike committed Aug 26, 2026
commit 6deb1038cd55c6fed5699813a6e214fd13e2af93
35 changes: 35 additions & 0 deletions coderd/x/chatd/chaterror/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"golang.org/x/net/http2"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/x/googleopenai"
"github.com/coder/coder/v2/codersdk"
)

Expand Down Expand Up @@ -177,6 +178,15 @@ func Classify(err error) ClassifiedError {
return classified
}

if classified, ok := functionCallFilterClassification(
lower,
provider,
statusCode,
structured,
); ok {
return classified
}

retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err)
// combinedText merges the transport wrapper text with the structured
// provider response body so signal patterns in either are detected.
Expand Down Expand Up @@ -375,6 +385,31 @@ func streamIncompleteMessage(provider string) string {
return providerSubject(provider) + " stream closed unexpectedly before the response completed."
}

// Match only the adapter's exact error prefix so unrelated provider errors
// mentioning function_call_filter retain their normal classification.
func functionCallFilterClassification(
lowerMessage string,
provider string,
statusCode int,
structured providerErrorDetails,
) (ClassifiedError, bool) {
if !strings.Contains(lowerMessage, googleopenai.MalformedFunctionCallMessagePrefix) {
return ClassifiedError{}, false
}
if provider == "" {
provider = "google"
}
return normalizeClassification(ClassifiedError{
Message: "Gemini rejected the model's generated function call as malformed.",
Detail: structured.detail,
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
Retryable: true,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
}), true
}

func responsesAPIDiagnostic(lowerMessage, detail string) (string, bool) {
lowerDetail := strings.ToLower(detail)
for _, match := range responsesAPIDiagnosticMatches {
Expand Down
27 changes: 27 additions & 0 deletions coderd/x/chatd/chaterror/classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ func TestClassify(t *testing.T) {
StatusCode: 0,
},
},
{
name: "FunctionCallFilterMentionKeepsOwnClassification",
err: xerrors.New(`status 401: function_call_filter is not supported for this endpoint`),
want: chaterror.ClassifiedError{
Message: "Authentication with the AI provider failed. Check the API key and permissions.",
Kind: codersdk.ChatErrorKindAuth,
Provider: "",
Retryable: false,
StatusCode: 401,
},
},
{
name: "GeminiFunctionCallFilter",
err: xerrors.New(
`stream response: received error while streaming: ` +
`{"message":"gemini dropped the model's generated function call ` +
`(finish_reason \"function_call_filter: MALFORMED_FUNCTION_CALL\")",` +
`"type":"invalid_response_error","code":"malformed_function_call"}`,
),
want: chaterror.ClassifiedError{
Message: "Gemini rejected the model's generated function call as malformed.",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "google",
Retryable: true,
StatusCode: 0,
},
},
{
name: "AuthBeatsConfig",
err: xerrors.New("authentication failed: invalid model"),
Expand Down
45 changes: 41 additions & 4 deletions coderd/x/chatd/chatloop/chatloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ var (
// classifiers blocked the response and the model produced no
// content, e.g. Anthropic's stop_reason "refusal".
ErrContentFiltered = xerrors.New("response blocked by provider content filter")
// ErrNoModelOutput is returned when a stream ends without visible content or
// tool calls and its finish reason indicates an incomplete response.
ErrNoModelOutput = xerrors.New("model stream finished without output")

errStreamSilenceTimeout = xerrors.New(
"chat stream was silent for longer than the configured timeout",
Expand Down Expand Up @@ -481,6 +484,12 @@ func GenerateAssistant(ctx context.Context, opts GenerateAssistantOptions) (Assi
if result.finishReason == fantasy.FinishReasonContentFilter && !hasUserVisibleContent(result.content) {
return AssistantOutcome{}, contentFilterError(errorProvider, result.providerMetadata)
}
// Treat discarded responses as retryable so an empty turn is not persisted.
if silentNoOutputFinish(result.finishReason) && !hasUserVisibleContent(result.content) && len(result.toolCalls) == 0 {
noOutputErr := noModelOutputError(errorProvider, result.finishReason)
opts.Metrics.RecordStreamRetry(provider, modelName, chaterror.Classify(noOutputErr))
return AssistantOutcome{}, noOutputErr
}
step := PersistedStep{
Content: result.content,
Usage: result.usage,
Expand Down Expand Up @@ -515,20 +524,48 @@ func wrapProviderStreamError(provider string, err error) error {
return xerrors.Errorf("stream response: %w", chaterror.WithClassification(err, classified))
}

// hasUserVisibleContent reports whether any content part carries output the
// user can see. Reasoning parts do not count: they stream transiently and are
// not a substitute for a response.
// hasUserVisibleContent ignores reasoning and blank text because neither can
// complete a user-facing response.
func hasUserVisibleContent(content []fantasy.Content) bool {
for _, part := range content {
switch part.(type) {
switch value := part.(type) {
case fantasy.ReasoningContent, *fantasy.ReasoningContent:
case fantasy.TextContent:
if strings.TrimSpace(value.Text) != "" {
return true
}
case *fantasy.TextContent:
if value != nil && strings.TrimSpace(value.Text) != "" {
return true
}
default:
return true
}
}
return false
}

func silentNoOutputFinish(reason fantasy.FinishReason) bool {
switch reason {
case fantasy.FinishReasonUnknown, fantasy.FinishReasonError,
fantasy.FinishReasonOther, fantasy.FinishReasonToolCalls:
return true
default:
return false
}
}

func noModelOutputError(provider string, reason fantasy.FinishReason) error {
classified := chaterror.ClassifiedError{
Message: "The model ended its response without producing any output.",
Detail: "finish reason: " + string(reason),
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
Retryable: true,
}
return chaterror.WithClassification(ErrNoModelOutput, classified)
}

func contentFilterError(provider string, metadata fantasy.ProviderMetadata) error {
classified := chaterror.ClassifiedError{
Kind: codersdk.ChatErrorKindContentFilter,
Expand Down
157 changes: 157 additions & 0 deletions coderd/x/chatd/chatloop/nooutput_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package chatloop

import (
"context"
"testing"

"charm.land/fantasy"
"github.com/prometheus/client_golang/prometheus"
promtestutil "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
)

func TestGenerateAssistant_SilentNoOutputFinish(t *testing.T) {
t.Parallel()

generate := func(t *testing.T, parts []fantasy.StreamPart) (AssistantOutcome, *Metrics, error) {
t.Helper()
model := &chattest.FakeModel{
ProviderName: "google",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts(parts), nil
},
}
metrics := NewMetrics(prometheus.NewRegistry())
outcome, err := GenerateAssistant(context.Background(), GenerateAssistantOptions{
Model: model,
Metrics: metrics,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "hello"),
},
})
return outcome, metrics, err
}

unterminatedReasoning := func(finish fantasy.FinishReason) []fantasy.StreamPart {
return []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeReasoningStart, ID: "reasoning-1"},
{Type: fantasy.StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: "planning"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: finish},
}
}

t.Run("UnknownFinishWithoutOutputErrors", func(t *testing.T) {
t.Parallel()

outcome, metrics, err := generate(t, unterminatedReasoning(fantasy.FinishReasonUnknown))
require.ErrorIs(t, err, ErrNoModelOutput)
require.Empty(t, outcome.Step.Content)

classified := chaterror.Classify(err)
require.True(t, classified.Retryable)
require.Equal(t, codersdk.ChatErrorKindGeneric, classified.Kind)
require.Equal(t, "google", classified.Provider)
require.Equal(t, "The model ended its response without producing any output.", classified.Message)
require.Equal(t, "finish reason: unknown", classified.Detail)

retries := promtestutil.ToFloat64(metrics.StreamRetriesTotal.WithLabelValues(
"google", "test-model", string(codersdk.ChatErrorKindGeneric),
))
require.Equal(t, float64(1), retries)
})

t.Run("ReasoningOnlyContentWithUnknownFinishErrors", func(t *testing.T) {
t.Parallel()

_, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeReasoningStart, ID: "reasoning-1"},
{Type: fantasy.StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: "planning"},
{Type: fantasy.StreamPartTypeReasoningEnd, ID: "reasoning-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonUnknown},
})
require.ErrorIs(t, err, ErrNoModelOutput)
})

t.Run("ToolCallsFinishWithoutToolCallsErrors", func(t *testing.T) {
t.Parallel()

_, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
})
require.ErrorIs(t, err, ErrNoModelOutput)
require.Equal(t, "finish reason: tool-calls", chaterror.Classify(err).Detail)
})

t.Run("StopFinishWithoutOutputCompletes", func(t *testing.T) {
t.Parallel()

outcome, metrics, err := generate(t, unterminatedReasoning(fantasy.FinishReasonStop))
require.NoError(t, err)
require.Empty(t, outcome.Step.Content)
require.True(t, outcome.ModelStopped)

retries := promtestutil.ToFloat64(metrics.StreamRetriesTotal.WithLabelValues(
"google", "test-model", string(codersdk.ChatErrorKindGeneric),
))
require.Equal(t, float64(0), retries)
})

t.Run("LengthFinishWithoutOutputCompletes", func(t *testing.T) {
t.Parallel()

_, _, err := generate(t, unterminatedReasoning(fantasy.FinishReasonLength))
require.NoError(t, err)
})

t.Run("EmptyTextWithUnknownFinishErrors", func(t *testing.T) {
t.Parallel()

_, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonUnknown},
})
require.ErrorIs(t, err, ErrNoModelOutput)
})

t.Run("WhitespaceTextWithUnknownFinishErrors", func(t *testing.T) {
t.Parallel()

_, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: " \n"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonUnknown},
})
require.ErrorIs(t, err, ErrNoModelOutput)
})

t.Run("UnknownFinishWithTextCompletes", func(t *testing.T) {
t.Parallel()

outcome, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "answer"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonUnknown},
})
require.NoError(t, err)
require.NotEmpty(t, outcome.Step.Content)
})

t.Run("ToolCallsFinishWithToolCallCompletes", func(t *testing.T) {
t.Parallel()

outcome, _, err := generate(t, []fantasy.StreamPart{
{Type: fantasy.StreamPartTypeToolCall, ID: "call-1", ToolCallName: "do_thing", ToolCallInput: "{}"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
})
require.NoError(t, err)
require.Len(t, outcome.ToolCalls, 1)
})
}
51 changes: 49 additions & 2 deletions coderd/x/googleopenai/thoughts.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package googleopenai
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
Expand Down Expand Up @@ -145,6 +146,9 @@ func (b *thoughtStreamBody) rewriteLine(line []byte) []byte {
if !choices.IsArray() {
return line
}
if errPayload := functionCallFilterErrorPayload(choices); errPayload != nil {
return assembleDataLine(errPayload, suffix)
}
out := payload
for index, choice := range choices.Array() {
delta := choice.Get("delta")
Expand Down Expand Up @@ -186,9 +190,52 @@ func (b *thoughtStreamBody) rewriteLine(line []byte) []byte {
b.inThought[index] = false
}
}
rewritten := make([]byte, 0, len(streamDataPrefix)+len(out)+len(suffix))
return assembleDataLine(out, suffix)
}

func assembleDataLine(payload, suffix []byte) []byte {
rewritten := make([]byte, 0, len(streamDataPrefix)+len(payload)+len(suffix))
rewritten = append(rewritten, streamDataPrefix...)
rewritten = append(rewritten, out...)
rewritten = append(rewritten, payload...)
rewritten = append(rewritten, suffix...)
return rewritten
}

// Gemini uses this nonstandard finish-reason prefix when it discards a
// malformed generated function call.
const functionCallFilterFinishReasonPrefix = "function_call_filter"

// MalformedFunctionCallMessagePrefix distinguishes adapter-generated errors
// from unrelated provider errors.
const MalformedFunctionCallMessagePrefix = "gemini dropped the model's generated function call"

type streamErrorEvent struct {
Error streamErrorDetail `json:"error"`
}

type streamErrorDetail struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}

// functionCallFilterErrorPayload turns the nonstandard finish reason into an
// SSE error so consumers do not accept the empty stream as successful.
func functionCallFilterErrorPayload(choices gjson.Result) []byte {
for _, choice := range choices.Array() {
reason := choice.Get("finish_reason")
if reason.Type != gjson.String || !strings.HasPrefix(reason.Str, functionCallFilterFinishReasonPrefix) {
continue
}
payload, err := json.Marshal(streamErrorEvent{Error: streamErrorDetail{
Message: MalformedFunctionCallMessagePrefix + " (finish_reason " + strconv.Quote(reason.Str) + ")",
Type: "invalid_response_error",
Code: "malformed_function_call",
}})
if err != nil {
return nil
}
return payload
}
return nil
}
Loading