Files
CyberStrikeAI/internal/multiagent/eino_summarize_model_guard_test.go
T

398 lines
15 KiB
Go

package multiagent
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/eino/schema/claude"
schemaopenai "github.com/cloudwego/eino/schema/openai"
)
type guardClassicSummaryModel struct {
out *schema.Message
}
func (m *guardClassicSummaryModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) {
return m.out, nil
}
func (m *guardClassicSummaryModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{m.out}), nil
}
func TestNonEmptySummaryChatModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := schema.AssistantMessage("", nil)
msg.ReasoningContent = "只返回了思考,没有最终摘要"
msg.ResponseMeta = &schema.ResponseMeta{
FinishReason: "stop",
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
}
_, err := newNonEmptySummaryChatModel(&guardClassicSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
`finish_reason="stop"`,
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
type guardAgenticSummaryModel struct {
out *schema.AgenticMessage
}
func (m *guardAgenticSummaryModel) Generate(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.AgenticMessage, error) {
return m.out, nil
}
func (m *guardAgenticSummaryModel) Stream(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return schema.StreamReaderFromArray([]*schema.AgenticMessage{m.out}), nil
}
func TestNonEmptyAgenticSummaryModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.Reasoning{Text: "只返回了思考,没有最终摘要"}),
},
ResponseMeta: &schema.AgenticResponseMeta{
TokenUsage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
},
}
_, err := newNonEmptyAgenticSummaryModel(&guardAgenticSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
"reasoning_blocks=1",
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
// Generate deliberately reproduces the SDK rejection; summaries must use Stream.
type streamingSummaryTestModel[T any] struct {
stream *schema.StreamReader[T]
err error
input []T
opts []model.Option
}
func (m *streamingSummaryTestModel[T]) Generate(context.Context, []T, ...model.Option) (T, error) {
var zero T
return zero, errors.New("streaming is required for operations that may take longer than 10 minutes")
}
func (m *streamingSummaryTestModel[T]) Stream(_ context.Context, input []T, opts ...model.Option) (*schema.StreamReader[T], error) {
m.input, m.opts = input, opts
return m.stream, m.err
}
func TestSummaryGenerateUsesStream(t *testing.T) {
t.Run("classic", func(t *testing.T) {
tail := schema.AssistantMessage("摘要", nil)
tail.ResponseMeta = &schema.ResponseMeta{FinishReason: "stop", Usage: &schema.TokenUsage{TotalTokens: 42}}
base := &streamingSummaryTestModel[*schema.Message]{stream: schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("完整", nil), nil, tail})}
input := []*schema.Message{schema.UserMessage("history")}
out, err := newNonEmptySummaryChatModel(base).Generate(context.Background(), input, model.WithMaxTokens(64000))
if err != nil {
t.Fatal(err)
}
if out.Content != "完整摘要" || out.ResponseMeta.Usage.TotalTokens != 42 {
t.Fatalf("lost streamed content or usage: %+v", out)
}
if base.input[0] != input[0] || *model.GetCommonOptions(nil, base.opts...).MaxTokens != 64000 {
t.Fatal("input/options not forwarded")
}
})
t.Run("agentic", func(t *testing.T) {
chunk := func(text string) *schema.AgenticMessage {
block := schema.NewContentBlock(&schema.AssistantGenText{Text: text})
block.StreamingMeta = &schema.StreamingMeta{Index: 0}
return &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant, ContentBlocks: []*schema.ContentBlock{block}}
}
tail := chunk("摘要")
tail.ResponseMeta = &schema.AgenticResponseMeta{ClaudeExtension: &claude.ResponseMetaExtension{StopReason: "end_turn"}, TokenUsage: &schema.TokenUsage{TotalTokens: 42}}
base := &streamingSummaryTestModel[*schema.AgenticMessage]{stream: schema.StreamReaderFromArray([]*schema.AgenticMessage{chunk("完整"), nil, tail})}
input := []*schema.AgenticMessage{chunk("history")}
out, err := newNonEmptyAgenticSummaryModel(base).Generate(context.Background(), input, model.WithMaxTokens(64000))
if err != nil {
t.Fatal(err)
}
if agenticAssistantTextContent(out) != "完整摘要" || out.ResponseMeta.TokenUsage.TotalTokens != 42 {
t.Fatalf("lost streamed content or usage: %+v", out)
}
if base.input[0] != input[0] || *model.GetCommonOptions(nil, base.opts...).MaxTokens != 64000 {
t.Fatal("input/options not forwarded")
}
})
}
func TestSummaryStreamFailuresDoNotReturnPartialSummary(t *testing.T) {
failure := errors.New("connection reset")
for _, kind := range []string{"start", "receive", "empty", "nil", "cancel"} {
t.Run(kind, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := &streamingSummaryTestModel[*schema.Message]{}
switch kind {
case "start":
base.err = failure
case "receive":
reader, writer := schema.Pipe[*schema.Message](2)
writer.Send(schema.AssistantMessage("partial", nil), nil)
writer.Send(nil, failure)
writer.Close()
base.stream = reader
case "empty":
base.stream = schema.StreamReaderFromArray([]*schema.Message{})
case "cancel":
base.stream = schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("partial", nil)})
cancel()
}
out, err := newNonEmptySummaryChatModel(base).Generate(ctx, nil)
if err == nil || out != nil {
t.Fatalf("out=%+v err=%v", out, err)
}
var wrapped *einoSummarizationModelError
if !errors.As(err, &wrapped) {
t.Fatalf("missing summary error wrapper: %v", err)
}
if (kind == "start" || kind == "receive") && !errors.Is(err, failure) {
t.Fatal("lost original error")
}
if kind == "cancel" && !errors.Is(err, context.Canceled) {
t.Fatal("lost cancellation")
}
})
}
}
func TestClaudeSummaryLargeBudgetStreamsThroughNativeSDK(t *testing.T) {
for _, mode := range []string{"complete", "truncated", "early_eof", "cancel_read"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
w.WriteHeader(400)
return
}
if !body.Stream || body.MaxTokens != 64000 {
t.Errorf("unexpected request: %+v", body)
w.WriteHeader(400)
return
}
w.Header().Set("Content-Type", "text/event-stream")
events := []string{
`{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}`,
`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"完整"}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"摘要"}}`,
`{"type":"content_block_stop","index":0}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}`,
`{"type":"message_stop"}`,
}
switch mode {
case "truncated":
events[5] = strings.ReplaceAll(events[5], "end_turn", "max_tokens")
case "early_eof", "cancel_read":
events = events[:4]
}
for _, event := range events {
var header struct {
Type string `json:"type"`
}
_ = json.Unmarshal([]byte(event), &header)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", header.Type, event)
}
if mode == "cancel_read" {
w.(http.Flusher).Flush()
close(started)
<-r.Context().Done()
}
}))
defer server.Close()
factory := newEinoAgenticChatModelFactory(server.Client(), nil, nil)
native, err := factory(ctx, config.OpenAIConfig{Provider: "claude", APIKey: "test-key", BaseURL: server.URL, Model: "claude-sonnet-4-20250514"}, einoModelModeNormal)
if err != nil {
t.Fatal(err)
}
input := EinoMessagesToAgentic([]*schema.Message{schema.UserMessage("summarize history")})
opts := newEinoSummarizationModelOptions(64000, "claude-sonnet-4-20250514", "agentic", nil, nil)
if _, err = native.Generate(ctx, input, opts...); err == nil || !strings.Contains(err.Error(), "streaming is required") {
t.Fatalf("expected original SDK rejection, got %v", err)
}
if mode == "cancel_read" {
go func() {
select {
case <-started:
cancel()
case <-ctx.Done():
}
}()
// Bound the test even if cancellation stops propagating to the HTTP request.
timer := time.AfterFunc(5*time.Second, cancel)
defer timer.Stop()
}
out, err := newNonEmptyAgenticSummaryModel(native).Generate(ctx, input, opts...)
if mode != "complete" {
if out != nil || err == nil {
t.Fatalf("accepted partial summary: out=%+v err=%v", out, err)
}
if mode == "cancel_read" && !errors.Is(err, context.Canceled) {
t.Fatalf("lost cancellation: %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if agenticAssistantTextContent(out) != "完整摘要" {
t.Fatalf("unexpected summary: %+v", out)
}
if out.ResponseMeta == nil || out.ResponseMeta.TokenUsage == nil || out.ResponseMeta.TokenUsage.CompletionTokens != 2 {
t.Fatalf("missing usage: %+v", out.ResponseMeta)
}
})
}
}
func TestSummaryCompletionValidation(t *testing.T) {
for _, reason := range []string{"stop", "", "length", "content_filter", "tool_calls", "unknown"} {
t.Run("classic/"+reason, func(t *testing.T) {
msg := schema.AssistantMessage("partial or complete summary", nil)
msg.ResponseMeta = &schema.ResponseMeta{FinishReason: reason}
out, err := newNonEmptySummaryChatModel(&guardClassicSummaryModel{out: msg}).Generate(context.Background(), nil)
if reason == "stop" {
if err != nil {
t.Fatal(err)
}
return
}
if out != nil || err == nil {
t.Fatalf("out=%+v err=%v", out, err)
}
})
}
for _, reason := range []string{"end_turn", "", "max_tokens", "stop_sequence", "tool_use", "pause_turn", "refusal"} {
t.Run("claude/"+reason, func(t *testing.T) {
msg := &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "summary"})},
ResponseMeta: &schema.AgenticResponseMeta{ClaudeExtension: &claude.ResponseMetaExtension{StopReason: reason}}}
out, err := newNonEmptyAgenticSummaryModel(&guardAgenticSummaryModel{out: msg}).Generate(context.Background(), nil)
if reason == "end_turn" {
if err != nil {
t.Fatal(err)
}
return
}
if out != nil || err == nil {
t.Fatalf("out=%+v err=%v", out, err)
}
})
}
for _, status := range []schemaopenai.ResponseStatus{"completed", "incomplete", "failed", "cancelled", "in_progress", ""} {
t.Run("openai/"+string(status), func(t *testing.T) {
msg := &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "summary"})},
ResponseMeta: &schema.AgenticResponseMeta{OpenAIExtension: &schemaopenai.ResponseMetaExtension{Status: status}}}
out, err := newNonEmptyAgenticSummaryModel(&guardAgenticSummaryModel{out: msg}).Generate(context.Background(), nil)
if status == "completed" {
if err != nil {
t.Fatal(err)
}
return
}
if out != nil || err == nil {
t.Fatalf("out=%+v err=%v", out, err)
}
})
}
}
// Exercise the actual Chat Completions adapter: its finish reason is stored in
// ResponseMeta.Extension, unlike the OpenAI Responses API's OpenAIExtension.
func TestOpenAIChatSummaryStreamCompletion(t *testing.T) {
for _, reason := range []string{"stop", "length", ""} {
t.Run(reason, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Stream bool `json:"stream"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !body.Stream {
t.Errorf("expected streamed request: %+v, %v", body, err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"summary\"},\"finish_reason\":null}]}\n\n")
if reason != "" {
fmt.Fprintf(w, "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":%q}]}\n\n", reason)
fmt.Fprint(w, "data: [DONE]\n\n")
}
}))
defer server.Close()
factory := newEinoAgenticChatModelFactory(server.Client(), nil, nil)
native, err := factory(context.Background(), config.OpenAIConfig{Provider: "openai", APIKey: "test-key", BaseURL: server.URL, Model: "gpt-4o"}, einoModelModeNormal)
if err != nil {
t.Fatal(err)
}
out, err := newNonEmptyAgenticSummaryModel(native).Generate(context.Background(), []*schema.AgenticMessage{schema.UserAgenticMessage("summarize")})
if reason == "stop" {
if err != nil || agenticAssistantTextContent(out) != "summary" {
t.Fatalf("out=%+v err=%v", out, err)
}
} else if err == nil || out != nil {
t.Fatalf("accepted incomplete summary: out=%+v err=%v", out, err)
}
})
}
}