176 lines
7.2 KiB
Go
176 lines
7.2 KiB
Go
package rpc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
|
"git.ipao.vip/rogee/go-sip/internal/configread"
|
|
"git.ipao.vip/rogee/go-sip/internal/dispatcher"
|
|
)
|
|
|
|
type approvedSDKObservation struct {
|
|
Body map[string]any
|
|
Authorization string
|
|
}
|
|
|
|
// finalUserSDKPipeline substitutes only final ASR text; TTS and call sequencing
|
|
// use the actual approved implementations and local SDK/media endpoints.
|
|
type finalUserSDKPipeline struct{ *ai.Call }
|
|
|
|
func (p finalUserSDKPipeline) RunTurn(ctx context.Context, _ []byte) (ai.TurnResult, error) {
|
|
return p.HandleFinalASR(ctx, ai.ASRSegment{Source: "user", Text: "我不用了", Final: true})
|
|
}
|
|
|
|
func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T) {
|
|
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
req := approvedTestRequest(t, now)
|
|
fullJSON := approvedFullTestJSON(t)
|
|
req.TaskConfigJson = fullJSON
|
|
var task configread.Task
|
|
if err := json.Unmarshal(fullJSON, &task); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.TaskId = task.TaskID
|
|
req.SourceEventId, req.CallId = "event-full", "event-full"
|
|
llmObserved := make(chan approvedSDKObservation, 1)
|
|
ttsObserved := make(chan approvedSDKObservation, 3)
|
|
var mockSDKs *httptest.Server
|
|
mockSDKs = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/tts" {
|
|
serveApprovedTTSMock(t, w, r, func(body map[string]any) {
|
|
ttsObserved <- approvedSDKObservation{Body: body, Authorization: r.Header.Get("Authorization")}
|
|
})
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad SDK request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/v1/chat/completions":
|
|
llmObserved <- approvedSDKObservation{Body: body, Authorization: r.Header.Get("Authorization")}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprintln(w, `{"id":"mock","object":"chat.completion","created":0,"model":"example-chat","choices":[{"index":0,"message":{"role":"assistant","content":"回答"},"finish_reason":"stop"}]}`)
|
|
default:
|
|
http.Error(w, "unexpected SDK request", http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer mockSDKs.Close()
|
|
var providers map[string]configread.Provider
|
|
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
llm := providers["openai_compatible"]
|
|
llm.Endpoint = mockSDKs.URL + "/v1"
|
|
providers[llm.Code] = llm
|
|
tts := providers["ali_bailian"]
|
|
tts.WSEndpoint = "ws" + strings.TrimPrefix(mockSDKs.URL, "http") + "/tts"
|
|
providers[tts.Code] = tts
|
|
var err error
|
|
req.ProvidersJson, err = json.Marshal(providers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.BindingSha256, err = configread.ExecutionBindingSHA256(req.TaskConfigJson, req.ProvidersJson, req.SipRevision)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hangups, calls := 0, 0
|
|
agent := activatedApprovedServer(t, now, filepath.Join(t.TempDir(), "agent-session.json"), req.DispatcherId, 1,
|
|
func(ctx context.Context, approved ApprovedExecution) error {
|
|
calls++
|
|
if approved.AI.Mode != "full_ai" || approved.AI.LLM == nil || approved.AI.TTS == nil || approved.AI.Conversation.MaxTurns != 20 || string(approved.AI.LLM.Params["temperature"]) != "0" {
|
|
t.Fatal("approved full-AI settings were not delivered to the Agent")
|
|
}
|
|
session := callflow.NewMemorySession(bytes.Repeat([]byte{1, 0}, 1600))
|
|
call, err := ai.NewCall(approved.AI, func(context.Context) error {
|
|
if session.Stats().SentBytes != 8 {
|
|
t.Fatal("keyword hung up before opening and closing media were sent")
|
|
}
|
|
hangups++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, segment := range []ai.ASRSegment{
|
|
{Source: "user", Text: "不用了", Final: false},
|
|
{Source: "assistant", Text: "不用了", Final: true},
|
|
{Source: "user", Text: "继续", Final: true},
|
|
} {
|
|
if stopped, err := call.HandleFinalASR(ctx, segment); err != nil || stopped.EndedByKeyword {
|
|
t.Fatal("interim, assistant or unmatched ASR text triggered hangup")
|
|
}
|
|
}
|
|
reply, err := approved.AI.Complete(ctx, "继续")
|
|
if err != nil || reply != "回答" {
|
|
return fmt.Errorf("bound LLM SDK request: %w", err)
|
|
}
|
|
audio, err := approved.AI.Synthesize(ctx, reply)
|
|
if err != nil || len(audio) != 4 {
|
|
return fmt.Errorf("bound TTS SDK request: %w", err)
|
|
}
|
|
result, err := callflow.ExecuteApproved(ctx, session, ai.ModeFullAI, finalUserSDKPipeline{call}, callflow.CaptureConfig{FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 20})
|
|
if err != nil || len(result.Turns) != 1 || !result.Turn.EndedByKeyword || result.Turn.Reply != "好的,祝您生活愉快。" || len(result.OutboundTurns) != 2 || hangups != 1 {
|
|
t.Fatalf("SDK keyword closing must play then hang up without another turn: %v", err)
|
|
}
|
|
if _, err := call.RunTurn(ctx, nil); err == nil {
|
|
t.Fatal("another ASR turn started after closing")
|
|
}
|
|
return nil
|
|
})
|
|
client := mockApprovedGRPC(t, agent)
|
|
sipJSON, err := os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sip configread.SIP
|
|
if err := json.Unmarshal(sipJSON, &sip); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orig := &dispatcher.ApprovedOriginator{DispatcherID: req.DispatcherId, Client: client,
|
|
Meta: func(context.Context) (*agentpb.RequestMeta, error) { return testMeta("read", "", 1), nil },
|
|
}
|
|
if err := orig.VerifySIP(context.Background(), sip); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
spec := dispatcher.CallSpec{
|
|
EventID: req.SourceEventId, TaskID: req.TaskId, TenantID: req.TenantId,
|
|
TrunkID: req.SelectedTrunkId, Callee: req.Callee, DialedCallee: req.DialedCallee,
|
|
CallerID: req.CallerId, RingTimeoutMS: req.RingTimeoutMs,
|
|
MaxCallDurationMS: req.MaxCallDurationMs, Deadline: time.UnixMilli(req.DialBeforeUnixMs),
|
|
Snapshot: configread.Snapshot{SIP: sip, Task: task, Providers: providers},
|
|
}
|
|
if err := orig.Originate(context.Background(), spec); err != nil || calls != 1 || hangups != 1 {
|
|
t.Fatalf("full-AI isolated Agent call failed: err=%v calls=%d hangups=%d", err, calls, hangups)
|
|
}
|
|
llmCall := <-llmObserved
|
|
ttsCall, openingCall, closingCall := <-ttsObserved, <-ttsObserved, <-ttsObserved
|
|
if closingCall.Body["input"].(map[string]any)["text"] != "好的,祝您生活愉快。" || len(llmObserved) != 0 || len(ttsObserved) != 0 {
|
|
t.Fatal("keyword closing text changed or extra LLM/TTS requests were sent")
|
|
}
|
|
if llmCall.Authorization != "Bearer "+llm.Credential || llmCall.Body["model"] != "example-chat" || llmCall.Body["temperature"] != float64(0) || llmCall.Body["max_tokens"] != float64(256) {
|
|
t.Fatal("Agent did not send approved LLM values through SDK")
|
|
}
|
|
if ttsCall.Authorization != "Bearer "+tts.Credential || ttsCall.Body["model"] != "qwen3-tts-flash" {
|
|
t.Fatal("Agent did not use approved Bailian TTS model and credential")
|
|
}
|
|
params := ttsCall.Body["parameters"].(map[string]any)
|
|
if openingCall.Body["input"].(map[string]any)["text"] != "Example greeting" || params["voice"] != "Cherry" || ttsCall.Body["input"].(map[string]any)["text"] != "回答" {
|
|
t.Fatal("Agent did not send the approved opening then reply via Bailian TTS")
|
|
}
|
|
}
|