Files
coder/coderd/x/chatd/chatloop/nooutput_internal_test.go
T
Michael Suchacz a48aedd924 fix(coderd/x): surface Gemini malformed-function-call stream deaths as 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 -->
2026-08-24 17:09:00 +02:00

158 lines
5.4 KiB
Go

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)
})
}