Ctrl K
ComunidadGuías…

internal/reply/generator_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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")
}