From 5d5861c8725daf000f7edfe944eddc3e32c8e2e8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 04:48:48 +0800 Subject: [PATCH] Parse explicit bounded Agent Mock media scenario --- cmd/sip-go-agent/current_agent_scenario.go | 69 +++++++++++++++ .../current_agent_scenario_test.go | 85 +++++++++++++++++++ .../saas-dispatcher-implementation.md | 1 + 3 files changed, 155 insertions(+) create mode 100644 cmd/sip-go-agent/current_agent_scenario.go create mode 100644 cmd/sip-go-agent/current_agent_scenario_test.go diff --git a/cmd/sip-go-agent/current_agent_scenario.go b/cmd/sip-go-agent/current_agent_scenario.go new file mode 100644 index 0000000..5efad8c --- /dev/null +++ b/cmd/sip-go-agent/current_agent_scenario.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "strings" + + "git.ipao.vip/rogee/go-sip/internal/ai" +) + +const maxApprovedMockScenarioBytes = 4 << 20 + +type approvedMockScenario struct { + InboundPCM16 []byte + Script ai.ApprovedMockScript + MaxWAVBytes int64 + ExpectedRecording bool + Outcome string + ReasonMessage string +} + +// loadApprovedMockScenario reads only explicit synthetic media facts. It does +// not read or override the AI business configuration delivered by Dispatcher. +func loadApprovedMockScenario(path string) (approvedMockScenario, error) { + if strings.TrimSpace(path) == "" { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE is required") + } + file, err := os.Open(path) + if err != nil { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE cannot be opened") + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxApprovedMockScenarioBytes+1)) + if err != nil || len(data) > maxApprovedMockScenarioBytes { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE exceeds its size limit or cannot be read") + } + var encoded struct { + InboundPCM16 *string `json:"inbound_pcm16"` + Script *ai.ApprovedMockScript `json:"script"` + MaxWAVBytes *int64 `json:"max_wav_bytes"` + ExpectedRecording *bool `json:"expected_recording"` + Outcome *string `json:"outcome"` + ReasonMessage *string `json:"reason_message"` + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&encoded); err != nil { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE has invalid fields") + } + if err := decoder.Decode(new(any)); err != io.EOF { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE contains extra data") + } + if encoded.InboundPCM16 == nil || encoded.Script == nil || encoded.MaxWAVBytes == nil || encoded.ExpectedRecording == nil || encoded.Outcome == nil || encoded.ReasonMessage == nil { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE requires explicit media, script, outcome and recording facts") + } + pcm, err := base64.StdEncoding.DecodeString(*encoded.InboundPCM16) + if err != nil || len(encoded.Script.Turns) == 0 || *encoded.MaxWAVBytes <= 44 || strings.TrimSpace(*encoded.ReasonMessage) == "" || + (*encoded.Outcome != "answered" && *encoded.Outcome != "no_answer" && *encoded.Outcome != "failed") { + return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE has invalid synthetic media or outcome") + } + return approvedMockScenario{ + InboundPCM16: pcm, Script: *encoded.Script, MaxWAVBytes: *encoded.MaxWAVBytes, + ExpectedRecording: *encoded.ExpectedRecording, Outcome: *encoded.Outcome, ReasonMessage: *encoded.ReasonMessage, + }, nil +} diff --git a/cmd/sip-go-agent/current_agent_scenario_test.go b/cmd/sip-go-agent/current_agent_scenario_test.go new file mode 100644 index 0000000..3927360 --- /dev/null +++ b/cmd/sip-go-agent/current_agent_scenario_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/ai" +) + +func writeApprovedMockScenario(t *testing.T, fields map[string]any) string { + t.Helper() + path := filepath.Join(t.TempDir(), "scenario.json") + data, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + return path +} + +func validApprovedMockScenario() map[string]any { + return map[string]any{ + "inbound_pcm16": bytes.Repeat([]byte{1, 0}, 1600), + "script": ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "synthetic final ASR fixture"}}}, + "max_wav_bytes": int64(4096), + "expected_recording": true, + "outcome": "answered", + "reason_message": "isolated Mock answered", + } +} + +func TestLoadApprovedMockScenarioPreservesExplicitSyntheticMedia(t *testing.T) { + path := writeApprovedMockScenario(t, validApprovedMockScenario()) + scenario, err := loadApprovedMockScenario(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(scenario.InboundPCM16, bytes.Repeat([]byte{1, 0}, 1600)) || + len(scenario.Script.Turns) != 1 || scenario.Script.Turns[0].Transcript != "synthetic final ASR fixture" || + scenario.MaxWAVBytes != 4096 || !scenario.ExpectedRecording || scenario.Outcome != "answered" || scenario.ReasonMessage != "isolated Mock answered" { + t.Fatal("explicit isolated Mock facts were changed or silently defaulted") + } +} + +func TestLoadApprovedMockScenarioRequiresExplicitFieldsAndRejectsUnknown(t *testing.T) { + if _, err := loadApprovedMockScenario(""); err == nil { + t.Fatal("missing Mock scenario was defaulted") + } + for _, key := range []string{"inbound_pcm16", "script", "max_wav_bytes", "expected_recording", "outcome", "reason_message"} { + t.Run(key, func(t *testing.T) { + fields := validApprovedMockScenario() + delete(fields, key) + if _, err := loadApprovedMockScenario(writeApprovedMockScenario(t, fields)); err == nil { + t.Fatalf("missing %s was silently defaulted", key) + } + }) + } + fields := validApprovedMockScenario() + fields["legacy_schema_version"] = "v3" + if _, err := loadApprovedMockScenario(writeApprovedMockScenario(t, fields)); err == nil || strings.Contains(err.Error(), "synthetic final ASR fixture") { + t.Fatalf("unknown implementation version was accepted or scenario content leaked: %v", err) + } +} + +func TestLoadApprovedMockScenarioRejectsMalformedAndUnboundedInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "scenario.json") + for _, data := range [][]byte{ + []byte(`{"inbound_pcm16":"%%%","script":{"Turns":[{"Transcript":"synthetic"}]},"max_wav_bytes":4096,"expected_recording":true,"outcome":"answered","reason_message":"isolated"}`), + []byte(`{} {}`), + bytes.Repeat([]byte("x"), 4<<20+1), + } { + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := loadApprovedMockScenario(path); err == nil { + t.Fatal("malformed or oversized Mock scenario was accepted") + } + } +} diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index b910218..fdc57ff 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -60,6 +60,7 @@ - 新增 Agent 任务级在途通话栅栏 `TaskCalls`:按 D/数字租户/任务隔离;暂停或停止先拒新通话,hangup 请求取消、drain 不挂断,两者均等待已登记通话结束才确认;等待超时仍保留关闭栅栏,停止后不能恢复,同一控制重复执行不另设去重。隔离测试验证两任务互不干扰、重复结束不破坏状态。该栅栏现由严格 Agent Mock 组装将已授权执行的**合成**在途通话登记到同一个控制器;主 CLI 仍未使用实际媒体入口,Dispatcher 的持久控制仍为权威,不能据此称真实通话已受控。 - 新增内部任务级 `ApplyApprovedTaskControl` Proto、生成物及 Dispatcher `ApprovedOriginator.SendControl`:D 每次发送前从已激活会话领取元数据,只生成独立 RPC 追踪 ID,不从 SaaS 控制虚构 command_id、幂等键或 revision;Agent 校验活跃 D 会话、数字租户、任务和 pause/stop 的 hangup/drain 政策,在 Mock 中等待已登记活动通话结束后才确认,超时保留本地停止栅栏。隔离测试覆盖拒绝伪 D/旧会话、非法字段与政策、无适配器、未知 RPC 结果不自动重试及重复控制的显式重送;本地双向 TLS gRPC 还验证了受信 D 证书经真实生成 Stub 传送控制后,先挂断再等已登记通话结束才确认,不受信的客户端证书与旧会话均拒绝且不能重新准入。**仅隔离合成 runner 已经登记,实际媒体与新主 CLI 尚未接线**,不能据此称真实通话控制已验收。 - `ApprovedCallWorker` 与 `NewApprovedAgentServer` 强制把已授权执行和控制 RPC 绑定同一个任务栅栏、显式 Mock 模式及 Agent 进程期限;每通话先在无拨号/无业务写入前准备并核验签发 AI、合成媒体和交付目标,准备失败即明确拒绝且不发完成事实;签发拨号时限在异步工人发出接受回执前重验,超时或任务关闭同样在无拨号时明确拒绝;启动请求结束不取消在途通话,任务 hangup 等待 runner 明确结束后才完成控制。隔离测试覆盖先执行回执后合成挂断、进程关闭、失败观察、重复身份防重及缺失/竞争适配器拒绝;未知执行不自动重拨。这里只证明调度与取消顺序,**未在主 CLI 连接真实媒体、录音、失败报告或出站 MQ**。 +- CLI 增加隔离 Mock 场景读取器:只接受显式给出的有界 JSON 文件,合成 PCM16、脚本、录音预期、结果事实和 WAV 上限均须逐项提供;缺失/未知字段、额外 JSON、非法 base64 和超大文件明确拒绝,错误不回显脚本内容。此读取器尚未连接 `agent` 主命令,不能把场景配置或 Mock 结果称为 SaaS 智能体值或真实音频验收。 - 验证:`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`go test ./... -count=1`、`go test -race ./internal/ai ./internal/callflow ./internal/configread ./internal/dispatcher ./internal/rpc -count=1`、`go vet ./...`、`go build ./...`、`git diff --check` 均通过。 - **后续边界:** 录音直传/OSS 失败恢复及最终结果属 P06;新主 CLI 接线、真实媒体完整联动及旧路径清理属 P07;隔离端到端和 A01–A12 属 P08。真实 Agent/Asterisk、SaaS、MQ、AI 供应商联调未开展,不能由 Mock 结果代签。