diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index ff324b4..e04fb5f 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -159,7 +159,6 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, if bufferToolContent { for _, evt := range flushToolSieve(&sieve, toolNames) { if evt.Content != "" { - finalText += evt.Content sendEvent("response.output_text.delta", util.BuildOpenAIResponsesTextDeltaPayload(responseID, evt.Content)) } if len(evt.ToolCalls) > 0 { diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go new file mode 100644 index 0000000..4633388 --- /dev/null +++ b/internal/adapter/openai/responses_stream_test.go @@ -0,0 +1,70 @@ +package openai + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleResponsesStreamNoDuplicateTailInCompletedOutputText(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + rec := httptest.NewRecorder() + + sseLine := func(v string) string { + b, _ := json.Marshal(map[string]any{ + "p": "response/content", + "v": v, + }) + return "data: " + string(b) + "\n" + } + + tail := `{"tool_calls":[{"name":"read_file","input":` + streamBody := sseLine("Before ") + sseLine(tail) + "data: [DONE]\n" + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(streamBody)), + } + + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}) + + completed, ok := extractSSEEventPayload(rec.Body.String(), "response.completed") + if !ok { + t.Fatalf("expected response.completed event, body=%s", rec.Body.String()) + } + responseObj, _ := completed["response"].(map[string]any) + outputText, _ := responseObj["output_text"].(string) + if strings.Count(outputText, tail) != 1 { + t.Fatalf("expected tail to appear once in output_text, got output_text=%q", outputText) + } +} + +func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) { + scanner := bufio.NewScanner(strings.NewReader(body)) + matched := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "event: ") { + evt := strings.TrimSpace(strings.TrimPrefix(line, "event: ")) + matched = evt == targetEvent + continue + } + if !matched || !strings.HasPrefix(line, "data: ") { + continue + } + raw := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if raw == "" || raw == "[DONE]" { + continue + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, false + } + return payload, true + } + return nil, false +}