Files
ragflow/internal/agent/audio/tts_dispatch_test.go
Zhichang Yu e45659868a feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)
Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 13:24:03 +08:00

224 lines
7.0 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// tts_dispatch_test.go — verifies that NewTTSDispatchFunc translates
// an audio.Synthesize request into the correct
// ModelProviderService.AudioSpeech call shape and surfaces the
// model's audio bytes back to the audio package as a
// SynthesizeResponse.
package audio
import (
"context"
"errors"
"testing"
"ragflow/internal/common"
modelModule "ragflow/internal/entity/models"
)
// fakeTTSDispatcher records every AudioSpeech invocation and
// returns canned responses. Lives only in the test file.
type fakeTTSDispatcher struct {
// Canned return values.
resp *modelModule.TTSResponse
code common.ErrorCode
err error
// Recorded inputs.
gotProviderName *string
gotInstanceName *string
gotModelName *string
gotModelID *string
gotUserID string
gotAudioContent *string
gotAPIConfig *modelModule.APIConfig
gotTTSConfig *modelModule.TTSConfig
}
func (f *fakeTTSDispatcher) AudioSpeech(
providerName, instanceName, modelName, modelID *string,
userID string,
audioContent *string,
apiConfig *modelModule.APIConfig,
modelConfig *modelModule.TTSConfig,
) (*modelModule.TTSResponse, common.ErrorCode, error) {
f.gotProviderName = providerName
f.gotInstanceName = instanceName
f.gotModelName = modelName
f.gotModelID = modelID
f.gotUserID = userID
f.gotAudioContent = audioContent
f.gotAPIConfig = apiConfig
f.gotTTSConfig = modelConfig
return f.resp, f.code, f.err
}
func TestNewTTSDispatchFunc_HappyPath(t *testing.T) {
fake := &fakeTTSDispatcher{
resp: &modelModule.TTSResponse{Audio: []byte("mp3bytes")},
code: common.CodeSuccess,
}
fn := NewTTSDispatchFunc(fake)
if fn == nil {
t.Fatal("NewTTSDispatchFunc returned nil for non-nil dispatcher")
}
resp, err := fn(context.Background(), ModelProviderRequest{
TenantID: "tenant-1",
ModelName: "tts-fish",
Text: "hello world",
Voice: "en-US-Aria",
Lang: "en-US",
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if string(resp.Audio) != "mp3bytes" {
t.Errorf("Audio = %q, want %q", resp.Audio, "mp3bytes")
}
if resp.MediaType != "audio/mpeg" {
t.Errorf("MediaType = %q, want %q", resp.MediaType, "audio/mpeg")
}
// Field-mapping assertions.
if fake.gotUserID != "tenant-1" {
t.Errorf("userID = %q, want %q", fake.gotUserID, "tenant-1")
}
if fake.gotAudioContent == nil || *fake.gotAudioContent != "hello world" {
t.Errorf("audioContent = %v, want pointer to %q", fake.gotAudioContent, "hello world")
}
if fake.gotModelName == nil || *fake.gotModelName != "tts-fish" {
t.Errorf("modelName = %v, want pointer to %q", fake.gotModelName, "tts-fish")
}
if fake.gotProviderName != nil {
t.Errorf("providerName = %v, want nil (resolved by name)", fake.gotProviderName)
}
if fake.gotInstanceName != nil {
t.Errorf("instanceName = %v, want nil (resolved by name)", fake.gotInstanceName)
}
if fake.gotModelID != nil {
t.Errorf("modelID = %v, want nil (looked up by name)", fake.gotModelID)
}
if fake.gotAPIConfig != nil {
t.Errorf("apiConfig = %v, want nil (no per-request config)", fake.gotAPIConfig)
}
if fake.gotTTSConfig == nil || fake.gotTTSConfig.Params["voice"] != "en-US-Aria" {
t.Errorf("ttsConfig.Params[voice] = %v, want %q", fake.gotTTSConfig, "en-US-Aria")
}
if fake.gotTTSConfig == nil || fake.gotTTSConfig.Params["lang"] != "en-US" {
t.Errorf("ttsConfig.Params[lang] = %v, want %q", fake.gotTTSConfig, "en-US")
}
}
func TestNewTTSDispatchFunc_EmptyModelName(t *testing.T) {
fake := &fakeTTSDispatcher{
resp: &modelModule.TTSResponse{Audio: []byte("x")},
code: common.CodeSuccess,
}
fn := NewTTSDispatchFunc(fake)
_, err := fn(context.Background(), ModelProviderRequest{
TenantID: "t1",
Text: "no model hint",
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if fake.gotModelName != nil {
t.Errorf("modelName = %v, want nil for empty ModelName (let the dispatcher default)", fake.gotModelName)
}
}
func TestNewTTSDispatchFunc_EmptyVoiceAndLang(t *testing.T) {
// Voice and Lang empty → TTSConfig.Params should be nil (not
// an empty map) so the model's default voice/lang take effect.
fake := &fakeTTSDispatcher{
resp: &modelModule.TTSResponse{Audio: []byte("x")},
code: common.CodeSuccess,
}
fn := NewTTSDispatchFunc(fake)
_, err := fn(context.Background(), ModelProviderRequest{TenantID: "t1", Text: "hi"})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if fake.gotTTSConfig == nil {
t.Fatal("TTSConfig is nil")
}
if len(fake.gotTTSConfig.Params) != 0 {
t.Errorf("TTSConfig.Params = %v, want empty/nil (no voice, no lang)", fake.gotTTSConfig.Params)
}
}
func TestNewTTSDispatchFunc_DispatcherError(t *testing.T) {
sentinel := errors.New("dispatch boom")
fake := &fakeTTSDispatcher{err: sentinel}
fn := NewTTSDispatchFunc(fake)
_, err := fn(context.Background(), ModelProviderRequest{TenantID: "t1", Text: "hi"})
if err == nil {
t.Fatal("expected error from dispatcher, got nil")
}
if !errors.Is(err, sentinel) {
t.Errorf("err = %v, want wraps %v", err, sentinel)
}
}
func TestNewTTSDispatchFunc_NonSuccessCode(t *testing.T) {
fake := &fakeTTSDispatcher{
resp: &modelModule.TTSResponse{Audio: []byte("ignored")},
code: common.CodeNotFound,
}
fn := NewTTSDispatchFunc(fake)
_, err := fn(context.Background(), ModelProviderRequest{TenantID: "t1", Text: "hi"})
if err == nil {
t.Fatal("expected error for non-CodeSuccess, got nil")
}
}
func TestNewTTSDispatchFunc_EmptyAudioFromModel(t *testing.T) {
// Some buggy model drivers return nil error + nil TTSResponse
// (or empty audio). The dispatch must surface that as the
// ErrSynthesizeEmpty sentinel so the audio package's caller
// can distinguish "model produced no audio" from a transport
// failure.
cases := []struct {
name string
resp *modelModule.TTSResponse
}{
{"nil response", nil},
{"empty audio", &modelModule.TTSResponse{Audio: nil}},
{"zero-length audio", &modelModule.TTSResponse{Audio: []byte{}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fake := &fakeTTSDispatcher{
resp: c.resp,
code: common.CodeSuccess,
}
fn := NewTTSDispatchFunc(fake)
_, err := fn(context.Background(), ModelProviderRequest{TenantID: "t1", Text: "hi"})
if !errors.Is(err, ErrSynthesizeEmpty) {
t.Errorf("err = %v, want ErrSynthesizeEmpty", err)
}
})
}
}
func TestNewTTSDispatchFunc_NilDispatcher(t *testing.T) {
if fn := NewTTSDispatchFunc(nil); fn != nil {
t.Errorf("NewTTSDispatchFunc(nil) = %v, want nil", fn)
}
}