Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
156 lines
5.0 KiB
Go
156 lines
5.0 KiB
Go
package chatpipeline
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/event"
|
|
"github.com/Tencent/WeKnora/internal/models/chat"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// syncEventBus is a thread-safe recorder; the stream plugin emits from a
|
|
// background goroutine so the test must guard concurrent appends.
|
|
type syncEventBus struct {
|
|
mu sync.Mutex
|
|
events []types.Event
|
|
}
|
|
|
|
func (b *syncEventBus) On(types.EventType, types.EventHandler) {}
|
|
|
|
func (b *syncEventBus) Emit(_ context.Context, evt types.Event) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.events = append(b.events, evt)
|
|
return nil
|
|
}
|
|
|
|
func (b *syncEventBus) finalAnswerContents() []string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
var out []string
|
|
for _, evt := range b.events {
|
|
if evt.Type != types.EventType(event.EventAgentFinalAnswer) {
|
|
continue
|
|
}
|
|
if data, ok := evt.Data.(event.AgentFinalAnswerData); ok {
|
|
out = append(out, data.Content)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// openStreamChat returns a buffered channel preloaded with chunks and never
|
|
// closes it, so the stream plugin blocks on the channel until ctx is cancelled
|
|
// — deterministically exercising the ctx.Done() branch.
|
|
type openStreamChat struct {
|
|
chunks []types.StreamResponse
|
|
closeStream bool
|
|
}
|
|
|
|
func (m *openStreamChat) Chat(context.Context, []chat.Message, *chat.ChatOptions) (*types.ChatResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *openStreamChat) ChatStream(
|
|
context.Context, []chat.Message, *chat.ChatOptions,
|
|
) (<-chan types.StreamResponse, error) {
|
|
ch := make(chan types.StreamResponse, len(m.chunks))
|
|
for _, c := range m.chunks {
|
|
ch <- c
|
|
}
|
|
if m.closeStream {
|
|
close(ch)
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
func (m *openStreamChat) GetModelName() string { return "mock" }
|
|
func (m *openStreamChat) GetModelID() string { return "mock" }
|
|
|
|
// stubModelService only needs GetChatModel; the rest is unused for this test.
|
|
type stubModelService struct {
|
|
interfaces.ModelService
|
|
model chat.Chat
|
|
}
|
|
|
|
func (s *stubModelService) GetChatModel(context.Context, string) (chat.Chat, error) {
|
|
return s.model, nil
|
|
}
|
|
|
|
// TestStreamDropsIncompleteHandleOnCancel verifies that cancellation cannot
|
|
// leak a model-context protocol fragment to the client.
|
|
func TestStreamDropsIncompleteHandleOnCancel(t *testing.T) {
|
|
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
|
|
bus := &syncEventBus{}
|
|
model := &openStreamChat{chunks: []types.StreamResponse{
|
|
// Ends with a partial alias prefix ("res://0"), so the stream decoder
|
|
// holds it back waiting for the rest that never arrives before cancel.
|
|
{ResponseType: types.ResponseTypeAnswer, Content: "hello res://0"},
|
|
}}
|
|
|
|
chatManage := &types.ChatManage{}
|
|
chatManage.SessionID = "sess-cancel"
|
|
chatManage.UserContent = ref // seeds the registry so res://0001 becomes a known alias
|
|
chatManage.EventBus = bus
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
|
require.Nil(t, plugin.OnEvent(ctx, types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
|
|
|
// Wait until the pre-hold content has been emitted, then cancel.
|
|
require.Eventually(t, func() bool {
|
|
for _, c := range bus.finalAnswerContents() {
|
|
if c == "hello " {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, 2*time.Second, 5*time.Millisecond)
|
|
|
|
cancel()
|
|
|
|
// Give the cancellation path time to flush, then assert that only the
|
|
// meaningful prefix was emitted; the incomplete private handle is dropped.
|
|
require.Eventually(t, func() bool { return len(bus.finalAnswerContents()) >= 1 }, 2*time.Second, 5*time.Millisecond)
|
|
time.Sleep(20 * time.Millisecond)
|
|
require.Equal(t, []string{"hello "}, bus.finalAnswerContents())
|
|
}
|
|
|
|
func TestStreamIgnoresDuplicateTerminalAnswer(t *testing.T) {
|
|
bus := &syncEventBus{}
|
|
model := &openStreamChat{closeStream: true, chunks: []types.StreamResponse{
|
|
{ResponseType: types.ResponseTypeAnswer, Content: "hello"},
|
|
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
|
// Some providers repeat the same terminal response when their EOF
|
|
// sentinel is received after finish_reason.
|
|
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
|
}}
|
|
|
|
chatManage := &types.ChatManage{}
|
|
chatManage.SessionID = "sess-duplicate-done"
|
|
chatManage.EventBus = bus
|
|
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
|
require.Nil(t, plugin.OnEvent(context.Background(), types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
|
|
|
require.Eventually(t, func() bool {
|
|
bus.mu.Lock()
|
|
defer bus.mu.Unlock()
|
|
return len(bus.events) == 2
|
|
}, 2*time.Second, 5*time.Millisecond)
|
|
|
|
bus.mu.Lock()
|
|
defer bus.mu.Unlock()
|
|
var answerEvents []event.AgentFinalAnswerData
|
|
for _, evt := range bus.events {
|
|
if evt.Type == types.EventType(event.EventAgentFinalAnswer) {
|
|
answerEvents = append(answerEvents, evt.Data.(event.AgentFinalAnswerData))
|
|
}
|
|
}
|
|
require.Equal(t, []event.AgentFinalAnswerData{{Content: "hello"}, {Done: true}}, answerEvents)
|
|
}
|