internal/reply/generator_test.go
Rama main · réplica de lectura del repositorio original.
package reply
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/yeomyeonggeori/blueprotocol/agentcontract"
"github.com/yeomyeonggeori/blueprotocol/model"
)
func TestReplyGeneratorRejectsProviderWithoutChatCompletion(t *testing.T) {
replyProvider := &structuredOnlyReplyProvider{}
generator := NewGenerator(replyProvider, nil)
_, errorValue := generator.GenerateReply(context.Background(), "hello")
if errorValue == nil || errorValue.Error() != "language model provider does not support chat completion" {
t.Fatalf("expected unavailable chat completion error, got %v", errorValue)
}
if replyProvider.structuredCallCount != 0 {
t.Fatalf("expected missing chat completion not to downgrade to structured generation, got %d calls", replyProvider.structuredCallCount)
}
}
func TestReplyGeneratorGeneratesChatReplyWithoutTools(t *testing.T) {
replyProvider := &chatReplyProvider{
response: model.ChatCompletionResponse{Message: model.ChatCompletionMessage{Role: "assistant", Content: " hello from chat "}},
}
generator := NewGenerator(replyProvider, nil)
reply, errorValue := generator.GenerateReply(context.Background(), "hello")
if errorValue != nil {
t.Fatalf("expected chat reply generation: %v", errorValue)
}
if reply != "hello from chat" {
t.Fatalf("expected trimmed chat reply, got %q", reply)
}
if len(replyProvider.request.Tools) != 0 {
t.Fatalf("expected no chat tools, got %+v", replyProvider.request.Tools)
}
if replyProvider.request.SchemaName != "blueclaw_reply" {
t.Fatalf("expected blueclaw_reply schema name, got %q", replyProvider.request.SchemaName)
}
expectedMessages := generator.chatMessages("hello", agentcontract.VisibleContext{}, nil)
if !reflect.DeepEqual(replyProvider.request.Messages, expectedMessages) {
t.Fatalf("expected existing reply messages to be preserved, got %+v", replyProvider.request.Messages)
}
if replyProvider.structuredCallCount != 0 {
t.Fatalf("expected chat reply not to call structured generation, got %d calls", replyProvider.structuredCallCount)
}
}
func TestReplyGeneratorResolvesChatReplyThroughCompleterAccessor(t *testing.T) {
replyProvider := &chatReplyProvider{
response: model.ChatCompletionResponse{Message: model.ChatCompletionMessage{Content: "fallback chat"}},
}
generator := NewGenerator(chatCompleterAccessorProvider{
structuredProvider: staticReplyProvider{content: `{"reply":"structured"}`},
chatCompleter: replyProvider,
}, nil)
reply, errorValue := generator.GenerateReply(context.Background(), "hello")
if errorValue != nil {
t.Fatalf("expected accessor chat reply generation: %v", errorValue)
}
if reply != "fallback chat" {
t.Fatalf("expected accessor chat reply, got %q", reply)
}
}
func TestReplyGeneratorRejectsEmptyChatReply(t *testing.T) {
replyProvider := &chatReplyProvider{
response: model.ChatCompletionResponse{Message: model.ChatCompletionMessage{Content: " "}},
}
generator := NewGenerator(replyProvider, nil)
_, errorValue := generator.GenerateReply(context.Background(), "hello")
if errorValue == nil || errorValue.Error() != "language model reply is empty" {
t.Fatalf("expected empty chat reply error, got %v", errorValue)
}
if replyProvider.structuredCallCount != 0 {
t.Fatalf("expected empty chat reply not to use structured fallback, got %d calls", replyProvider.structuredCallCount)
}
}
func TestReplyGeneratorPropagatesChatErrorWithoutStructuredRetry(t *testing.T) {
chatError := errors.New("chat contract rejected")
replyProvider := &chatReplyProvider{chatError: chatError}
generator := NewGenerator(replyProvider, nil)
_, errorValue := generator.GenerateReply(context.Background(), "hello")
if !errors.Is(errorValue, chatError) {
t.Fatalf("expected chat error to propagate, got %v", errorValue)
}
if replyProvider.structuredCallCount != 0 {
t.Fatalf("expected chat contract error not to trigger structured retry, got %d calls", replyProvider.structuredCallCount)
}
}
func TestReplyGeneratorPreservesChatCancellationContext(t *testing.T) {
replyProvider := &chatReplyProvider{chatError: context.Canceled}
generator := NewGenerator(replyProvider, nil)
responseContext, cancel := context.WithCancel(context.Background())
cancel()
_, errorValue := generator.GenerateReply(responseContext, "hello")
if !errors.Is(errorValue, context.Canceled) {
t.Fatalf("expected cancellation to propagate, got %v", errorValue)
}
if replyProvider.responseContext.Err() != context.Canceled {
t.Fatalf("expected canceled context to reach chat completer, got %v", replyProvider.responseContext.Err())
}
}
func TestReplyGeneratorInjectsMemoryIntoChatReplyRequest(t *testing.T) {
replyProvider := &capturingReplyProvider{content: "remembered"}
generator := NewGenerator(replyProvider, nil)
_, errorValue := generator.generateReplyWithMemory(
context.Background(),
"what did I ask for last time?",
[]agentcontract.MemoryFact{
{
Content: "the user asked for help debugging Mattermost DM replies.",
},
},
)
if errorValue != nil {
t.Fatalf("expected reply generation: %v", errorValue)
}
body := joinChatMessageContent(replyProvider.request.Messages)
if len(replyProvider.request.Messages) != 3 {
t.Fatalf("expected system, flattened context, user messages, got %d", len(replyProvider.request.Messages))
}
if !strings.Contains(body, "Runtime:") || !strings.Contains(body, "This week:") {
t.Fatalf("expected runtime context to be injected, got %q", body)
}
if !strings.Contains(body, "debugging Mattermost DM replies") {
t.Fatalf("expected memory context to be injected, got %q", body)
}
}
func TestReplyGeneratorInjectsWhatIsRememberedAndNothingAboutHowItWasFound(t *testing.T) {
replyProvider := &capturingReplyProvider{content: "remembered"}
generator := NewGenerator(replyProvider, nil)
longContent := strings.Repeat("a detailed memory that needs summarizing ", 30) + "RAW_TAIL_SHOULD_NOT_APPEAR"
_, errorValue := generator.generateReplyWithMemory(
context.Background(),
"use what you remember",
[]agentcontract.MemoryFact{
{
ScopeType: agentcontract.MemoryScopeWorkspace,
Content: longContent,
Score: 0.87,
SourceEpisodeID: "episode-1",
ValidAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
},
},
)
if errorValue != nil {
t.Fatalf("expected reply generation: %v", errorValue)
}
body := joinChatMessageContent(replyProvider.request.Messages)
if !strings.Contains(body, "What you already know") {
t.Fatalf("expected the remembered statements to be headed, got %q", body)
}
if !strings.Contains(body, "[2026-05-01]") {
t.Fatalf("expected the date a fact is about, got %q", body)
}
for _, retrieval := range []string{"score=0.87", "source=episode-1", "0.87", "episode-1"} {
if strings.Contains(body, retrieval) {
t.Fatalf("expected %q to stay out of the prompt, got %q", retrieval, body)
}
}
if strings.Contains(body, "RAW_TAIL_SHOULD_NOT_APPEAR") {
t.Fatalf("expected long raw memory content to be compacted, got %q", body)
}
}
func TestReplyGeneratorPlacesVisibleContextBeforeMemoryAndPrompt(t *testing.T) {
replyProvider := &capturingReplyProvider{content: "contextual"}
generator := NewGenerator(replyProvider, nil)
_, errorValue := generator.GenerateReplyWithContext(
context.Background(),
"so what should we do?",
agentcontract.VisibleContext{
Messages: []agentcontract.VisibleContextMessage{
{Speaker: "admin", Text: "let's go with A"},
},
HasMoreBefore: true,
HistoryCursor: "cursor-1",
},
[]agentcontract.MemoryFact{
{Content: "the user prefers a design without redundancy."},
},
)
if errorValue != nil {
t.Fatalf("expected reply generation: %v", errorValue)
}
body := joinChatMessageContent(replyProvider.request.Messages)
if len(replyProvider.request.Messages) != 3 {
t.Fatalf("expected system, flattened context, prompt messages, got %d", len(replyProvider.request.Messages))
}
visibleIndex := strings.Index(body, "admin: let's go with A")
memoryIndex := strings.Index(body, "a design without redundancy")
runtimeIndex := strings.Index(body, "Runtime:")
promptIndex := strings.LastIndex(body, "so what should we do?")
if visibleIndex < 0 || memoryIndex < 0 || runtimeIndex < 0 || promptIndex < 0 {
t.Fatalf("expected visible context, memory, runtime, and prompt, got %q", body)
}
if !(visibleIndex < memoryIndex && memoryIndex < runtimeIndex && runtimeIndex < promptIndex) {
t.Fatalf("expected visible context before memory before the volatile runtime timestamp before the final prompt, got %q", body)
}
}
type staticReplyProvider struct {
content string
}
type structuredOnlyReplyProvider struct {
structuredCallCount int
}
type chatCompleterAccessorProvider struct {
structuredProvider model.LanguageModelProvider
chatCompleter model.ChatCompleter
}
func (replyProvider chatCompleterAccessorProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
return replyProvider.structuredProvider.GenerateResponse(responseContext, prompt)
}
func (replyProvider chatCompleterAccessorProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest model.StructuredResponseRequest) (model.StructuredResponse, error) {
return replyProvider.structuredProvider.GenerateStructuredResponse(responseContext, structuredResponseRequest)
}
func (replyProvider chatCompleterAccessorProvider) TextChatCompleter() (model.ChatCompleter, bool) {
return replyProvider.chatCompleter, replyProvider.chatCompleter != nil
}
func (replyProvider staticReplyProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
_ = responseContext
_ = prompt
return replyProvider.content, nil
}
func (replyProvider staticReplyProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest model.StructuredResponseRequest) (model.StructuredResponse, error) {
_ = responseContext
_ = structuredResponseRequest
return model.StructuredResponse{Content: replyProvider.content}, nil
}
func (replyProvider *structuredOnlyReplyProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
_ = responseContext
_ = prompt
return "", nil
}
func (replyProvider *structuredOnlyReplyProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest model.StructuredResponseRequest) (model.StructuredResponse, error) {
_ = responseContext
_ = structuredResponseRequest
replyProvider.structuredCallCount++
return model.StructuredResponse{}, nil
}
type capturingReplyProvider struct {
content string
request model.ChatCompletionRequest
}
type chatReplyProvider struct {
response model.ChatCompletionResponse
chatError error
request model.ChatCompletionRequest
responseContext context.Context
structuredCallCount int
}
func (replyProvider *chatReplyProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
_ = responseContext
_ = prompt
return "", nil
}
func (replyProvider *chatReplyProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest model.StructuredResponseRequest) (model.StructuredResponse, error) {
_ = responseContext
_ = structuredResponseRequest
replyProvider.structuredCallCount++
return model.StructuredResponse{}, nil
}
func (replyProvider *chatReplyProvider) GenerateChatCompletion(responseContext context.Context, request model.ChatCompletionRequest) (model.ChatCompletionResponse, error) {
replyProvider.responseContext = responseContext
replyProvider.request = request
return replyProvider.response, replyProvider.chatError
}
func (replyProvider *capturingReplyProvider) GenerateResponse(responseContext context.Context, prompt string) (string, error) {
_ = responseContext
_ = prompt
return replyProvider.content, nil
}
func (replyProvider *capturingReplyProvider) GenerateStructuredResponse(responseContext context.Context, structuredResponseRequest model.StructuredResponseRequest) (model.StructuredResponse, error) {
_ = responseContext
_ = structuredResponseRequest
return model.StructuredResponse{}, errors.New("structured reply generation is not supported")
}
func (replyProvider *capturingReplyProvider) GenerateChatCompletion(responseContext context.Context, request model.ChatCompletionRequest) (model.ChatCompletionResponse, error) {
_ = responseContext
replyProvider.request = request
return model.ChatCompletionResponse{
Message: model.ChatCompletionMessage{
Role: "assistant",
Content: replyProvider.content,
},
}, nil
}
func joinChatMessageContent(messages []model.ChatCompletionMessage) string {
content := make([]string, 0, len(messages))
for _, message := range messages {
content = append(content, message.Content)
}
return strings.Join(content, "\n")
}