Bound approved Agent calls by signed duration

This commit is contained in:
2026-09-29 22:29:02 +08:00
parent c61858fded
commit 428d587f45
5 changed files with 186 additions and 6 deletions
+28
View File
@@ -0,0 +1,28 @@
package rpc
import (
"context"
"errors"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/callflow"
)
// RunApprovedCall is the isolated Agent media entry point. Both the
// Dispatcher-signed call limit and the immutable AI conversation limit apply;
// neither missing media nor a missing hangup adapter can silently proceed.
func RunApprovedCall(ctx context.Context, approved ApprovedExecution, session callflow.MediaSession, hangup func(context.Context) error) (callflow.Result, error) {
if session == nil || hangup == nil || approved.MaxCallDuration <= 0 {
return callflow.Result{}, errors.New("approved Agent media, hangup and signed call duration are required")
}
if approved.AI.Mode != string(ai.ModeFullAI) && approved.AI.Mode != string(ai.ModeASROnly) {
return callflow.Result{}, errors.New("approved AI mode is unsupported")
}
call, err := ai.NewCurrentCall(approved.AI, hangup)
if err != nil {
return callflow.Result{}, err
}
ctx, cancel := context.WithTimeout(ctx, approved.MaxCallDuration)
defer cancel()
return callflow.ExecuteApproved(ctx, session, ai.Mode(approved.AI.Mode), call, callflow.ApprovedCapture(approved.AI))
}
+103
View File
@@ -0,0 +1,103 @@
package rpc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/media"
)
type blockedApprovedMedia struct {
sent int
rate int
}
func (*blockedApprovedMedia) ReadPayload(ctx context.Context) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (m *blockedApprovedMedia) SendPCM16(ctx context.Context, pcm []byte, rate int) error {
if err := ctx.Err(); err != nil {
return err
}
m.sent++
m.rate = rate
return nil
}
func (*blockedApprovedMedia) Stats() media.RTPStats { return media.RTPStats{} }
func TestApprovedCallRunnerASROnlyHonorsSignedTimeout(t *testing.T) {
req := approvedTestRequest(t, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC))
var task configread.CurrentTask
if err := json.Unmarshal(req.TaskConfigJson, &task); err != nil {
t.Fatal(err)
}
var providers map[string]configread.CurrentProvider
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
t.Fatal(err)
}
bound, err := ai.BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
mediaSession := &blockedApprovedMedia{}
_, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: bound, MaxCallDuration: 35 * time.Millisecond}, mediaSession, func(context.Context) error { return nil })
if err == nil || !strings.Contains(err.Error(), "deadline") || mediaSession.sent != 0 {
t.Fatalf("ASR-only call must timeout without opening or outbound TTS: sent=%d err=%v", mediaSession.sent, err)
}
}
func TestApprovedCallRunnerSynthesizesOpeningBeforeMediaCapture(t *testing.T) {
raw, err := os.ReadFile("../../contracts/local/examples/config-read-task-full.json")
if err != nil {
t.Fatal(err)
}
var task configread.CurrentTask
if err := json.Unmarshal(raw, &task); err != nil {
t.Fatal(err)
}
req := approvedTestRequest(t, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC))
var providers map[string]configread.CurrentProvider
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
t.Fatal(err)
}
var sdkCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sdkCalls.Add(1)
var payload struct {
Params struct {
Text string `json:"text"`
} `json:"req_params"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.Params.Text != "Example greeting" {
http.Error(w, "opening text changed", http.StatusBadRequest)
return
}
_, _ = fmt.Fprintf(w, `{"code":0,"data":%q}`+"\n", base64.StdEncoding.EncodeToString([]byte{1, 0, 2, 0}))
_, _ = fmt.Fprintln(w, `{"code":20000000,"message":"ok","data":null}`)
}))
defer server.Close()
tts := providers["tts-example"]
tts.Endpoint = server.URL
providers[tts.ProviderRef] = tts
bound, err := ai.BindCurrent(task, providers)
if err != nil {
t.Fatal(err)
}
mediaSession := &blockedApprovedMedia{}
_, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: bound, MaxCallDuration: 100 * time.Millisecond}, mediaSession, func(context.Context) error { return nil })
if err == nil || !strings.Contains(err.Error(), "deadline") || sdkCalls.Load() != 1 || mediaSession.sent != 1 || mediaSession.rate != 16000 {
t.Fatalf("approved opening must be played once before timed capture: requests=%d sent=%d rate=%d err=%v", sdkCalls.Load(), mediaSession.sent, mediaSession.rate, err)
}
}