mirror of
https://github.com/coder/coder.git
synced 2026-11-01 04:47:17 +08:00
## 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 -->
158 lines
5.4 KiB
Go
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)
|
|
})
|
|
}
|