Files
xinmi/internal/im/full_output_test.go
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

392 lines
12 KiB
Go

package im
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type fullOutputOrder struct {
mu sync.Mutex
steps []string
}
func lifecycleIMContext() context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.UserIDContextKey, "system-1")
return types.WithPrincipal(ctx, types.Principal{Type: types.PrincipalIMUser, ID: "test-user"})
}
func (o *fullOutputOrder) add(step string) {
o.mu.Lock()
defer o.mu.Unlock()
o.steps = append(o.steps, step)
}
func (o *fullOutputOrder) snapshot() []string {
o.mu.Lock()
defer o.mu.Unlock()
return append([]string(nil), o.steps...)
}
type fullOutputSessionService struct {
interfaces.SessionService
order *fullOutputOrder
answer string
hangUntilCancel bool
started chan struct{}
}
func (s *fullOutputSessionService) KnowledgeQA(ctx context.Context, req *types.QARequest, bus *event.EventBus) error {
s.order.add("qa")
if s.started != nil {
close(s.started)
}
if s.hangUntilCancel {
<-ctx.Done()
return ctx.Err()
}
return bus.Emit(ctx, event.Event{
ID: "answer-1",
Type: event.EventAgentFinalAnswer,
SessionID: req.Session.ID,
Data: event.AgentFinalAnswerData{
Content: s.answer,
Done: true,
},
})
}
type fullOutputMessageService struct {
interfaces.MessageService
}
func (s *fullOutputMessageService) CreateMessage(_ context.Context, msg *types.Message) (*types.Message, error) {
created := *msg
created.ID = created.Role + "-message"
return &created, nil
}
func (s *fullOutputMessageService) UpdateMessage(_ context.Context, _ *types.Message) error {
return nil
}
type fullOutputStreamManager struct {
interfaces.StreamManager
}
func (s *fullOutputStreamManager) AppendEvent(_ context.Context, _, _ string, _ interfaces.StreamEvent) error {
return nil
}
func (s *fullOutputStreamManager) GetEvents(
_ context.Context, _, _ string, from int,
) ([]interfaces.StreamEvent, int, error) {
return nil, from, nil
}
type fullOutputAdapter struct {
Adapter
order *fullOutputOrder
finalContent string
updates int
plainReplies int
plainContent string
startErr error
finalizeErr error
sendErr error
}
func (a *fullOutputAdapter) SendReply(ctx context.Context, _ *IncomingMessage, reply *ReplyMessage) error {
if err := ctx.Err(); err != nil {
return err
}
if a.sendErr != nil {
a.order.add("plain-reply")
return a.sendErr
}
a.plainReplies++
if reply != nil {
a.plainContent = reply.Content
}
a.order.add("plain-reply")
return nil
}
func (a *fullOutputAdapter) StartStream(ctx context.Context, _ *IncomingMessage) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
a.order.add("start")
if a.startErr != nil {
return "", a.startErr
}
return "full-output-stream", nil
}
func (a *fullOutputAdapter) SupportsFullOutputProgress() bool {
return true
}
func (a *fullOutputAdapter) UpdateStreamContent(ctx context.Context, _ *IncomingMessage, _ string, _ string) error {
if err := ctx.Err(); err != nil {
return err
}
a.updates++
a.order.add("update")
return nil
}
func (a *fullOutputAdapter) FinalizeStream(ctx context.Context, _ *IncomingMessage, _ string, content string) error {
if err := ctx.Err(); err != nil {
return err
}
a.finalContent = content
a.order.add("finalize")
return a.finalizeErr
}
func (a *fullOutputAdapter) EndStream(ctx context.Context, _ *IncomingMessage, _ string) error {
if err := ctx.Err(); err != nil {
return err
}
a.order.add("end")
return nil
}
type fullOutputNoProgressAdapter struct {
*fullOutputAdapter
}
func (a *fullOutputNoProgressAdapter) SupportsFullOutputProgress() bool {
return false
}
func newFullOutputHarness(answer string) (*Service, *fullOutputAdapter, *fullOutputSessionService, *fullOutputOrder) {
order := &fullOutputOrder{}
adapter := &fullOutputAdapter{order: order}
sessionSvc := &fullOutputSessionService{order: order, answer: answer}
service := &Service{
sessionService: sessionSvc,
messageService: &fullOutputMessageService{},
streamManager: &fullOutputStreamManager{},
}
return service, adapter, sessionSvc, order
}
func TestHandleMessageFullOutputShowsPlaceholderWithoutIntermediateUpdates(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.updates != 0 {
t.Fatalf("full output sent %d intermediate updates, want 0", adapter.updates)
}
if adapter.finalContent != "最终答案" {
t.Fatalf("final content = %q, want %q", adapter.finalContent, "最终答案")
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestHandleMessageFullOutputReplacesPlaceholderAfterCancel(t *testing.T) {
service, adapter, sessionSvc, order := newFullOutputHarness("")
sessionSvc.hangUntilCancel = true
sessionSvc.started = make(chan struct{})
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
ctx, cancel := context.WithCancel(lifecycleIMContext())
done := make(chan error, 1)
go func() {
done <- service.handleMessageFullOutput(
ctx, msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
}()
select {
case <-sessionSvc.started:
case <-time.After(2 * time.Second):
t.Fatal("QA did not start")
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for full-output after cancel")
}
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.finalContent != imCancelledFallback {
t.Fatalf("final content = %q, want cancel fallback %q", adapter.finalContent, imCancelledFallback)
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestHandleMessageFullOutputStartStreamErrorFallsBackToPlainReply(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
adapter.startErr = errors.New("create card failed")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
wantOrder := []string{"start", "qa", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 1 {
t.Fatalf("plain fallback replies = %d, want 1", adapter.plainReplies)
}
if adapter.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", adapter.plainContent, "最终答案")
}
if adapter.finalContent != "" {
t.Fatalf("FinalizeStream ran after StartStream failure, content=%q", adapter.finalContent)
}
}
func TestHandleMessageFullOutputFinalizeFailureSendsPlainReply(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
adapter.finalizeErr = errors.New("card update failed")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("successful plain fallback should not return error, got %v", err)
}
wantOrder := []string{"start", "qa", "finalize", "end", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 1 {
t.Fatalf("plain fallback replies = %d, want 1", adapter.plainReplies)
}
if adapter.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", adapter.plainContent, "最终答案")
}
}
func TestHandleMessageFullOutputThinkOnlyReplacesWithNoAnswerFallback(t *testing.T) {
service, adapter, _, _ := newFullOutputHarness("<think>only reasoning</think>")
msg := &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"}
session := &types.Session{ID: "session-1"}
err := service.handleMessageFullOutput(
context.Background(), msg, session, nil, nil, nil, nil, adapter, adapter, "user-key", nil,
)
if err != nil {
t.Fatalf("handleMessageFullOutput() error = %v", err)
}
if adapter.finalContent != imNoAnswerFallback {
t.Fatalf("final content = %q, want %q", adapter.finalContent, imNoAnswerFallback)
}
}
func TestExecuteQARequestFullOutputDispatchesToProgressSender(t *testing.T) {
service, adapter, _, order := newFullOutputHarness("最终答案")
service.db = newLifecycleTestDB(t)
channel := createLifecycleChannel(t, service.db, "progress-channel", "progress-agent")
ctx, cancel := context.WithCancel(lifecycleIMContext())
service.executeQARequest(&qaRequest{
ctx: ctx,
cancel: cancel,
msg: &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"},
session: &types.Session{ID: "session-1"},
adapter: adapter,
channel: channel,
userKey: "user-key",
})
wantOrder := []string{"start", "qa", "finalize", "end"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if adapter.plainReplies != 0 {
t.Fatalf("plain fallback replies = %d, want 0", adapter.plainReplies)
}
}
func TestExecuteQARequestFullOutputSkipsProgressWhenNotSupported(t *testing.T) {
service, inner, _, order := newFullOutputHarness("最终答案")
service.db = newLifecycleTestDB(t)
channel := createLifecycleChannel(t, service.db, "plain-channel", "plain-agent")
adapter := &fullOutputNoProgressAdapter{fullOutputAdapter: inner}
ctx, cancel := context.WithCancel(lifecycleIMContext())
service.executeQARequest(&qaRequest{
ctx: ctx,
cancel: cancel,
msg: &IncomingMessage{Platform: PlatformFeishu, UserID: "user-1", Content: "问题"},
session: &types.Session{ID: "session-1"},
adapter: adapter,
channel: channel,
userKey: "user-key",
})
wantOrder := []string{"qa", "plain-reply"}
if got := order.snapshot(); !reflect.DeepEqual(got, wantOrder) {
t.Fatalf("lifecycle order = %v, want %v", got, wantOrder)
}
if inner.plainContent != "最终答案" {
t.Fatalf("plain reply = %q, want %q", inner.plainContent, "最终答案")
}
}
func TestFormatIMOutboundAnswerOrFallbackAfterCleanup(t *testing.T) {
got := formatIMOutboundAnswerOrFallback(
context.Background(), "<think>only reasoning</think>", nil, nil,
)
if got != imNoAnswerFallback {
t.Fatalf("think-only final content = %q, want fallback %q", got, imNoAnswerFallback)
}
}
func TestIMQAFailureReply(t *testing.T) {
if got := imQAFailureReply(context.Canceled); got != imCancelledFallback {
t.Fatalf("canceled = %q, want %q", got, imCancelledFallback)
}
if got := imQAFailureReply(context.DeadlineExceeded); got != imCancelledFallback {
t.Fatalf("deadline = %q, want %q", got, imCancelledFallback)
}
if got := imQAFailureReply(errors.New("boom")); got != imErrorFallback {
t.Fatalf("other error = %q, want %q", got, imErrorFallback)
}
}